Skip to content

fix(bitcoin): report a never-synced balance as unavailable, not 0 - #519

Draft
rvelaz wants to merge 1 commit into
mainfrom
fix/stale-balances-01-btc-no-fake-zero
Draft

rvelaz wants to merge 1 commit into
mainfrom
fix/stale-balances-01-btc-no-fake-zero

Conversation

@rvelaz

@rvelaz rvelaz commented Oct 26, 2026 •

Copy link
Copy Markdown

Draft. Manifest shasum not yet regenerated, so snap checks may fail.
Built and tested on 80b21f6; main is one commit ahead (#331, stellar only) and git merge-tree shows no conflicts.

  • Package: @metamask/bitcoin-wallet-snap

Explanation

Bug

A Bitcoin account whose first chain scan failed shows 0 BTC, which looks exactly like an empty account ("my bitcoin is gone"). The snap answers getAccountBalances from a wallet that never synced: genesis checkpoint, empty UTXO set.

How to reproduce

  1. Build extension release/13.54.0 (acf000d) with yarn build:dev. For the "after" case, also build this branch (yarn workspace @metamask/bitcoin-wallet-snap build) 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. Open the home screen while the bitcoin requests fail (in our runs, from onboarding until the bitcoin connection was healed).
  6. Control (profile B): the same steps with Chrome started with --disable-http2 give no coalescing, and all four balances are correct.

Expected: while the account has never synced, the snap reports the balance as unavailable (the wallet shows "Balance unavailable", not 0), and the expected state is not sent to Sentry.
Actual (before this change): BTC shows 0 for as long as the requests fail. In the unfixed A run it was 0 in all 17 snapshots over 15 minutes, and still 0 after a reload.

Without a browser: KeyringHandler.test.ts › getAccountBalances › fails instead of reporting 0 when the balance is still unknown reproduces the fake zero with a never-synced account mock.

Summary

getAccountBalances no longer answers a confident 0 BTC for an account whose wallet has never synced with the chain. It throws BalanceNotSynchronizedError instead. This is an expected state: it reaches the client as "balance unavailable" and is never sent to Sentry. The cron also stops pushing AccountBalancesUpdated for such accounts.

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, which the snap sees as Failed to fetch. When the first scan of a fresh account failed that way, the BDK wallet stayed on its genesis checkpoint with an empty UTXO set. getAccountBalances read that empty wallet and reported 0 BTC, a fake zero that looked exactly like an empty account. The error path also reported every request to Sentry, flooding it with a state that is expected while the network is broken.

What changed

  • entities/account.ts, infra/BdkAccountAdapter.ts, infra/StoredAccountAdapter.ts: new hasSynchronized(), true once the wallet applied a chain update (checkpoint height > 0). The metadata-only StoredAccountAdapter always answers true, so it never hides a known balance.
  • use-cases/AccountUseCases.ts: isBalanceUnknown(account) = !account.hasSynchronized(). PR 03 extends it with "first full scan still pending".
  • handlers/KeyringHandler.ts: getAccountBalances throws BalanceNotSynchronizedError while the balance is unknown.
  • entities/error.ts:
    • BalanceNotSynchronizedError (an ExternalServiceError, so the client sees it as disconnected/unavailable);
    • the stable BALANCE_NOT_SYNCHRONIZED_MESSAGE marker;
    • isBalanceNotSynchronizedError(), which matches the class, the message, the cause chain or a serialized error. This matters because the keyring SDK's handleKeyringRequest re-creates every non-SnapError as a plain Error.
  • handlers/HandlerMiddleware.ts, utils/errors.ts: the expected state is logged at debug level and skipped by trackError / shouldTrackError.
  • handlers/CronHandler.ts: the batched balance event only includes accounts whose balance is known.
  • Tests: KeyringHandler, HandlerMiddleware (including the re-created plain-Error path), utils/errors, CronHandler, AccountUseCases.

Validation

Checked locally: the test files changed in this PR were run with the parent branch's source (tests from this branch, every other file from the parent), then on this branch.

Unit tests that fail before and pass after (parent: 80b21f6)

  • KeyringHandler › getAccountBalances › fails instead of reporting 0 when the balance is still unknown, throws the untracked BalanceNotSynchronizedError when the balance is unknown
  • AccountUseCases › isBalanceUnknown › is unknown while the wallet never synchronized, is known once the wallet synchronized
  • CronHandler › synchronizeAccounts › repair rescan › holds back the balance of a synced account whose balance is still unknown, emits no balance event when every synced balance is unknown
  • HandlerMiddleware › handle › does not track or log as an error the expected "not synchronized yet" state, plus 2 tests on the real keyring request path (the SDK re-creates the error as a plain Error)
  • errors › shouldTrackError › returns false for the "not synchronized yet" state however it was wrapped (v3)
  • In total, 10 tests fail on the parent, and the other 239 in the same files pass.

Full run on this branch

  • jest --runInBand --testPathIgnorePatterns integration-test in packages/bitcoin-wallet-snap: 26 suites, 671 tests pass. The 4 docker-based integration suites need the local regtest stack, so they are excluded.
  • tsc --noEmit (package): clean.

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.

Check Before After
A: BTC shown while bitcoin requests fail Unfixed 13.54.0, A: 0 in all 17 snapshots (15 min) and after a reload fixes-v6 build, A-v6: never 0. The state message was "the account has not been synchronized yet" (UI: Balance unavailable) from 22:47 until the heal, then BTC 0.00031918 (22:51:23 dump)
A+B: 'not been synchronized' snap_trackError fixes-v2 build (state existed but was still tracked): B-v2 8 unique, A-v2 21 unique fixes-v3 to v7 builds: 0 in every A and B run
B: first BTC balance (control) Unfixed 13.54.0, B: 0.000319 from the first snapshot fixes-v7 build, B-v7: 0.00031918 at the first dump, 9 s after onboarding

Risks

  • A never-synced account now shows "Balance unavailable" instead of 0 until its first successful sync. That is intended. The client side (PR 10/12) renders it with a Retry; older clients keep the previous value or show nothing.
  • On this PR alone, recovery relies on the existing cron sync: after the first successful sync, the balance is reported. PR 03 adds the retried first full scan and makes a balance request trigger a scan.
  • hasSynchronized() reads the BDK checkpoint height. A wallet restored from a persisted changeset keeps its checkpoint, so already-synced accounts are unaffected.

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

  • Report a balance-unavailable error, not sent to Sentry, instead of 0 BTC for accounts that have never synchronized, and hold back balance events for them (#519)

References

Dependencies

  • None. Base of the bitcoin stack: 02 (requests), 03 (scan reliability), 04 (startup cost) and 05 (stale) stack on it.
  • Wallet side: PR 10 (core) and PR 12 (extension) for the unavailable state + Retry.
  • Follow-up: regenerate the snap.manifest.json shasum (yarn workspace @metamask/bitcoin-wallet-snap build). It is not included here.

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

getAccountBalances throws the expected-state BalanceNotSynchronizedError
while the wallet still sits on the genesis checkpoint, instead of answering
a confident zero, and the cron holds back balance events for such accounts.
The error is matched by its stable message marker (the keyring SDK
re-creates it as a plain Error) and is never sent to Sentry.

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