diff options
| -rw-r--r-- | src/api.js | 47 | ||||
| -rw-r--r-- | src/app.js | 94 | ||||
| -rw-r--r-- | src/electrum.js | 237 | ||||
| -rw-r--r-- | src/i18n.js | 6 | ||||
| -rw-r--r-- | src/wallet.js | 113 |
5 files changed, 438 insertions, 59 deletions
@@ -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 { @@ -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); |
