aboutsummaryrefslogtreecommitdiffstats
path: root/src/wallet.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-26 08:23:57 -0700
committerAdam Soltys <[email protected]>2026-06-26 08:23:57 -0700
commitff69e7e9f166e01102500b559b0cd66e3a019fab (patch)
treed3e26d58118559f59d69222c852dcb01b41f1899 /src/wallet.js
parentaa044138337c947b26eea910db57f2d5b743b5b8 (diff)
downloadcoinosv3-ff69e7e9f166e01102500b559b0cd66e3a019fab.tar.gz
coinosv3-ff69e7e9f166e01102500b559b0cd66e3a019fab.zip
Add Electrum-over-WebSocket as an alternative data backend
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) <[email protected]>
Diffstat (limited to 'src/wallet.js')
-rw-r--r--src/wallet.js113
1 files changed, 91 insertions, 22 deletions
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);