diff options
| author | Adam Soltys <[email protected]> | 2026-06-24 23:36:17 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-24 23:36:17 -0700 |
| commit | 6d8df4d5b004ea9a84057ff78b9276262cb8f264 (patch) | |
| tree | 1b9f82551726bc6088fe21b81d745a0becc67b0f | |
| parent | 3b1adc89a361fd4c1a0552673c621a06f041dc75 (diff) | |
| download | coinosv3-6d8df4d5b004ea9a84057ff78b9276262cb8f264.tar.gz coinosv3-6d8df4d5b004ea9a84057ff78b9276262cb8f264.zip | |
Drop background/full rescans for on-demand per-address rescan
Remove the periodic full re-scan entirely — the WS and the frontier poll keep
the balance current, and refreshLive's _reconcileHeld catches spends of held
coins. The only gap (a deposit to a reused old address) is now handled
explicitly: Settings → Rescan opens a paginated list of every known address,
each with its own Rescan button (wallet.rescanAddress re-queries just that one).
Replaces the full 'Rescan wallet' button.
This sharply cuts recurring REST volume (the main rate-limit driver). Trade-off:
when the socket is down, a pending tx's confirmed badge in History may lag until
the WS reconnects or you rescan — the balance itself stays correct via reconcile.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| -rw-r--r-- | src/app.js | 63 | ||||
| -rw-r--r-- | src/i18n.js | 8 | ||||
| -rw-r--r-- | src/wallet.js | 67 |
3 files changed, 124 insertions, 14 deletions
@@ -64,6 +64,9 @@ const ui = { txPage: 0, // History: current page of transactions (10 per page) giftsAll: false, // History: showing the full paginated list of sent gifts 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 send: blankSend(), draft: null, // built tx summary awaiting review broadcastTx: null, // scanned signed tx awaiting broadcast confirmation @@ -911,7 +914,60 @@ function brandHeader(withLock) { // users who skipped backup verification) and the offline snapshot transfer. // 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. +async function doRescanAddress(chain, index) { + if (wallet.offline) { toast(t('scanOffline')); return; } + ui.rescanningAddr = chain + '/' + index; + render(); + try { + await wallet.rescanAddress(chain, index); + toast(t('rescanDone')); + } catch (e) { + toast(e.message || t('rescanFailed')); + } + ui.rescanningAddr = null; + render(); +} + +// Paginated list of every known address, each with its own rescan button. +function addressScanView() { + const addrs = wallet.knownAddresses(); + const pages = Math.ceil(addrs.length / PAGE_SIZE) || 1; + const page = Math.min(ui.addrScanPage, pages - 1); + const slice = addrs.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE); + return h( + 'div', + { class: 'col', style: 'gap:16px' }, + h('div', { class: 'card col', style: 'gap:12px' }, + h('div', { class: 'row between' }, + h('h3', { style: 'margin:0' }, t('rescanAddresses')), + h('button', { class: 'btn-sm', onClick: () => { ui.addrScan = false; render(); } }, t('back')) + ), + h('p', { class: 'small muted', style: 'margin:0' }, t('rescanAddrDesc')), + h('div', { class: 'list' }, + ...slice.map((a) => { + const id = a.chain + '/' + a.index; + const busy = ui.rescanningAddr === id; + return h('div', { class: 'item' }, + h('div', { class: 'grow' }, + h('div', { class: 'mono small break' }, shortAddr(a.address, 16, 10), + a.used ? null : h('span', { class: 'badge off dot', style: 'font-size:10px;margin-left:6px;padding:1px 7px' }, t('unusedTag'))), + h('div', { class: 'path' }, `${a.chain}/${a.index}` + (a.balance ? ' · ' + fmtAmount(a.balance) + ' ' + unitLabel() : '')) + ), + 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')) + ); + }) + ), + pager(page, addrs.length, (p) => { ui.addrScanPage = p; render(); }) + ) + ); +} + function settingsTab() { + if (ui.addrScan && !wallet.offline) return addressScanView(); const shown = ui.revealShown; const words = wallet.mnemonic.split(' '); const cells = words.map((w, i) => @@ -967,12 +1023,10 @@ function settingsTab() { 'div', { class: 'card col' }, h('h3', {}, t('rescan')), - h('p', { class: 'small muted', style: 'margin:0' }, - t('rescanDesc')), + h('p', { class: 'small muted', style: 'margin:0' }, t('rescanDesc')), wallet.offline ? null - : h('button', { disabled: wallet.scanning, onClick: () => wallet.scan() }, - wallet.scanning ? t('scanning') : t('rescanWallet')) + : h('button', { onClick: () => { ui.addrScan = true; ui.addrScanPage = 0; render(); } }, t('rescanAddresses')) ), explorerCard(), wallet.watchOnly || !wallet.mnemonic ? null : syncCard() @@ -1628,6 +1682,7 @@ function tabsBar() { ui.revealShown = false; // re-mask the recovery phrase whenever tabs change ui.txDetail = null; // back to the history list when leaving/returning ui.giftsAll = false; // and back to the paged history, not the all-gifts view + ui.addrScan = false; // and back to the main Settings, not the address list ui.bump = null; ui.giftMode = false; render(); diff --git a/src/i18n.js b/src/i18n.js index 84ed882..d948917 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -237,8 +237,14 @@ const STR = { exportSnapshot: '⤓ Export snapshot', importSnapshot: 'Import snapshot…', rescan: 'Rescan', - rescanDesc: 'Your current address updates automatically. Rescan only if a payment was sent to an old (reused) address and isn’t showing.', + rescanDesc: 'Your current address updates automatically. Rescan an individual address only if a payment was sent to an old (reused) one and isn’t showing.', rescanWallet: 'Rescan wallet', + rescanAddresses: 'Rescan an address', + rescanAddrDesc: 'Tap Rescan next to the address a payment was sent to. Only that address is re-checked.', + rescanOne: 'Rescan', + rescanDone: 'Address rescanned', + rescanFailed: 'Rescan failed', + unusedTag: 'unused', scanning: 'Scanning…', explorer: 'Block explorer', explorerDesc: 'The server this wallet queries for balances and history. Point it at your own node for maximum privacy.', diff --git a/src/wallet.js b/src/wallet.js index f52efdf..0f2a763 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -428,6 +428,59 @@ export class Wallet { return changed; } + // Every address the wallet currently knows about (scanned receive + change), + // sorted by chain then index, for the per-address rescan list in Settings. + knownAddresses() { + const mk = (e, chain) => ({ chain, index: e.index, address: e.address, used: !!e.used, balance: (e.confirmed || 0) + (e.pending || 0) }); + return [ + ...this.receive.map((e) => mk(e, 0)), + ...this.change.map((e) => mk(e, 1)), + ].sort((a, b) => a.chain - b.chain || a.index - b.index); + } + + // Re-query a single address and reconcile just its coins/history into state — + // the targeted alternative to a full rescan, for recovering a deposit to a + // reused old address. Returns whether the address is used. + async rescanAddress(chain, index) { + const { address } = this.derive(chain, index); + const info = await this.api.addressInfo(address); + const cs = info.chain_stats || {}; + const ms = info.mempool_stats || {}; + const used = (cs.tx_count || 0) > 0 || (ms.tx_count || 0) > 0; + const confirmed = (cs.funded_txo_sum || 0) - (cs.spent_txo_sum || 0); + const pending = (ms.funded_txo_sum || 0) - (ms.spent_txo_sum || 0); + + const arr = chain === 0 ? this.receive : this.change; + let entry = arr.find((e) => e.index === index); + if (!entry) { entry = { chain, index, address }; arr.push(entry); arr.sort((a, b) => a.index - b.index); } + this.addrMap.set(address, { chain, index }); + entry.used = used; + entry.confirmed = confirmed; + entry.pending = pending; + + const us = await this.api.addressUtxos(address); + this.utxos = this.utxos.filter((u) => u.address !== address); + for (const u of us) this.utxos.push({ txid: u.txid, vout: u.vout, value: u.value, address, chain, index, confirmed: !!u.status.confirmed }); + + const list = await this.api.addressTxs(address); + for (const tx of list) { + const summary = this._txSummary(tx); + const at = this.txs.findIndex((t) => t.txid === tx.txid); + if (at >= 0) this.txs[at] = summary; + else this.txs.push(summary); + } + + this.nextReceiveIndex = firstUnused(this.receive); + this.nextChangeIndex = firstUnused(this.change); + this.utxos.sort((x, y) => y.value - x.value); + this._sortTxs(); + this._recomputeBalanceFromChains(); + this.loaded = true; + this.saveCache(); + this.emit(); + return used; + } + // 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. @@ -1079,16 +1132,12 @@ export class Wallet { this._lastPoll = Date.now(); this.refreshLive(); }, 3000); - // Periodic full scan: reconciles pending→confirmed history, spends, and - // stale cache/relay state — but only when the socket is down. While live the - // WS already pushes confirmations and block events, so a full re-scan would - // just burn the rate limit (and risk starving the very socket we rely on). - this._deepTimer = setInterval(() => { - if (this.live) return; - this.scan({ silent: true }).catch(() => {}); - }, 120000); + // No periodic full re-scan: the WS (and the frontier poll above) keep the + // balance current, and refreshLive's _reconcileHeld catches spends of held + // coins. The only thing neither covers is a deposit to a reused old address, + // which is handled on demand via per-address rescan in Settings. // Only mempool.space has a live socket; other explorers rely on the poll - // and deep scan above (wsUrl returns null → no socket). + // above (wsUrl returns null → no socket). if (typeof WebSocket === 'undefined' || !this.wsUrl()) return; this._wsWant = true; |
