Skip to content

fix(assets-controller): revalidate quiet snap accounts and treat an empty balance map as a failure - #11070

Draft
rvelaz wants to merge 1 commit into
fix/stale-balances-10-snap-balance-errors-backofffrom
fix/stale-balances-11-snap-balance-revalidate
Draft

rvelaz wants to merge 1 commit into
fix/stale-balances-10-snap-balance-errors-backofffrom
fix/stale-balances-11-snap-balance-revalidate

Conversation

@rvelaz

@rvelaz rvelaz commented Oct 26, 2026 •

Copy link
Copy Markdown

Draft. Needs a rebase. Built and tested on 93f4402 (Release/1326.0.0); main is ~200 commits ahead. git merge-tree against main shows no textual conflicts, but main has since moved assets-controller tests to Vitest (#11038) and added balance state to snap requests (#10819), so the new tests (written for Jest) may need porting.

  • Package: @metamask/assets-controller

Explanation

Bug

A non-EVM chain that starts failing after it had a balance keeps its cached amount for minutes, because a snap whose syncs fail simply stops pushing updates. And a snap that swallows a network error and returns {} is treated as a confirmed (empty) result, so SOL never shows an error.

How to reproduce

  1. Build extension release/13.54.0 (acf000d) with yarn build:dev, with this branch's @metamask/assets-controller build in node_modules (locally we patched the 19.0.0 dist). Add extension PR 12 to see the UI. This PR is stacked on PR 10.
  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 tron-mainnet / solana-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 TRX and SOL are correct, then break their connections (heal bitcoin, so the shared connection moves away from tron and solana), and watch the Tron and Solana rows.
  6. Control (profile B): the same steps with Chrome started with --disable-http2 give no coalescing, and all four balances are correct.

Expected: within about 90 s of a chain going quiet (or at once for an empty map), the row shows "Balance unavailable" + Retry, and it clears after recovery.
Actual (before this change): TRX kept the cached 24.11 for about 7 minutes (fixes-v2 A-v2: 10:34:19 → 10:41:00), and SOL never showed an error (A-v2, failing from 10:30).

Without a browser: SnapDataSource.test.ts › revalidation of subscribed accounts (v3) › flags a quiet, failing snap account unavailable after the revalidation window (TRX case) and an empty balance map for requested assets is a failure, not a confirmed balance (SOL case, v6).

Summary

Two gaps remained after PR 10, and both let a stale or failed snap balance look current:

  1. Quiet accounts. A snap pushes balances only after a successful sync. If its syncs keep failing, it just goes quiet, and the client keeps showing the cached balance with no error. SnapDataSource now records when each subscribed snap account's balance was last confirmed (a successful fetch or a pushed update). Every 15 s it checks for accounts with nothing confirmed for 90 s and re-fetches them with forceUpdate. A failure enters the PR 10 error and backoff path, so the row says "Balance unavailable".
  2. Empty balance map. Some snaps swallow a network error and return {} from getAccountBalances. That is now recorded as a failed fetch (Snap returned no balances for the requested assets: N asset(s) requested), not stored as a confirmed result.

Why

In Chrome, HTTP/2 connection coalescing across *.infura.io can send a snap's request over a connection opened for another Infura host. The reply is an empty 404 without CORS headers, so the snap sees Failed to fetch. With PR 10 alone, the error state appeared only when core itself fetched and the snap threw. In the EM v2 run, TRX took about 7 min to show "Balance unavailable" after its connection broke, because the tron snap stopped pushing and nothing in core fetched it again (the quiet-account case). The solana snap answered failed fetches with an empty map, which core stored as a confirmed result (the empty-map case). Healthy snaps push every 30–60 s, so a 90 s window does not re-fetch them.

What changed

  • data-sources/SnapDataSource.ts:
    • New constants SNAP_BALANCE_REVALIDATE_AFTER_MS = 90_000, SNAP_BALANCE_REVALIDATE_CHECK_INTERVAL_MS = 15_000 and SNAP_EMPTY_BALANCES_ERROR.
    • New revalidation?: { afterMs?, checkIntervalMs? } | false option.
    • Subscribed accounts are tracked per subscription, narrowed to the chains of their own snap. #lastConfirmedAt is set on subscribe, on a successful fetch and on a snap-pushed update.
    • A revalidation timer re-fetches quiet accounts. It skips accounts already waiting for a scheduled retry and accounts already being revalidated.
    • unsubscribe and destroy stop the watching and the timer.
    • Empty map handling: when assets were requested and the snap returned an empty or missing balances map, the fetch throws, which records the failure.
  • SnapDataSource.test.ts: a 'revalidation of subscribed accounts' describe with 6 tests:
    • a quiet failing account is flagged after the window (the TRX case);
    • a synced account whose snap reports its balance as stale is flagged, Retry asks the snap, and recovery clears it;
    • no re-fetch when the snap pushed recently;
    • failing accounts are left to their scheduled retries;
    • the timer stops after unsubscribe;
    • revalidation can be disabled.
    • Plus the empty map cases.

Validation

Checked locally: the test files changed in this PR were run with the parent'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 10)

  • SnapDataSource › ... › revalidation of subscribed accounts (v3) › all 8: flags a quiet, failing snap account unavailable after the revalidation window (TRX case), an empty balance map for requested assets is a failure, not a confirmed balance (SOL case, v5) and (v6), flags a synced account unavailable when the snap reports its balance as stale, Retry asks the snap, and recovery clears it, does not ask again when the snap pushed balances recently, leaves failing accounts to their scheduled retries, runs on its own timer, and stops after unsubscribe, can be disabled
  • 56 other tests in the file pass on the parent.

Full run on this branch

  • jest --runInBand --forceExit in packages/assets-controller: 49 suites, 1348 tests pass (PR 10: 1340).
  • Type check (temporary no-references config, see PR 10): the error list is identical to 93f4402 (621, 522 of them TS6307 notices caused by the no-references config).

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: TRX loses its connection after it had a balance fixes-v2 build (PR 10 without this), A-v2: cached 24.11 shown from 10:34:19 until the error at 10:41:00 (~7 min) fixes-v3 build, A-v3: drop 15:09:36.9 → first 404 15:09:37 → TRX error 15:09:38 → "Balance unavailable" at the 15:09:45 dump (≤ 8 s)
A: SOL requests fail (snap returns an empty map) A-v2: SOL failed from 10:30 and never showed an error A-v3: failure from 15:05:32 → error 15:05:38 → unavailable ≤ 13 s; recovered 15:09:45. A-v4: error in the same second (15:46:02)
A: BTC/XLM stale (with snaps PRs 05/07) fixes-v5 build, A-v5: BTC/XLM not flagged 3.5 min after their requests started failing. Revalidation alone cannot catch a snap that answers successfully with stale data fixes-v6 build, A-v6: both unavailable at 22:55:16, ~124 s after their last success; then backoff +30 / +60 / +120 s; recovery cleared the entries
B: false positives (control) — B-v3 to B-v7: all balances correct at the first dump, never "Balance unavailable"

Risks

  • Extra snap calls: a quiet account gets at most one getAccountBalances per 90 s window, and only while it is subscribed and not already on the PR 10 backoff. Healthy snaps that push regularly get no extra calls.
  • A snap that legitimately returns an empty map for requested assets is now reported as unavailable. Snaps are expected to return an entry (possibly 0) for every requested asset.
  • Timer lifecycle: the interval runs only while at least one account is watched and is cleared on unsubscribe and destroy. Both are covered by tests.
  • Test names mention the internal run labels ("v3", "v6", "TRX case"). Happy to rename them if reviewers prefer.

Changelog (packages/assets-controller/CHANGELOG.md, Unreleased → Fixed)

  • Re-fetch subscribed snap accounts whose balance has not been confirmed for 90 s, so a snap whose syncs keep failing is flagged unavailable instead of showing a stale balance, and treat an empty balance map from a snap as a failed fetch (#11070)

References

Dependencies

  • Stacked on PR 10 and must merge after it.
  • It works best with the snap staleness fixes (bitcoin PR 05, stellar PR 07), which make a stale snap answer with an error that this revalidation can surface.
  • Consumers: extension PRs 12 and 13.

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

…mpty balance map as a failure

Snap accounts that are not pushing balance updates are re-checked after 90 s
so a chain whose snap keeps failing is flagged unavailable instead of keeping
a stale value. An empty balance map from the snap counts as a failed fetch.
Includes the stale-balance tests (flagged unavailable, Retry asks the snap,
recovery clears it).
This was referenced Oct 26, 2026

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