From faceb2eef8abe14302b057d835f0449bcd06bc59 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 24 Jun 2026 23:44:48 -0700 Subject: Watch pending-coin addresses; let per-address rescans queue - watchedAddresses() now includes any address holding an unconfirmed coin (not just the frontier), so a pending deposit's confirmation pushes over the socket instantly. Capped at the socket's 10-address limit. refreshLive/rescanAddress retrack when a new pending coin changes the set. - While any coin is pending, the frontier poll runs every ~6s even when live, so confirmations show fast as a belt-and-suspenders to the WS push. - Per-address rescans no longer disable each other: clicking many queues them, and the api.js global scheduler (1 concurrent, 500ms spacing, 429 backoff) serializes and staggers the underlying requests within the rate limit. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 17 ++++++++++------- src/wallet.js | 33 ++++++++++++++++++++------------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/app.js b/src/app.js index 4195ef6..5146414 100644 --- a/src/app.js +++ b/src/app.js @@ -66,7 +66,7 @@ const ui = { giftsPage: 0, // History: current page within the all-gifts list addrScan: false, // Settings: showing the per-address rescan list addrScanPage: 0, // Settings: current page within the address list - rescanningAddr: null, // 'chain/index' of the address currently rescanning + rescanning: new Set(), // 'chain/index' ids queued/in-flight for rescan send: blankSend(), draft: null, // built tx summary awaiting review broadcastTx: null, // scanned signed tx awaiting broadcast confirmation @@ -915,18 +915,21 @@ function brandHeader(withLock) { // The phrase is gated: the real words are never put in the DOM until "Reveal", // so the warning is read first. // Rescan a single address on demand — recovers a deposit to a reused old -// address without a full wallet rescan. +// address without a full wallet rescan. Multiple can be queued at once; the +// API layer's global scheduler serializes and spaces the underlying requests +// (and backs off on 429), so a flurry of clicks stays within the rate limit. async function doRescanAddress(chain, index) { if (wallet.offline) { toast(t('scanOffline')); return; } - ui.rescanningAddr = chain + '/' + index; + const id = chain + '/' + index; + if (ui.rescanning.has(id)) return; // already queued + ui.rescanning.add(id); render(); try { await wallet.rescanAddress(chain, index); - toast(t('rescanDone')); } catch (e) { toast(e.message || t('rescanFailed')); } - ui.rescanningAddr = null; + ui.rescanning.delete(id); render(); } @@ -948,7 +951,7 @@ function addressScanView() { h('div', { class: 'list' }, ...slice.map((a) => { const id = a.chain + '/' + a.index; - const busy = ui.rescanningAddr === id; + const busy = ui.rescanning.has(id); return h('div', { class: 'item' }, h('div', { class: 'grow' }, h('div', { class: 'mono small break' }, shortAddr(a.address, 16, 10), @@ -957,7 +960,7 @@ function addressScanView() { ), busy ? h('button', { class: 'btn-sm', disabled: true }, h('span', { class: 'spinner sm' })) - : h('button', { class: 'btn-sm', disabled: !!ui.rescanningAddr, onClick: () => doRescanAddress(a.chain, a.index) }, t('rescanOne')) + : h('button', { class: 'btn-sm', onClick: () => doRescanAddress(a.chain, a.index) }, t('rescanOne')) ); }) ), diff --git a/src/wallet.js b/src/wallet.js index 0f2a763..54e427a 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -367,6 +367,7 @@ export class Wallet { this._recomputeBalanceFromChains(); this.loaded = true; this.saveCache(); + this.retrack(); // a new pending coin may have changed the watched set this.emit(); } } catch { @@ -477,6 +478,7 @@ export class Wallet { this._recomputeBalanceFromChains(); this.loaded = true; this.saveCache(); + this.retrack(); // watch this address over the socket if its coin is pending this.emit(); return used; } @@ -1105,29 +1107,34 @@ export class Wallet { return wsUrl(this.netName); } - // Only the fresh frontier needs realtime watching: the next receive address - // (incoming deposits) and the next change address (our change landing). Coin - // spends are handled by the post-send refresh and cross-device Nostr sync, so - // we don't track coin addresses — which also stays under the relay's 10-addr - // per-connection limit. + // What realtime watches: the fresh frontier (next receive + next change), plus + // any address holding an unconfirmed coin — so a pending deposit's confirmation + // pushes instantly instead of waiting for a poll. Capped at the socket's 10- + // address-per-connection limit (frontier first, then pending coins). watchedAddresses() { - return [ + const set = new Set([ this.derive(0, this.nextReceiveIndex).address, this.derive(1, this.nextChangeIndex).address, - ]; + ]); + for (const u of this.utxos) { + if (set.size >= 10) break; + if (!u.confirmed) set.add(u.address); + } + return [...set]; } startRealtime() { if (this.offline) return; this.stopRealtime(); - // Frontier poll (next receive/change address only). While the socket is live - // it pushes deposits instantly, so we poll just as a slow half-open-socket - // safety net (~25s). When the socket is down, poll fast (~6s) so receives - // still show quickly. The 3s tick is a cheap time check — it issues no - // request unless one is due, which keeps REST volume (and 429s) low. + // Frontier poll (next receive/change + any pending-coin address). While the + // socket is live and nothing is pending it's just a slow half-open-socket + // safety net (~25s). When the socket is down, OR a coin is waiting to + // confirm, poll fast (~6s) so receives and confirmations show quickly. The + // 3s tick is a cheap time check — it issues no request unless one is due. this._pollTimer = setInterval(() => { if (this.offline) return; - const due = this.live ? 25000 : 6000; + const pending = this.utxos.some((u) => !u.confirmed); + const due = this.live && !pending ? 25000 : 6000; if (Date.now() - (this._lastPoll || 0) < due) return; this._lastPoll = Date.now(); this.refreshLive(); -- cgit v1.2.3