Skip to content

fix(bitcoin): retry the first full scan and make Retry trigger a real scan - #521

Draft
rvelaz wants to merge 1 commit into
mainfrom
fix/stale-balances-03-btc-scan-reliability
Draft

rvelaz wants to merge 1 commit into
mainfrom
fix/stale-balances-03-btc-scan-reliability

Conversation

@rvelaz

@rvelaz rvelaz commented Oct 26, 2026 •

Copy link
Copy Markdown

Update (28 Oct 2026): Rebased onto main on its own. It no longer includes #519 or #520. #520 (Bitcoin request retries and timeouts) was closed because Julien's Bitcoin HTTP retry-policy work covers it, and that work is already on main as #530. This PR's scans now run under #530's shared fetch policy.
To stand without #519, this PR adds the small hasSynchronized() helper itself (the same code as in #519). It no longer throws BalanceNotSynchronizedError or holds back balance events: showing an unknown balance as unavailable stays in #519. A balance request on an unknown account still starts a full scan. This PR also now follows a reused discovery scan that found activity with a standard-gap scan (this was in #522; without it, this PR alone would leave such accounts at gap 5).
It can merge on its own. If it merges first, #519 and #522 each need a small rebase: #519 conflicts in KeyringHandler and the handler tests, and #522 conflicts in AccountUseCases.fullScan.
Draft. Manifest shasum not yet regenerated, so snap checks may fail.

  • Package: @metamask/bitcoin-wallet-snap

Explanation

Bug

On main (and with PR 01), a new BTC account whose first scan failed stays "Balance unavailable" long after the network is back. Nothing retries the first full scan, and the wallet's Retry does not start one. When the connection healed right after a failed scan, BTC took about 2 minutes to appear.

How to reproduce

  1. Build extension release/13.54.0 (acf000d) with yarn build:dev. For the "after" case, also build this branch and copy its dist/bundle.js, snap.manifest.json and preinstalled-snap.json into node_modules/@metamask/bitcoin-wallet-snap. To see the wallet-side "Balance unavailable" + Retry, add core PR 10 and extension PR 12.
  2. Load it unpacked in Chrome or Chromium with default settings (HTTP/2 on). This is profile A.
  3. Import a test wallet that holds BTC, TRX, XLM and SOL (we used a team test wallet). Onboarding creates the Bitcoin, Tron, Stellar and Solana accounts.
  4. Let the snaps' requests coalesce. In Chrome, a request to bitcoin-mainnet.infura.io can reuse the HTTP/2 connection already open to another *.infura.io host (in our runs the Solana snap usually opened it first). The reply is an empty 404 without CORS headers, so the snap sees Failed to fetch. We reproduced this on a test machine where every *.infura.io name resolved to the same IP. It depends on the network path and may not reproduce on every network. In our runs we moved the shared connection between hosts on purpose: we dropped the test browser's sockets while briefly blocking the other Infura hosts, which "heals" one chain and breaks the others.
  5. Let the first bitcoin scans fail after onboarding. Then (a) click Retry on the Bitcoin row, or (b) heal the bitcoin connection (move it back to bitcoin), especially 20–30 s after a failed scan.
  6. Control (profile B): the same steps with Chrome started with --disable-http2 give no coalescing, and all four balances are correct.

Expected: Retry sends real Esplora requests and runs a full scan, and after a heal the pending first scan runs within seconds. Each account gets one scan at a time.
Actual (before this change): Retry sends nothing, and BTC stays unavailable after the heal (fixes-v1 build: no full scan about 2 min after the heal). With the v6 code, a heal 25 s after a failed scan waited about 2 min.

Without a browser: AccountUseCases.test.ts › first heal of a never-synchronized account (v7) › requests the full scan within seconds when the first successful sync comes 25 s after a failed scan replays the exact A-v6 timeline with fake timers.

Summary

