Skip to content

fix: add BackendWebsocketDataSource tests for Arbitrum USDC balance update#9265

Open
salimtb wants to merge 5 commits into
mainfrom
fix/ws-balance-stale-after-reconnect
Open

fix: add BackendWebsocketDataSource tests for Arbitrum USDC balance update#9265
salimtb wants to merge 5 commits into
mainfrom
fix/ws-balance-stale-after-reconnect

Conversation

@salimtb

@salimtb salimtb commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes stale or missing token balances when websocket live updates compete with Accounts API / RPC polling, and when websocket subscriptions are torn down and recreated during account switches or reconnects.

Manual verification (Arbitrum USDC):

  • USDC on Arbitrum (eip155:42161/erc20:…) notification processed correctly after send/receive
  • EVM addresses matched case-insensitively (checksummed vs lowercase) so notifications are not dropped for the wrong account
  • Balance recovers correctly after websocket disconnect → reconnect → resubscribe (no silently dropped notification during the gap)

Explanation

What was broken?

AssetsController merges balances from several sources: websocket push (BackendWebsocketDataSource), Accounts API polling (AccountsApiDataSource), RPC, Snap, etc. Several races caused the UI to show stale values or never recover after a transaction:

  1. Websocket vs polling — A live websocket balance could be overwritten seconds later by a stale Accounts API poll (TanStack Query cache, ~60s staleTime), or polling could “win” right after a websocket update.
  2. Account switch ordering — On account group change we re-subscribed before forcing a fresh fetch, so websocket notifications could arrive before authoritative API data was applied.
  3. Websocket subscription races — Concurrent subscribe/unsubscribe during account switches could interleave; EVM address comparison was case-sensitive, so a checksummed vs lowercase address looked like a “new” account and triggered unnecessary rebinds or missed matches.
  4. Disconnect/reconnect gap — Notifications routed by subscriptionId could be dropped when the server-side subscription ID changed after reconnect; there was no per-channel callback fallback.
  5. forceUpdate still stalegetAssets({ forceUpdate: true }) did not bypass the Accounts API TanStack cache, and websocket freshness guards could block the forced fetch from applying (e.g. receiver switches to account 2 after a send but state still shows the pre-receive balance).

What does this PR do?

