aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-15 08:01:18 -0700
committerAdam Soltys <[email protected]>2026-06-15 08:01:18 -0700
commit8a2bb0790d581ff67325966b3e37d6b2500128b5 (patch)
tree4af258decd2f16c13ca2dd255ebac4e9c9c3972b /src
parentacd0e15c7066b7be4a11c5cf0151f5752d4a3b06 (diff)
downloadcoinosv3-8a2bb0790d581ff67325966b3e37d6b2500128b5.tar.gz
coinosv3-8a2bb0790d581ff67325966b3e37d6b2500128b5.zip
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) <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/wallet.js61
1 files changed, 47 insertions, 14 deletions
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;