From 8a2bb0790d581ff67325966b3e37d6b2500128b5 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 15 Jun 2026 08:01:18 -0700 Subject: Add polling safety-net so pending balances appear reliably Relying solely on the mempool WebSocket was unreliable: it only pushes new events (no replay), and pushes can be missed (drops, server quirks), so a deposit could go unseen with no manual refresh to fall back on. - Add a 12s background poll alongside the WebSocket. - Make background refreshes "silent": scan({silent:true}) re-renders only when the visible state (utxos/txs/fresh indices) actually changes, so polls never flash the UI or steal input focus while typing. - Skip fees/price fetches on routine polls to keep request volume low. - WS events now trigger the same silent refresh (debounce 800ms). Unit-tested: silent refresh emits once when a pending deposit appears and not at all when nothing changed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wallet.js | 61 +++++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 14 deletions(-) (limited to 'src') diff --git a/src/wallet.js b/src/wallet.js index 6395490..77ec97e 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -63,6 +63,7 @@ export class Wallet { this.scanning = false; this.loaded = false; // true once a scan/snapshot has populated balances once + this._refreshing = false; // a scan/refresh is in flight this.nextReceiveIndex = 0; this.nextChangeIndex = 0; @@ -76,6 +77,7 @@ export class Wallet { this._wsWant = false; this._wsRetry = null; this._refreshTimer = null; + this._pollTimer = null; this._subs = new Set(); } @@ -186,10 +188,28 @@ export class Wallet { return found; } - async scan() { - if (this.offline) return; - this.scanning = true; - this.emit(); + // A short signature of the user-visible state, so background ("silent") + // refreshes only re-render when something actually changed. + _sig() { + return JSON.stringify({ + u: this.utxos.map((u) => `${u.txid}:${u.vout}:${u.value}:${u.confirmed ? 1 : 0}`), + t: this.txs.map((t) => `${t.txid}:${t.confirmed ? 1 : 0}`), + r: this.nextReceiveIndex, + c: this.nextChangeIndex, + }); + } + + // silent=false: foreground load (shows loading state, always re-renders). + // silent=true: background refresh (poll / WS) — no spinner, and only emits + // if the visible state changed, so it never disrupts typing. + async scan({ silent = false } = {}) { + if (this.offline || this._refreshing) return; + this._refreshing = true; + const before = silent ? this._sig() : null; + if (!silent) { + this.scanning = true; + this.emit(); + } try { this.addrMap = new Map(); [this.receive, this.change] = await Promise.all([ @@ -199,17 +219,25 @@ export class Wallet { this.nextReceiveIndex = firstUnused(this.receive); this.nextChangeIndex = firstUnused(this.change); - [this.feeRates, this.price] = await Promise.all([ - this.api.feeRates(), - this.api.price(), - ]); + // Fees/price are only needed at load — skip on routine background polls. + if (!silent || !this.feeRates) { + [this.feeRates, this.price] = await Promise.all([ + this.api.feeRates(), + this.api.price(), + ]); + } await this.refreshUtxos(); await this.refreshHistory(); this.loaded = true; } finally { - this.scanning = false; - this.emit(); + this._refreshing = false; + if (!silent) { + this.scanning = false; + this.emit(); + } else if (this._sig() !== before) { + this.emit(); + } } } @@ -390,8 +418,13 @@ export class Wallet { } startRealtime() { - if (this.offline || typeof WebSocket === 'undefined') return; + if (this.offline) return; this.stopRealtime(); + // Safety-net poll: the WebSocket gives instant updates, but its pushes can + // be missed (connection drops, server quirks). A light periodic silent + // refresh guarantees pending balances appear within a few seconds. + this._pollTimer = setInterval(() => this.scan({ silent: true }), 12000); + if (typeof WebSocket === 'undefined') return; this._wsWant = true; this._connectWs(); } @@ -459,12 +492,11 @@ export class Wallet { _scheduleRefresh() { clearTimeout(this._refreshTimer); this._refreshTimer = setTimeout(async () => { - if (this.scanning || this.offline) return; try { - await this.scan(); + await this.scan({ silent: true }); this.retrack(); } catch {} - }, 1200); + }, 800); } stopRealtime() { @@ -472,6 +504,7 @@ export class Wallet { this.live = false; clearTimeout(this._wsRetry); clearTimeout(this._refreshTimer); + clearInterval(this._pollTimer); if (this._ws) { try { this._ws.onclose = null; -- cgit v1.2.3