A new account's first full scan, and the discovery scan during onboarding, are retried with backoff until they succeed. Each account gets one scan at a time. The repair migration can no longer be blocked by a stuck account. The wallet's Retry (a balance request on an unknown account) now triggers a real scan. When the network comes back after a failed scan, the pending first scan runs within seconds.

Why

In Chrome, HTTP/2 connection coalescing across *.infura.io can send the snap's Esplora request over a connection opened for another Infura host. The provider router answers with an empty 404 and no CORS headers, so requests fail with Failed to fetch, often for minutes at a time. PR 01 makes a never-scanned account "unavailable" instead of 0, but on main:

  • a failed first full scan was never retried. Only the revealed addresses were synced, so the balance could stay partial;
  • the one-off rescanV1 repair list advanced one account per run and only after success, so one failing account blocked everything behind it;
  • onboarding ran the discovery scan, then a second full scan of the same account. Concurrent cron runs and events could start several scans of one account (~250 requests with two full scans per account in the v1 build);
  • clicking Retry only re-read local state, so nothing changed until the next cron run;
  • after a failed scan, the first successful sync was dropped if it came within 30 s, so recovery waited up to 2 min.

What changed

  • store/FullScanQueue.ts (new):
    • persistent per-account full-scan queue. It reuses the rescanV1 state key and shape, so existing installs continue their repair list;
    • retry metadata under fullScanRetryV1, backoff 30 s doubling up to 5 min;
    • due accounts are returned in queue order and failing ones rotate to the tail (no head-of-line blocking);
    • isInitialScanPending().
  • use-cases/AccountUseCases.ts:
    • isBalanceUnknown = never synchronized or first full scan pending (PR 01 uses it to report the balance as unavailable);
    • listDueFullScans() re-queues never-synced accounts;
    • createMany enqueues new accounts and gives up to 2 of them an immediate first scan;
    • discovery scans are remembered (10 min TTL, max 64) and reused by createMany, so onboarding does not scan an account twice; there is also a discovery frontier. A reused discovery scan that found activity is followed by a standard-gap scan, so used accounts keep gap-20 coverage;
    • single-flight fullScan per account; syncs join an in-flight scan;
    • requestFullScan(account, reason) schedules a deduplicated FullScanAccount background event, with on-demand backoff 30 s doubling up to 2 min;
    • the first successful sync after failures (network recovered) requests the pending scan, waiting only 5 s after a failed attempt (30 s after a successful one); inside that window the request is deferred, not dropped.
  • handlers/CronHandler.ts:
    • #runDueFullScans replaces #repairNextAccount: up to 2 scans per run, skipping in-flight or just-attempted accounts;
    • fullScanAccount skips an on-demand scan whose purpose was already fulfilled.
  • handlers/KeyringHandler.ts: a balance request on an account whose balance is unknown calls requestFullScan(account, 'balance-request'). (With PR 01, that request also fails as "unavailable".)
  • entities/account.ts, infra/BdkAccountAdapter.ts, infra/StoredAccountAdapter.ts: hasSynchronized() (same code as PR 01).
  • Tests: FullScanQueue (backoff, rotation, migration of the old state), AccountUseCases (queue, single-flight, on-demand backoff, recovery timing including the A-v6 timeline, discovery reuse), CronHandler, KeyringHandler.

Validation

Run on a Cursor cloud VM against main c2e8d19, with this PR's commit applied alone (packages/bitcoin-wallet-snap):

  • ESLint: pass.
  • tsc --noEmit: pass.
  • jest: 28 suites, 720 tests pass. The package's own yarn workspace @metamask/bitcoin-wallet-snap run test (with coverage) also passes. Plain main passes too (28 suites, 720 tests), so no failures are shared with main.
  • The build reports the expected manifest shasum mismatch: the shasum is not regenerated yet (see Dependencies).

The earlier fail-before list was measured against the old parent (PR 02) and is no longer current.

Manual A/B

