From ff69e7e9f166e01102500b559b0cd66e3a019fab Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 26 Jun 2026 08:23:57 -0700 Subject: Add Electrum-over-WebSocket as an alternative data backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Data source now offers 'Electrum' alongside the Esplora REST explorer. One Electrum WebSocket serves BOTH the wallet's data queries and scripthash push notifications, so pointing Hal at your own Fulcrum/electrs gives a fully self-hosted setup — no coinos, no public REST, nothing shared off your node. - src/electrum.js: ElectrumApi maps the Esplora shapes the wallet consumes onto Electrum methods (scripthash get_balance/listunspent/get_history, estimatefee, transaction.broadcast). electrs has no verbose tx lookup, so it parses raw txs and resolves input values from parent txs to compute fees (cached, in-flight deduped, bounded-parallel). Validated field-for-field against mempool.space: addressTxs (fee/vin/vout/status), getTx, outspend, balance, utxos. - wallet.js: the realtime socket already spoke Electrum's notify protocol; added request/response (_rpcCall) so the same connection answers data calls. Real Electrum notifications carry no tx data, so they reconcile via the backend. Replaced the fragile newline parser with a string-aware brace-depth scanner that handles every framing (newline, one-per-frame, concatenated, split) — fixes a buffer-poisoning hang under concurrent calls; also hardens the coinos watcher path. Esplora stays the default; nothing changes for existing users. Verified end-to-end against blockstream's electrs: concurrent transport, a full fresh-wallet scan (~2s), and the parser unit-tested on all framings. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 94 ++++++++++++++++++++++++++++++++++++++------------------------ 1 file changed, 58 insertions(+), 36 deletions(-) (limited to 'src/app.js') 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')) + ) ); } -- cgit v1.2.3