diff options
| author | Adam Soltys <[email protected]> | 2026-10-08 10:11:48 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-08 10:11:48 -0700 |
| commit | 35f7e2f2ca967c24f26c6eadd73c2222ad252e52 (patch) | |
| tree | b2a837f52b1e2ff3ca7bba83b26ad38a5883381a /src | |
| parent | 9bc604bdd0359cae6c8f43bc6a6ad8b96bff4433 (diff) | |
| download | coinosv3-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.js | 75 |
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) { |