AssetsController

  • Tags applied updates with an internal sourceId on DataResponse.
  • After a BackendWebsocketDataSource balance push, marks those accountId:assetId entries fresh for 120s; passive polling sources (AccountsApiDataSource, RpcDataSource, SnapDataSource, StakedBalanceDataSource) cannot overwrite them during that window.
  • getAssets({ forceUpdate: true }) is tagged getAssets:forceUpdate, clears freshness locks for applied balances, and is not subject to the websocket freshness filter — authoritative for account switch / manual refresh.
  • Account group and account-tree refreshes run under a mutex: forceUpdate fetch first, then subscribe, so API balances land before websocket recovery.
  • Account-tree handler skips tree updates with no overlapping account IDs (full group switches still go through #handleAccountGroupChanged).

AccountsApiDataSource

  • When request.forceUpdate is true, passes { staleTime: 0, gcTime: 0 } to fetchV5MultiAccountBalances so forced refreshes bypass the TanStack cache.

BackendWebsocketDataSource

  • Serializes subscribe/unsubscribe with a lock so account switches cannot interleave.
  • Treats EVM addresses as unchanged when only checksumming differs; re-subscribes when the account set actually changes.
  • Registers optional per-channel callbacks when server subscriptionId routing is unreliable after reconnect.
  • Tears down subscriptions inline (without re-acquiring the subscribe lock) and forwards the original DataRequest with balance updates.

Non-obvious details

  • sourceId is internal — added to DataResponse for merge policy only; not a new public messenger action.
  • Freshness guard is polling-only — websocket updates are still applied immediately; the guard only blocks stale poll merges, not other websocket pushes or forceUpdate.
  • Channel callbacks are best-effort — if BackendWebSocketService:addChannelCallback is not delegated in the client messenger, the primary websocket subscription path still works; callbacks are a fallback for reconnect routing mismatches.

Scope

All changes are in @metamask/assets-controller only. No dependency upgrades and no breaking public API changes.

Test plan

  • yarn workspace @metamask/assets-controller test — new/updated unit tests pass
  • yarn workspace @metamask/assets-controller run changelog:validate
  • Manual: send USDC on Arbitrum, confirm balance updates via websocket
  • Manual: disconnect/reconnect websocket, confirm balance recovers after resubscribe
  • Manual: switch to receiver account after inbound transfer, confirm forceUpdate shows correct balance

New unit test coverage:

  • AssetsController: polling does not overwrite recent websocket balance; getAssets forceUpdate wins over websocket freshness
  • AccountsApiDataSource: forceUpdate bypasses TanStack cache
  • BackendWebsocketDataSource: concurrent subscribe serialization, checksummed vs lowercase EVM addresses, channel-callback fallback, disconnect/reconnect chain reclaim

References

  • Fixes stale balance races after transactions, account switches, and websocket reconnects (#9265)

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 (changelog only; behavior is internal merge policy)
  • 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 (N/A — no breaking changes)

Note

Medium Risk
Changes how displayed balances are merged across websocket and polling paths and reorders fetch/subscribe on account changes; incorrect freshness or locking could show wrong balances until the next forceUpdate fetch.

Overview
Fixes stale or flickering balances when backend websocket updates compete with Accounts API / RPC polling, and tightens account-switch and websocket subscription ordering so live updates are not dropped after reconnects or rapid resubscribes.

  • AssetsController: internal sourceId tagging; 120s websocket freshness guard for polling sources; getAssets:forceUpdate clears locks and wins; mutex-serialized fetch-then-subscribe on account change
  • AccountsApiDataSource: staleTime: 0 / gcTime: 0 on forceUpdate
  • BackendWebsocketDataSource: subscribe lock, case-insensitive EVM address matching, per-channel callback fallback, clean teardown on resubscribe

Note

Medium Risk
Changes how displayed balances are merged across websocket and polling paths and reorders fetch/subscribe on account changes; incorrect freshness or locking could show wrong balances until the next forceUpdate fetch.

Overview
Fixes stale or flickering token balances when websocket pushes compete with Accounts API / RPC polling, and when subscriptions are recreated on account switch or reconnect.

AssetsController tags applied updates with internal sourceId on DataResponse. After websocket balance pushes, per-asset freshness locks block passive polling sources for 120s; getAssets({ forceUpdate: true }) is tagged getAssets:forceUpdate, skips that filter, and clears locks for applied balances. Account group and account-tree refreshes use a mutex and run force fetch before subscribe; account-tree updates with no overlapping account IDs are ignored.

AccountsApiDataSource passes { staleTime: 0, gcTime: 0 } to multi-account balance fetches when forceUpdate is set.

BackendWebsocketDataSource serializes subscribe/unsubscribe, treats EVM addresses as unchanged when only checksumming differs, registers optional per-channel callbacks for reconnect routing, and passes the original DataRequest with balance updates.

Reviewed by Cursor Bugbot for commit 773e4e8. Bugbot is set up for automated code reviews on this repo. Configure here.

@salimtb salimtb force-pushed the fix/ws-balance-stale-after-reconnect branch from f6eaa4f to 39a67eb Compare June 25, 2026 09:38
@salimtb salimtb changed the title test(assets-controller): add BackendWebsocketDataSource tests for Arbitrum USDC balance update fix: add BackendWebsocketDataSource tests for Arbitrum USDC balance update Jun 25, 2026
@salimtb salimtb force-pushed the fix/ws-balance-stale-after-reconnect branch 2 times, most recently from 1cdc5c2 to ca17234 Compare June 25, 2026 09:55
@salimtb

salimtb commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

@metamaskbot publish-preview

@salimtb salimtb marked this pull request as ready for review June 25, 2026 10:02
@salimtb salimtb requested review from a team as code owners June 25, 2026 10:02
@salimtb salimtb temporarily deployed to default-branch June 25, 2026 10:02 — with GitHub Actions Inactive
@github-actions

Copy link
Copy Markdown
Contributor

Preview builds have been published. Learn how to use preview builds in other projects.

Expand for full list of packages and versions.
@metamask-previews/account-tree-controller@7.5.3-preview-ca1723428
@metamask-previews/accounts-controller@39.0.3-preview-ca1723428
@metamask-previews/address-book-controller@7.1.2-preview-ca1723428
@metamask-previews/ai-controllers@0.7.0-preview-ca1723428
@metamask-previews/analytics-controller@1.1.1-preview-ca1723428
@metamask-previews/analytics-data-regulation-controller@0.0.0-preview-ca1723428
@metamask-previews/announcement-controller@8.1.0-preview-ca1723428
@metamask-previews/app-metadata-controller@2.0.1-preview-ca1723428
@metamask-previews/approval-controller@9.0.2-preview-ca1723428
@metamask-previews/assets-controller@9.1.0-preview-ca1723428
@metamask-previews/assets-controllers@109.2.2-preview-ca1723428
@metamask-previews/authenticated-user-storage@2.1.0-preview-ca1723428
@metamask-previews/base-controller@9.1.0-preview-ca1723428
@metamask-previews/base-data-service@0.1.3-preview-ca1723428
@metamask-previews/bitcoin-regtest-up@0.0.0-preview-ca1723428
@metamask-previews/bridge-controller@77.0.0-preview-ca1723428
@metamask-previews/bridge-status-controller@73.0.0-preview-ca1723428
@metamask-previews/build-utils@3.0.4-preview-ca1723428
@metamask-previews/chain-agnostic-permission@1.6.2-preview-ca1723428
@metamask-previews/chomp-api-service@3.1.0-preview-ca1723428
@metamask-previews/claims-controller@0.5.3-preview-ca1723428
@metamask-previews/client-controller@1.0.1-preview-ca1723428
@metamask-previews/compliance-controller@2.1.0-preview-ca1723428
@metamask-previews/composable-controller@12.0.1-preview-ca1723428
@metamask-previews/config-registry-controller@0.4.1-preview-ca1723428
@metamask-previews/connectivity-controller@0.2.0-preview-ca1723428
@metamask-previews/controller-utils@12.3.0-preview-ca1723428
@metamask-previews/core-backend@6.3.3-preview-ca1723428
@metamask-previews/delegation-controller@3.0.2-preview-ca1723428
@metamask-previews/earn-controller@12.2.1-preview-ca1723428
@metamask-previews/eip-5792-middleware@3.0.4-preview-ca1723428
@metamask-previews/eip-7702-internal-rpc-middleware@0.1.1-preview-ca1723428
@metamask-previews/eip1193-permission-middleware@2.0.1-preview-ca1723428
@metamask-previews/ens-controller@19.1.4-preview-ca1723428
@metamask-previews/eth-block-tracker@15.0.1-preview-ca1723428
@metamask-previews/eth-json-rpc-middleware@23.1.3-preview-ca1723428
@metamask-previews/eth-json-rpc-provider@6.0.1-preview-ca1723428
@metamask-previews/foundryup@1.0.1-preview-ca1723428
@metamask-previews/gas-fee-controller@26.2.3-preview-ca1723428
@metamask-previews/gator-permissions-controller@4.2.1-preview-ca1723428
@metamask-previews/geolocation-controller@0.1.3-preview-ca1723428
@metamask-previews/java-tron-up@0.0.0-preview-ca1723428
@metamask-previews/json-rpc-engine@10.5.0-preview-ca1723428
@metamask-previews/json-rpc-middleware-stream@8.0.8-preview-ca1723428
@metamask-previews/keyring-controller@27.1.0-preview-ca1723428
@metamask-previews/local-node-utils@0.0.0-preview-ca1723428
@metamask-previews/logging-controller@8.0.2-preview-ca1723428
@metamask-previews/message-manager@14.1.2-preview-ca1723428
@metamask-previews/messenger@1.2.0-preview-ca1723428
@metamask-previews/messenger-cli@0.2.0-preview-ca1723428
@metamask-previews/money-account-balance-service@2.1.1-preview-ca1723428
@metamask-previews/money-account-controller@0.3.3-preview-ca1723428
@metamask-previews/money-account-upgrade-controller@2.1.0-preview-ca1723428
@metamask-previews/multichain-account-service@11.1.0-preview-ca1723428
@metamask-previews/multichain-api-middleware@3.1.5-preview-ca1723428
@metamask-previews/multichain-network-controller@3.2.0-preview-ca1723428
@metamask-previews/multichain-transactions-controller@7.1.1-preview-ca1723428
@metamask-previews/name-controller@9.1.2-preview-ca1723428
@metamask-previews/network-controller@33.0.0-preview-ca1723428
@metamask-previews/network-enablement-controller@5.4.0-preview-ca1723428
@metamask-previews/notification-services-controller@24.2.0-preview-ca1723428
@metamask-previews/passkey-controller@2.0.1-preview-ca1723428
@metamask-previews/permission-controller@13.1.1-preview-ca1723428
@metamask-previews/permission-log-controller@5.1.0-preview-ca1723428
@metamask-previews/perps-controller@9.0.0-preview-ca1723428
@metamask-previews/phishing-controller@17.2.0-preview-ca1723428
@metamask-previews/polling-controller@16.0.7-preview-ca1723428
@metamask-previews/preferences-controller@23.1.0-preview-ca1723428
@metamask-previews/profile-metrics-controller@4.0.0-preview-ca1723428
@metamask-previews/profile-sync-controller@28.2.0-preview-ca1723428
@metamask-previews/ramps-controller@15.0.0-preview-ca1723428
@metamask-previews/rate-limit-controller@7.0.1-preview-ca1723428
@metamask-previews/react-data-query@0.2.1-preview-ca1723428
@metamask-previews/remote-feature-flag-controller@4.2.2-preview-ca1723428
@metamask-previews/sample-controllers@5.0.2-preview-ca1723428
@metamask-previews/seedless-onboarding-controller@10.0.2-preview-ca1723428
@metamask-previews/selected-network-controller@26.1.4-preview-ca1723428
@metamask-previews/shield-controller@5.1.2-preview-ca1723428
@metamask-previews/signature-controller@39.2.6-preview-ca1723428
@metamask-previews/smart-transactions-controller@24.2.3-preview-ca1723428
@metamask-previews/snap-account-service@1.0.0-preview-ca1723428
@metamask-previews/social-controllers@2.3.1-preview-ca1723428
@metamask-previews/solana-test-validator-up@0.0.0-preview-ca1723428
@metamask-previews/storage-service@1.0.2-preview-ca1723428
@metamask-previews/subscription-controller@6.2.0-preview-ca1723428
@metamask-previews/transaction-controller@68.2.0-preview-ca1723428
@metamask-previews/transaction-pay-controller@23.16.1-preview-ca1723428
@metamask-previews/user-operation-controller@41.2.5-preview-ca1723428
@metamask-previews/wallet@5.0.0-preview-ca1723428
@metamask-previews/wallet-cli@0.0.0-preview-ca1723428

Comment thread packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts Outdated
Comment thread packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts Outdated
@salimtb salimtb force-pushed the fix/ws-balance-stale-after-reconnect branch from 432b1cd to 4e4e438 Compare June 25, 2026 14:24
Comment thread packages/assets-controller/src/AssetsController.ts
@salimtb

salimtb commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

@metamaskbot publish-preview

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.

2 participants