Skip to content

fix(bitcoin): report a stale balance as unavailable when syncs keep failing - #523

Closed
rvelaz wants to merge 1 commit into
fix/stale-balances-04-btc-startup-costfrom
fix/stale-balances-05-btc-stale
Closed

rvelaz wants to merge 1 commit into
fix/stale-balances-04-btc-startup-costfrom
fix/stale-balances-05-btc-stale

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

When bitcoin syncs start failing after a successful one, BTC keeps showing the last balance as if it were current, with no sign that it is outdated, for as long as the failure lasts.

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. Wait until BTC is correct, then break the bitcoin connection (move the shared connection to another Infura host), and watch the Bitcoin row for 3–4 minutes.
  6. Control (profile B): the same steps with Chrome started with --disable-http2 give no coalescing, and all four balances are correct.

Expected: about 90 s after the last successful sync (plus the next poll), BTC shows "Balance unavailable" + Retry. It is correct again within seconds of the heal, with no Sentry event for the stale state.
Actual (before this change): BTC keeps the old amount. In fixes-v5 A-v5 it was still shown 3.5 min after its requests started failing.

Without a browser: AccountUseCases.test.ts › stale balance after failing syncs (v6) › becomes stale 90 s after the last success while the syncs fail, and clears on the next success.

Summary

After an account's syncs have kept failing for ~90 s since its last successful sync, getAccountBalances stops confirming the stored balance. It throws BalanceStaleError instead, an expected state that is never sent to Sentry, so the wallet shows "Balance unavailable" rather than an outdated amount. The call also schedules a sync. The next successful sync clears the state and pushes the real balance.

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 shows up as Failed to fetch. Once an account had synced, getAccountBalances answered from local state without any request. While the connection stayed broken, the snap kept confirming the last balance indefinitely as if it were current, even after funds moved. The wallet had no way to tell.

What changed

  • store/SyncHealthStore.ts (new):
    • tracks per account whether syncs are failing and since when the stored balance has not been confirmed (staleSince);
    • the failure streak is persisted under syncHealthV1, written only on transitions (its first failure and the success that ends it), so a snap restart between syncs does not reset it;
    • BALANCE_STALE_AFTER_MS = 90_000, three missed 30 s cron syncs, matching the client's 90 s re-check (PR 11).
  • use-cases/AccountUseCases.ts:
    • sync success and failure are recorded;
    • isBalanceStale(id);
    • scheduleStaleRefresh(id) schedules a sync, at most one per 10 s per account (STALE_SYNC_REQUEST_MIN_INTERVAL_MS).
  • handlers/KeyringHandler.ts: after the unknown-balance check, a stale balance schedules the refresh and throws BalanceStaleError.
  • entities/error.ts:
    • BalanceStaleError with the stable BALANCE_STALE_MESSAGE marker;
    • isBalanceUnavailableError() covers both expected states (not synchronized, stale) across re-created, wrapped or serialized errors;
    • HandlerMiddleware and utils/errors use it, so neither state is tracked.
  • Tests: SyncHealthStore (streaks, persistence on transitions, restart), AccountUseCases, KeyringHandler, HandlerMiddleware, utils/errors.

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: PR 04)

  • KeyringHandler › getAccountBalances › throws the untracked BalanceStaleError and requests a sync when the syncs keep failing (v6)
  • HandlerMiddleware › ... › does not track the re-created stale-balance error (v6)
  • errors › shouldTrackError › returns false for the stale-balance state however it was wrapped (v6)
  • AccountUseCases.test.ts and SyncHealthStore.test.ts cannot run on the parent because they import the new SyncHealthStore. The new tests there (stale balance after failing syncs (v6): 4 tests; SyncHealthStore: 6) were not verified individually against the parent.

Full run on this branch

  • jest --runInBand --testPathIgnorePatterns integration-test in packages/bitcoin-wallet-snap: 28 suites, 746 tests pass.
  • 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 after its connection breaks fixes-v5 build, A-v5: still showing the balance 3.5 min after 22:05:27, with all requests failing fixes-v6 build, A-v6: "Balance unavailable" at 22:55:16, 124 s after the last success; fixes-v7, A-v7: 123 s
A: recovery after the heal — A-v6: heal 22:57:42 → correct by the 22:57:53 dump; A-v7: correct within 11 s; no click
A: stale-state snap_trackError — A-v6: 0 BalanceStaleError in the console
B: bitcoin snap_trackError (control) B-v5: 0 B-v6, B-v7: 0

Risks

  • A user on a flaky network can now see "Balance unavailable" for a while instead of the last known amount. That is intended. The wallet shows it as 'Balance unavailable' with a Retry (PR 12), and Retry schedules a sync.
  • The threshold assumes the 30 s cron cadence. If the cron interval changes, BALANCE_STALE_AFTER_MS should follow.
  • One more snap state key (syncHealthV1), written only on streak transitions.

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

  • Report a balance-unavailable error, not sent to Sentry, instead of the stored balance once an account's syncs have kept failing for 90 s since the last success, and schedule a sync when that balance is requested (#523)

References

Dependencies

  • Stacked on 04 (needs the expected-state error plumbing from 01 and the sync recording points from 03).
  • Wallet side: PR 11 (core 90 s re-check) asks for the balance again so the stale state surfaces; PR 12 renders it.
  • Follow-up: regenerate the snap.manifest.json shasum.

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

…ailing

SyncHealthStore tracks per account whether its syncs are failing and
since when its stored balance was last confirmed; the streak is persisted
on transitions so a snap restart does not reset it. After 90 s of failed
syncs since the last success (three missed 30 s syncs, matching the
client's 90 s re-check), getAccountBalances throws the expected-state
BalanceStaleError (not sent to Sentry) and schedules a sync, rate limited
to one per 10 s; the next successful sync clears the state and emits the
balances-updated event.
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