aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api.js47
-rw-r--r--src/app.js94
-rw-r--r--src/electrum.js237
-rw-r--r--src/i18n.js6
-rw-r--r--src/wallet.js113
5 files changed, 438 insertions, 59 deletions
diff --git a/src/api.js b/src/api.js
index b981bc6..feac78f 100644
--- a/src/api.js
+++ b/src/api.js
@@ -71,11 +71,56 @@ export function setRealtimeEnabled(on) {
try { localStorage.setItem(REALTIME_KEY, on ? 'on' : 'off'); } catch {}
}
+// Data backend: the default Esplora REST (`Api`) or a full Electrum-over-WS
+// server (`ElectrumApi`) that serves data AND notifications over one connection.
+const BACKEND_KEY = 'btc-wallet-backend';
+export function getBackend() {
+ try { return localStorage.getItem(BACKEND_KEY) === 'electrum' ? 'electrum' : 'esplora'; } catch { return 'esplora'; }
+}
+export function setBackend(b) {
+ try { localStorage.setItem(BACKEND_KEY, b === 'electrum' ? 'electrum' : 'esplora'); } catch {}
+}
+
+// Electrum servers that accept WebSocket connections. blockstream.info is
+// electrs (validated); the point, though, is pointing at your own Fulcrum/electrs.
+export const ELECTRUM_PRESETS = [
+ { id: 'blockstream', label: 'blockstream.info', url: 'wss://blockstream.info/electrum-websocket/' },
+ { id: 'jochen', label: 'electrum.jochen-hoenicke.de', url: 'wss://electrum.jochen-hoenicke.de:50010' },
+ { id: 'custom', label: 'Custom (your node)', url: '' },
+];
+const ELECTRUM_SERVER_KEY = 'btc-wallet-electrum-server';
+export function getElectrumServerConfig() {
+ try {
+ const c = JSON.parse(localStorage.getItem(ELECTRUM_SERVER_KEY) || 'null');
+ if (c && c.server) return { server: c.server, url: c.url || '' };
+ } catch {}
+ return { server: 'blockstream', url: '' };
+}
+export function setElectrumServerConfig({ server, url }) {
+ try { localStorage.setItem(ELECTRUM_SERVER_KEY, JSON.stringify({ server, url: url || '' })); } catch {}
+}
+export function resolveElectrumUrl() {
+ const cfg = getElectrumServerConfig();
+ if (cfg.server === 'custom') return cfg.url.trim() || null;
+ const p = ELECTRUM_PRESETS.find((x) => x.id === cfg.server);
+ return (p && p.url) || ELECTRUM_PRESETS[0].url;
+}
+
export function wsUrl(net) {
- if (net === 'testnet' || !getRealtimeEnabled()) return null;
+ if (net === 'testnet') return null;
+ // Electrum backend: the socket IS the data source, so it's always wanted
+ // (independent of the coinos-notification opt-out, which only governs Esplora).
+ if (getBackend() === 'electrum') return resolveElectrumUrl();
+ if (!getRealtimeEnabled()) return null;
return getElectrumWsUrl();
}
+// Web base for tx links (Electrum has no web UI — reuse the chosen explorer host).
+export function explorerWeb() {
+ const hosts = resolveHosts('mainnet');
+ return (hosts[0] && hosts[0].web) || 'https://mempool.space';
+}
+
const REQUEST_TIMEOUT_MS = 10000;
export class Api {
diff --git a/src/app.js b/src/app.js
index d7fbc4e..6a994d8 100644
--- a/src/app.js
+++ b/src/app.js
@@ -8,7 +8,7 @@ import { Wallet, newMnemonic, isValidMnemonic, utxoId, previewGift, giftOutpoint
import { qrSvg } from './qr.js';
import { scanQr } from './scan.js';
import { getSyncConfig, setSyncConfig } from './nostr.js';
-import { getExplorerConfig, setExplorerConfig, EXPLORER_PRESETS, getRealtimeEnabled, setRealtimeEnabled } from './api.js';
+import { getExplorerConfig, setExplorerConfig, EXPLORER_PRESETS, getRealtimeEnabled, setRealtimeEnabled, getBackend, setBackend, ELECTRUM_PRESETS, getElectrumServerConfig, setElectrumServerConfig } from './api.js';
import { isSilentPaymentAddress } from './silentpay.js';
import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js';
import {
@@ -1298,7 +1298,9 @@ async function doRevoke(id) {
// Block explorer / server selection: a preset (mempool.space, blockstream.info)
// or a custom Esplora/electrs REST URL (e.g. your own node).
function explorerCard() {
+ const backend = getBackend();
const cfg = getExplorerConfig();
+ const ecfg = getElectrumServerConfig();
const rt = getRealtimeEnabled();
const setRealtime = (on) => {
if (on === getRealtimeEnabled()) return;
@@ -1306,49 +1308,69 @@ function explorerCard() {
render();
if (!wallet.offline) wallet.startRealtime(); // reconnect or drop the watcher
};
+ const setBackendTo = (b) => {
+ if (b === getBackend()) return;
+ setBackend(b);
+ render();
+ if (!wallet.offline) wallet.reloadExplorer(); // rebuild api + reconnect
+ };
return h(
'div',
{ class: 'card col' },
h('h3', {}, t('explorer')),
h('p', { class: 'small muted', style: 'margin:0' }, t('explorerDesc')),
- h(
- 'select',
- {
- onChange: (e) => {
- const server = e.target.value;
- setExplorerConfig({ server, url: cfg.url });
- render();
- if (server !== 'custom' || cfg.url) wallet.reloadExplorer();
- },
- },
- EXPLORER_PRESETS.map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.label))
- ),
- cfg.server === 'custom'
- ? h('label', { class: 'field' },
- h('span', { class: 'lab' }, t('explorerUrl')),
- h('input', {
- type: 'text', class: 'mono-input', placeholder: 'https://mempool.space/api',
- autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
- value: cfg.url,
- // Apply on blur/Enter, not every keystroke (each change rescans).
- onChange: (e) => {
- setExplorerConfig({ server: 'custom', url: e.target.value.trim() });
- wallet.reloadExplorer();
- },
- }),
- h('div', { class: 'small faint' }, t('explorerUrlHint'))
- )
- : null,
- // Realtime via coinos — the one place addresses are shared off-device. Opt
- // out to poll the explorer above instead (a bit slower, nothing told to coinos).
- h('div', { class: 'row between', style: 'margin-top:8px' },
- h('span', { class: 'lab', style: 'margin:0' }, t('instantNotif')),
+ // Data source: Esplora REST, or a full Electrum-over-WS server (data + push).
+ h('div', { class: 'row between', style: 'margin-top:2px' },
+ h('span', { class: 'lab', style: 'margin:0' }, t('dataSource')),
h('div', { class: 'seg' },
- h('button', { type: 'button', class: rt ? 'active' : '', onClick: () => setRealtime(true) }, t('syncOn')),
- h('button', { type: 'button', class: !rt ? 'active' : '', onClick: () => setRealtime(false) }, t('syncOff'))
+ h('button', { type: 'button', class: backend === 'esplora' ? 'active' : '', onClick: () => setBackendTo('esplora') }, t('backendEsplora')),
+ h('button', { type: 'button', class: backend === 'electrum' ? 'active' : '', onClick: () => setBackendTo('electrum') }, t('backendElectrum'))
)
),
- h('div', { class: 'small faint' }, t('instantNotifDesc'))
+ backend === 'electrum'
+ ? h('div', { class: 'col', style: 'gap:8px' },
+ h('div', { class: 'small faint' }, t('backendElectrumDesc')),
+ h('select', {
+ onChange: (e) => { setElectrumServerConfig({ server: e.target.value, url: ecfg.url }); render(); if (e.target.value !== 'custom' || ecfg.url) wallet.reloadExplorer(); },
+ }, ELECTRUM_PRESETS.map((o) => h('option', { value: o.id, selected: o.id === ecfg.server }, o.label))),
+ ecfg.server === 'custom'
+ ? h('label', { class: 'field' },
+ h('span', { class: 'lab' }, t('electrumUrl')),
+ h('input', {
+ type: 'text', class: 'mono-input', placeholder: 'wss://your-node:50004',
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ecfg.url,
+ onChange: (e) => { setElectrumServerConfig({ server: 'custom', url: e.target.value.trim() }); wallet.reloadExplorer(); },
+ }),
+ h('div', { class: 'small faint' }, t('electrumUrlHint'))
+ )
+ : null
+ )
+ : h('div', { class: 'col', style: 'gap:8px' },
+ h('select', {
+ onChange: (e) => { setExplorerConfig({ server: e.target.value, url: cfg.url }); render(); if (e.target.value !== 'custom' || cfg.url) wallet.reloadExplorer(); },
+ }, EXPLORER_PRESETS.map((o) => h('option', { value: o.id, selected: o.id === cfg.server }, o.label))),
+ cfg.server === 'custom'
+ ? h('label', { class: 'field' },
+ h('span', { class: 'lab' }, t('explorerUrl')),
+ h('input', {
+ type: 'text', class: 'mono-input', placeholder: 'https://mempool.space/api',
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: cfg.url,
+ onChange: (e) => { setExplorerConfig({ server: 'custom', url: e.target.value.trim() }); wallet.reloadExplorer(); },
+ }),
+ h('div', { class: 'small faint' }, t('explorerUrlHint'))
+ )
+ : null,
+ // Realtime via coinos — the one place addresses are shared off-device.
+ // (Electrum already serves its own push, so this only applies to REST.)
+ h('div', { class: 'row between', style: 'margin-top:8px' },
+ h('span', { class: 'lab', style: 'margin:0' }, t('instantNotif')),
+ h('div', { class: 'seg' },
+ h('button', { type: 'button', class: rt ? 'active' : '', onClick: () => setRealtime(true) }, t('syncOn')),
+ h('button', { type: 'button', class: !rt ? 'active' : '', onClick: () => setRealtime(false) }, t('syncOff'))
+ )
+ ),
+ h('div', { class: 'small faint' }, t('instantNotifDesc'))
+ )
);
}
diff --git a/src/electrum.js b/src/electrum.js
new file mode 100644
index 0000000..57f8c34
--- /dev/null
+++ b/src/electrum.js
@@ -0,0 +1,237 @@
+// Electrum-over-WebSocket backend — an alternative to the Esplora REST `Api`.
+//
+// A single Electrum connection answers the wallet's data queries AND pushes
+// `scripthash.subscribe` notifications, so pointing Hal at your own Fulcrum /
+// electrs gives a fully self-hosted setup: no coinos, no public REST, nothing
+// shared with anyone but your own server.
+//
+// This class only maps the data calls into the Esplora shapes the wallet already
+// consumes; the persistent connection (request/response correlation + reconnect)
+// lives in wallet.js, which hands us a `call(method, params)` transport. Electrum
+// addresses everything by scripthash, and electrs doesn't support verbose tx
+// lookups, so we fetch raw transactions and parse them ourselves (resolving input
+// values from parent txs to compute fees) — cached, since confirmed txs are
+// immutable.
+
+import * as btc from '@scure/btc-signer';
+import { sha256 } from '@noble/hashes/sha256';
+import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
+
+const RAW_OPTS = { allowUnknownOutputs: true, allowUnknownInputs: true, disableScriptCheck: true };
+
+// Bounded-concurrency map (results in input order) — keeps history reconstruction
+// fast without flooding the server with one request per tx/parent at once.
+async function parallelMap(items, concurrency, worker) {
+ const results = new Array(items.length);
+ let next = 0;
+ const run = async () => { while (next < items.length) { const i = next++; results[i] = await worker(items[i], i); } };
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run));
+ return results;
+}
+
+// Electrum scripthash: sha256(scriptPubKey), byte-reversed, hex.
+export function scripthashOfScript(script) {
+ const h = sha256(script);
+ h.reverse();
+ return bytesToHex(h);
+}
+
+export class ElectrumApi {
+ // call: (method, params) => Promise<result> (provided by wallet.js)
+ // network: a @scure/btc-signer network object (NETWORK / TEST_NETWORK)
+ // testnet: bool, only affects the explorer web link
+ constructor({ call, network, testnet = false, explorerWeb = 'https://mempool.space' }) {
+ this.call = call;
+ this.network = network;
+ this.testnet = testnet;
+ this.offline = false;
+ this._explorerWeb = explorerWeb;
+ this._raw = new Map(); // txid -> raw hex (immutable)
+ this._tx = new Map(); // txid -> Esplora-shape tx (confirmed only)
+ this._time = new Map(); // block height -> block time
+ }
+
+ explorerTx(txid) {
+ return this._explorerWeb + (this.testnet ? '/testnet/tx/' : '/tx/') + txid;
+ }
+
+ _sh(address) {
+ return scripthashOfScript(btc.OutScript.encode(btc.Address(this.network).decode(address)));
+ }
+
+ // ---- address-level queries (cheap) ------------------------------------
+ async addressInfo(address) {
+ const sh = this._sh(address);
+ const [bal, hist] = await Promise.all([
+ this.call('blockchain.scripthash.get_balance', [sh]),
+ this.call('blockchain.scripthash.get_history', [sh]),
+ ]);
+ const confTxs = hist.filter((h) => h.height > 0).length;
+ const u = bal.unconfirmed || 0;
+ // We only ever read (funded − spent); express the net balance that way.
+ return {
+ chain_stats: { tx_count: confTxs, funded_txo_sum: bal.confirmed || 0, spent_txo_sum: 0 },
+ mempool_stats: {
+ tx_count: hist.length - confTxs,
+ funded_txo_sum: u >= 0 ? u : 0,
+ spent_txo_sum: u < 0 ? -u : 0,
+ },
+ };
+ }
+
+ async addressUtxos(address) {
+ const u = await this.call('blockchain.scripthash.listunspent', [this._sh(address)]);
+ return u.map((x) => ({
+ txid: x.tx_hash,
+ vout: x.tx_pos,
+ value: x.value,
+ status: { confirmed: x.height > 0, block_height: x.height > 0 ? x.height : undefined },
+ }));
+ }
+
+ async addressTxs(address) {
+ const hist = await this.call('blockchain.scripthash.get_history', [this._sh(address)]);
+ // Esplora returns newest-first: mempool (height ≤ 0) first, then height desc.
+ const sorted = hist.slice().sort((a, b) => {
+ const ha = a.height > 0 ? a.height : Infinity;
+ const hb = b.height > 0 ? b.height : Infinity;
+ return hb - ha;
+ });
+ return parallelMap(sorted, 6, (h) => this._esploraTx(h.tx_hash, h.height > 0 ? h.height : 0));
+ }
+
+ // ---- transactions ------------------------------------------------------
+ async _rawTx(txid) {
+ if (this._raw.has(txid)) return this._raw.get(txid);
+ // Dedup concurrent fetches of the same tx (parents are shared across a batch).
+ if (!this._rawInflight) this._rawInflight = new Map();
+ let p = this._rawInflight.get(txid);
+ if (!p) {
+ p = this.call('blockchain.transaction.get', [txid]).then((raw) => { this._raw.set(txid, raw); this._rawInflight.delete(txid); return raw; }, (e) => { this._rawInflight.delete(txid); throw e; });
+ this._rawInflight.set(txid, p);
+ }
+ return p;
+ }
+
+ async _parsed(txid) {
+ return btc.Transaction.fromRaw(hexToBytes(await this._rawTx(txid)), RAW_OPTS);
+ }
+
+ _addrOf(script) {
+ try { return btc.Address(this.network).encode(btc.OutScript.decode(script)); } catch { return undefined; }
+ }
+
+ async _blockTime(height) {
+ if (height <= 0) return 0;
+ if (this._time.has(height)) return this._time.get(height);
+ const hdr = hexToBytes(await this.call('blockchain.block.header', [height])); // 80-byte header
+ const t = new DataView(hdr.buffer, hdr.byteOffset, hdr.byteLength).getUint32(68, true); // timestamp @ offset 68 (LE)
+ this._time.set(height, t);
+ return t;
+ }
+
+ // Build the Esplora-shape tx the wallet's _txSummary expects: vin[].prevout
+ // (address + value, resolved from parent txs), vout[] (address + value), fee,
+ // weight, and confirmation status (height comes from the caller's get_history).
+ async _esploraTx(txid, height) {
+ const cached = this._tx.get(txid);
+ if (cached) return cached; // only confirmed (immutable) txs are cached
+ const tx = await this._parsed(txid);
+
+ const vout = [];
+ let outSum = 0n;
+ for (let i = 0; i < tx.outputsLength; i++) {
+ const o = tx.getOutput(i);
+ outSum += o.amount;
+ vout.push({ scriptpubkey_address: this._addrOf(o.script), value: Number(o.amount) });
+ }
+
+ // Resolve every input's prevout (address + value) from its parent tx, in
+ // parallel — needed to compute the fee and which inputs are ours.
+ const vin = await parallelMap(Array.from({ length: tx.inputsLength }, (_, i) => i), 6, async (i) => {
+ const inp = tx.getInput(i);
+ const ptxid = bytesToHex(inp.txid); // btc-signer stores txids in display order
+ if (/^0+$/.test(ptxid)) return { is_coinbase: true };
+ const ptx = await this._parsed(ptxid);
+ const po = ptx.getOutput(inp.index);
+ return { txid: ptxid, vout: inp.index, prevout: { scriptpubkey_address: this._addrOf(po.script), value: Number(po.amount) }, _value: po.amount };
+ });
+ let inSum = 0n;
+ let coinbase = false;
+ for (const v of vin) { if (v.is_coinbase) coinbase = true; else { inSum += v._value; delete v._value; } }
+
+ const confirmed = height > 0;
+ const result = {
+ txid,
+ vin,
+ vout,
+ fee: coinbase ? 0 : Number(inSum - outSum),
+ weight: tx.weight,
+ status: {
+ confirmed,
+ block_height: confirmed ? height : undefined,
+ block_time: confirmed ? await this._blockTime(height) : 0,
+ },
+ };
+ if (confirmed) this._tx.set(txid, result);
+ return result;
+ }
+
+ // Standalone tx fetch (lazy fee fill / gift check). Electrum's raw tx carries
+ // no height, so recover it from an output's scripthash history.
+ async getTx(txid) {
+ let height = 0;
+ try {
+ const tx = await this._parsed(txid);
+ if (tx.outputsLength) {
+ const hist = await this.call('blockchain.scripthash.get_history', [scripthashOfScript(tx.getOutput(0).script)]);
+ const e = hist.find((x) => x.tx_hash === txid);
+ if (e && e.height > 0) height = e.height;
+ }
+ } catch {}
+ return this._esploraTx(txid, height);
+ }
+
+ // Is output (txid:vout) spent? (used to detect an already-claimed gift). Scan
+ // the output address's history for a tx that spends this exact outpoint.
+ async outspend(txid, vout) {
+ const tx = await this._parsed(txid);
+ if (vout >= tx.outputsLength) return { spent: false };
+ const sh = scripthashOfScript(tx.getOutput(vout).script);
+ const hist = await this.call('blockchain.scripthash.get_history', [sh]);
+ for (const h of hist) {
+ if (h.tx_hash === txid) continue;
+ const spender = await this._parsed(h.tx_hash);
+ for (let i = 0; i < spender.inputsLength; i++) {
+ const inp = spender.getInput(i);
+ if (bytesToHex(inp.txid) === txid && inp.index === vout) {
+ return { spent: true, txid: h.tx_hash, vin: i, status: { confirmed: h.height > 0, block_height: h.height > 0 ? h.height : undefined } };
+ }
+ }
+ }
+ return { spent: false };
+ }
+
+ // ---- fees + broadcast --------------------------------------------------
+ async feeRates() {
+ const targets = { fastestFee: 1, halfHourFee: 3, hourFee: 6, economyFee: 144, minimumFee: 1008 };
+ const out = {};
+ await Promise.all(Object.entries(targets).map(async ([k, n]) => {
+ try {
+ const perKb = await this.call('blockchain.estimatefee', [n]); // BTC/kB; -1 if unknown
+ out[k] = perKb > 0 ? Math.max(1, Math.round(perKb * 1e5)) : 0; // → sat/vB
+ } catch { out[k] = 0; }
+ }));
+ return {
+ fastestFee: out.fastestFee || 10,
+ halfHourFee: out.halfHourFee || 5,
+ hourFee: out.hourFee || 3,
+ economyFee: out.economyFee || 2,
+ minimumFee: out.minimumFee || 1,
+ };
+ }
+
+ async broadcast(hexTx) {
+ return this.call('blockchain.transaction.broadcast', [hexTx]); // returns txid
+ }
+}
diff --git a/src/i18n.js b/src/i18n.js
index 8ed57f3..aa8b499 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -259,6 +259,12 @@ const STR = {
explorerDesc: 'The server this wallet queries for balances and history. Point it at your own node for maximum privacy.',
explorerUrl: 'Server URL',
explorerUrlHint: 'Esplora / electrs REST base, e.g. https://mempool.space/api',
+ dataSource: 'Data source',
+ backendEsplora: 'Explorer',
+ backendElectrum: 'Electrum',
+ backendElectrumDesc: 'Fetch data and get instant payments over one Electrum WebSocket. Point it at your own Fulcrum/electrs for a fully self-hosted wallet — no coinos, no REST.',
+ electrumUrl: 'Electrum server (wss://)',
+ electrumUrlHint: 'A WebSocket Electrum server, e.g. wss://your-node:50004',
silentPaymentNote: 'Silent payment',
instantNotif: 'Instant notifications',
instantNotifDesc: 'Uses coinos to push payments the instant they arrive. Turn off to poll the explorer above instead — a bit slower, but your addresses are never sent to coinos.',
diff --git a/src/wallet.js b/src/wallet.js
index 5941353..a6dd7e3 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -21,7 +21,8 @@ import * as btc from '@scure/btc-signer';
import { p2wpkh } from '@scure/btc-signer/payment';
import { concatBytes } from '@scure/btc-signer/utils.js';
-import { Api, pool, wsUrl } from './api.js';
+import { Api, pool, wsUrl, getBackend, explorerWeb } from './api.js';
+import { ElectrumApi } from './electrum.js';
import { NostrSync, getSyncConfig } from './nostr.js';
import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder } from './silentpay.js';
@@ -126,7 +127,7 @@ export class Wallet {
this.watchOnly = !!this.xpub && !this.mnemonic && !this.xprv; // view/receive only
this.netName = netName;
this.offline = offline;
- this.api = new Api(netName);
+ this._buildApi();
this.api.offline = offline;
this._account = null;
this._accountKey = null;
@@ -161,17 +162,35 @@ export class Wallet {
this.emit();
}
- // Rebuild the Api against the currently-configured explorer, then rescan and
- // reconnect realtime (called when the user switches explorer in Settings).
+ // Build the data backend for the current setting: Esplora REST (`Api`) or a
+ // full Electrum-over-WS server (`ElectrumApi`), which routes its data calls
+ // over this wallet's realtime socket via _rpcCall.
+ _buildApi() {
+ if (getBackend() === 'electrum') {
+ this.api = new ElectrumApi({
+ call: (m, p) => this._rpcCall(m, p),
+ network: this.netCfg.net,
+ testnet: this.netName === 'testnet',
+ explorerWeb: explorerWeb(),
+ });
+ } else {
+ this.api = new Api(this.netName);
+ }
+ this.api.offline = this.offline;
+ }
+
+ // Rebuild the backend against the current settings, then rescan and reconnect
+ // realtime (called when the user switches explorer/backend in Settings).
async reloadExplorer() {
this.stopRealtime();
- this.api = new Api(this.netName);
- this.api.offline = this.offline;
+ this._buildApi();
if (this.offline) return;
+ // Electrum's data rides the socket, so connect first, then scan.
+ if (getBackend() === 'electrum') this.startRealtime();
try {
await this.scan();
} catch {}
- this.startRealtime();
+ if (getBackend() !== 'electrum') this.startRealtime();
}
get netCfg() {
@@ -1418,13 +1437,18 @@ export class Wallet {
this._ws = ws;
this._wsBuf = ''; // partial-line accumulator (newline-delimited JSON-RPC)
this._subscribed = new Set(); // scripthashes subscribed on this connection
- this._rpcId = 0;
+ if (this._rpcId == null) this._rpcId = 0; // monotonic across reconnects (don't alias pending ids)
ws.onopen = () => {
this.live = true;
this._wsBackoff = 0; // healthy again — reset the reconnect backoff
this._lastMsg = Date.now();
// Electrum handshake, then subscribe our watched scripthashes.
this._rpcSend('server.version', ['halwallet', '1.4']);
+ // Flush any data calls queued while the socket was down.
+ if (this._callQueue && this._callQueue.length) {
+ for (const payload of this._callQueue) { try { ws.send(payload); } catch {} }
+ this._callQueue = [];
+ }
this.retrack();
this.emit();
// The server only pushes activity AFTER we subscribe, so reconcile once on
@@ -1433,20 +1457,22 @@ export class Wallet {
};
ws.onmessage = (ev) => {
this._lastMsg = Date.now();
- this._wsBuf += (typeof ev.data === 'string' ? ev.data : '');
- // Electrum frames are newline-delimited JSON-RPC; parse each complete line.
- let nl;
- while ((nl = this._wsBuf.indexOf('\n')) >= 0) {
- const line = this._wsBuf.slice(0, nl).trim();
- this._wsBuf = this._wsBuf.slice(nl + 1);
- if (line) this._handleRpc(line);
- }
- // Fallback: a server that sends one complete JSON per frame without a
- // trailing newline. Only consume the buffer if it parses cleanly.
- const rest = this._wsBuf.trim();
- if (rest) {
- try { JSON.parse(rest); this._handleRpc(rest); this._wsBuf = ''; } catch {}
+ this._wsBuf += (typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data));
+ // Robustly extract every complete top-level JSON object from the buffer,
+ // regardless of framing: newline-delimited (coinos watcher), one-per-frame
+ // without a newline (electrs), several concatenated in one frame, or a
+ // single object split across frames. Brace-depth scan, string-aware.
+ const s = this._wsBuf;
+ let depth = 0, inStr = false, esc = false, start = -1, consumed = 0;
+ for (let i = 0; i < s.length; i++) {
+ const ch = s[i];
+ if (inStr) { if (esc) esc = false; else if (ch === '\\') esc = true; else if (ch === '"') inStr = false; continue; }
+ if (ch === '"') inStr = true;
+ else if (ch === '{') { if (depth === 0) start = i; depth++; }
+ else if (ch === '}' && depth > 0) { if (--depth === 0 && start >= 0) { this._handleRpc(s.slice(start, i + 1)); consumed = i + 1; start = -1; } }
}
+ this._wsBuf = s.slice(consumed);
+ if (this._wsBuf.length > 1 << 20) this._wsBuf = ''; // runaway guard
};
ws.onerror = () => {
try {
@@ -1472,13 +1498,54 @@ export class Wallet {
return id;
}
+ // Electrum request/response (used by the ElectrumApi data backend). Resolves
+ // when the matching id comes back; queues until the socket is open, and times
+ // out so a wedged call can't hang a scan. ids are monotonic for the session so
+ // they never alias a fire-and-forget _rpcSend id.
+ _rpcCall(method, params) {
+ return new Promise((resolve, reject) => {
+ if (this.offline) { reject(new Error('offline')); return; }
+ const id = ++this._rpcId;
+ if (!this._pending) this._pending = new Map();
+ const timer = setTimeout(() => {
+ if (this._pending.delete(id)) reject(new Error('electrum timeout: ' + method));
+ }, 20000);
+ this._pending.set(id, { resolve, reject, timer });
+ const payload = JSON.stringify({ id, method, params }) + '\n';
+ if (this._ws && this._ws.readyState === 1) {
+ try { this._ws.send(payload); } catch {}
+ } else {
+ (this._callQueue = this._callQueue || []).push(payload);
+ }
+ });
+ }
+
+ _rejectPending(reason) {
+ if (!this._pending) return;
+ for (const { reject, timer } of this._pending.values()) { clearTimeout(timer); try { reject(new Error(reason)); } catch {} }
+ this._pending.clear();
+ }
+
// A notification means a tx touched one of our addresses (a deposit, or a
// confirmation). If it carries the tx data (our watcher does), credit it
// instantly with no REST on the critical path; otherwise just trigger a scan.
_handleRpc(line) {
let msg;
try { msg = JSON.parse(line); } catch { return; }
- if (!msg || msg.method !== 'blockchain.scripthash.subscribe') return;
+ if (!msg) return;
+ // Response to a data call (ElectrumApi backend)?
+ if (msg.id != null && this._pending && 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 === 'string' ? msg.error : (msg.error.message || JSON.stringify(msg.error))));
+ else p.resolve(msg.result);
+ return;
+ }
+ // Subscription notification: a tx touched a watched address. Our coinos
+ // watcher carries the tx data (credit instantly); a real Electrum server
+ // sends only [scripthash, status], so reconcile via the data backend.
+ if (msg.method !== 'blockchain.scripthash.subscribe') return;
const sh = msg.params && msg.params[0];
if (msg.data && sh && this._applyTxNotification(sh, msg.data)) return;
this._scheduleRefresh();
@@ -1523,6 +1590,8 @@ export class Wallet {
stopRealtime() {
this._wsWant = false;
this.live = false;
+ this._rejectPending('realtime stopped'); // fail any in-flight Electrum data calls
+ this._callQueue = [];
clearTimeout(this._wsRetry);
clearTimeout(this._refreshTimer);
clearInterval(this._pollTimer);