aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-08 10:11:48 -0700
committerAdam Soltys <[email protected]>2026-10-08 10:11:48 -0700
commit35f7e2f2ca967c24f26c6eadd73c2222ad252e52 (patch)
treeb2a837f52b1e2ff3ca7bba83b26ad38a5883381a /src
parent9bc604bdd0359cae6c8f43bc6a6ad8b96bff4433 (diff)
downloadcoinosv3-35f7e2f2ca967c24f26c6eadd73c2222ad252e52.tar.gz
coinosv3-35f7e2f2ca967c24f26c6eadd73c2222ad252e52.zip
Savings scan looks 20 addresses past the last used one (BIP-44 gap limit) instead of stopping at the first unused address, so a seed restored from another wallet shows coins sitting past a gap; the window of 20 is probed in parallel, the empty look-ahead tail isn't kept, and the next fresh address is one past the highest used (never a hole another wallet left)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/wallet.js75
1 files changed, 40 insertions, 35 deletions
diff --git a/src/wallet.js b/src/wallet.js
index bdcb451..aca11e1 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -37,17 +37,11 @@ export function absWsUrl(u) {
return u;
}
-// No look-ahead: stop scanning a chain at the first unused address. This wallet
-// only ever exposes ONE unused address at a time (freshReceive = first unused;
-// there is no "generate another address" button), so used addresses stay
-// contiguous and there is never a gap to look past. Keeps scans tiny.
-const GAP_LIMIT = 1;
-
-// Extra pause after finding a used address, before querying the next index —
-// keeps us gentle on the explorers' rate limits when a wallet has activity.
-const USED_HIT_DELAY_MS = 500;
-
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+// Standard BIP-44 look-ahead: a chain ends after 20 unused addresses in a row.
+// This app only ever hands out one unused address at a time, but a seed
+// restored from another wallet (or one that generated many addresses) can hold
+// coins well past a gap, which a stop-at-first-unused scan never saw.
+const GAP_LIMIT = 20;
// Silent-payment catch-up dust limit (sats): the indexer drops tweaks for txs
// whose taproot outputs are all below this, cutting the candidate math on a big
@@ -363,32 +357,40 @@ export class Wallet {
}
// --- online scan --------------------------------------------------------
- // Query one address at a time; stop at the first unused one (no look-ahead).
+ // Probe a window of GAP_LIMIT addresses at once (the Electrum backend answers
+ // them in parallel; the Esplora scheduler paces them), and keep sliding the
+ // window until GAP_LIMIT addresses past the last used one are all unused.
async scanChain(chain) {
const found = [];
- let gap = 0;
- let i = 0;
- while (gap < GAP_LIMIT) {
- const { address } = this.derive(chain, i);
- 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 hasMempool = (ms.tx_count || 0) > 0;
- // Balance straight from chain_stats — no need to fetch /utxo just to total.
- const confirmed = (cs.funded_txo_sum || 0) - (cs.spent_txo_sum || 0);
- const pending = (ms.funded_txo_sum || 0) - (ms.spent_txo_sum || 0);
- found.push({ chain, index: i, address, used, confirmed, pending, hasMempool });
- this.addrMap.set(address, { chain, index: i });
- if (used) {
- gap = 0;
- await sleep(USED_HIT_DELAY_MS); // slow down before fetching the next one
- } else {
- gap += 1;
+ let lastUsed = -1;
+ let next = 0;
+ while (next <= lastUsed + GAP_LIMIT) {
+ const end = lastUsed + GAP_LIMIT + 1;
+ const idxs = [];
+ for (let i = next; i < end; i++) idxs.push(i);
+ const rows = await Promise.all(idxs.map(async (i) => {
+ const { address } = this.derive(chain, i);
+ 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 hasMempool = (ms.tx_count || 0) > 0;
+ // Balance straight from chain_stats — no need to fetch /utxo just to total.
+ const confirmed = (cs.funded_txo_sum || 0) - (cs.spent_txo_sum || 0);
+ const pending = (ms.funded_txo_sum || 0) - (ms.spent_txo_sum || 0);
+ return { chain, index: i, address, used, confirmed, pending, hasMempool };
+ }));
+ for (const r of rows) {
+ found.push(r);
+ this.addrMap.set(r.address, { chain, index: r.index });
+ if (r.used) lastUsed = r.index;
}
- i++;
+ next = end;
}
- return found;
+ // Keep only up to the first unused address past the last used one: the
+ // look-ahead tail is just evidence the chain ended, and keeping it would put
+ // 20 empty rows per chain in the address list and the cache.
+ return found.filter((r) => r.index <= lastUsed + 1);
}
// A short signature of the user-visible state, so background ("silent")
@@ -2034,9 +2036,12 @@ export function xpubToZpub(xpub) {
+// The next fresh address: one past the highest used index, so a hole left by
+// another wallet below a used address is never handed out as "new".
function firstUnused(chain) {
- const u = chain.find((a) => !a.used);
- return u ? u.index : chain.length;
+ let last = -1;
+ for (const a of chain) if (a.used && a.index > last) last = a.index;
+ return last + 1;
}
function summarize(sel, network) {