aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js63
-rw-r--r--src/i18n.js8
-rw-r--r--src/wallet.js67
3 files changed, 124 insertions, 14 deletions
diff --git a/src/app.js b/src/app.js
index dee907c..4195ef6 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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;