A = Chrome with default HTTP/2 (reproduces the coalescing); B = Chrome with --disable-http2 (control, no coalescing). Every run used the same test wallet and a local yarn build:dev of extension 13.54.0 (acf000d), with the snaps and assets-controller wired into node_modules by hand. "Unfixed 13.54.0" is that build with no fixes. The fixes-vN builds are cumulative: each one has every fix up to that pass, so an "after" number shows this PR together with the others in that build. Run names (A-v6, B-v7, ...) are our local test passes on those builds; times are CEST.

The unfixed build had no "unavailable" state and no Retry (it showed 0), so the "before" column below uses the earlier fixes builds that had PR 01's state without this PR's logic (fixes-v1), or with the v6 timing bug.

Check Before After
A: Retry sends requests and scans fixes-v1 build, A: the click sent no requests fixes-v2 build, A-v2: click 10:33:51 → bitcoin requests 10:33:54 → full scan 10:34:04 → BTC 0.00031918 by 10:34:09
A: recovery with no click after a heal fixes-v1 build, A: heal 23:28:34, but no full scan and BTC not recovered by 23:30:21 (~23 bitcoin requests) A-v3: 7 s from the first 200 to correct BTC; A-v4: 5 s; A-v5: ≤ 10 s
A: heal 25–26 s after a failed first scan fixes-v6 build, A-v6: heal 22:49:12 → full scan 22:51:12 → BTC 22:51:23 (~2 min) fixes-v7 build, A-v7c: full scan 2 s after the first 200; BTC ≤ 9 s after the heal, no click
A: heal outside that window — A-v7: heal ~150 s after the last scan → scan within 1 s, BTC ≤ 10 s, one scan only
B: full scans per account at onboarding fixes-v1 build, B: two full scans by the cron queue (22:57:53 and 22:58:23) fixes-v2/v3 builds, B: one full scan per account (~47 scripthash requests per account)

Risks

  • The queue state lives in snap state (rescanV1, fullScanRetryV1). The old { pending: string[] } shape is kept, so a downgrade still reads it as the repair list.
  • On-demand scans are rate-limited per account (30 s..2 min). Clicking Retry repeatedly does not multiply requests, but a click right after a failed scan only takes effect when the backoff allows it.
  • Single-flight and discovery-reuse state is in memory. A snap restart loses it, which costs at most one extra scan.
  • A few test names reference the internal A/B runs (for example "A-v6 22:49:13"). Happy to rename them if reviewers prefer.

Changelog (packages/bitcoin-wallet-snap/CHANGELOG.md, Unreleased → Fixed)

  • Retry the first full scan of new and never-synced accounts with backoff, treat their balance as unknown until it succeeds, run one scan per account (reusing the onboarding discovery scan), keep a failing account from blocking the repair scan of the others, and start a full scan when the balance of an unknown account is requested or the network recovers (#521)

References

Dependencies

Stale non-EVM balances series (Chrome HTTP/2 coalescing across *.infura.io):

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

… scan

- FullScanQueue: persistent per-account full-scan queue (reuses the
  rescanV1 state) with backoff 30 s..5 min; a failing account rotates to
  the tail, so neither new accounts nor the repair migration are blocked by
  a stuck one; never-synced accounts are re-queued
- the balance is treated as unknown (isBalanceUnknown) until the first full
  scan succeeds; BitcoinAccount.hasSynchronized() tells a never-synced
  wallet (genesis checkpoint) apart
- one scan per account: single-flight full scans, discovery scans reused
  for account creation (10 min TTL), at most 2 immediate scans on create;
  a reused discovery scan (discovery gap) that found activity is followed
  by a standard-gap scan
- a balance request on an unknown account (the wallet's Retry) requests an
  on-demand full scan (30 s..2 min backoff)
- the first successful sync after a failed scan requests a recovery scan
  within 5 s
@rvelaz
rvelaz force-pushed the fix/stale-balances-03-btc-scan-reliability branch from d0acea0 to 679210f Compare October 28, 2026 10:27
@rvelaz
rvelaz changed the base branch from fix/stale-balances-02-btc-requests to main October 28, 2026 10:27

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant