From 82b63864f38ecde904ad55095071c75d18f98fb8 Mon Sep 17 00:00:00 2001 From: salimtb Date: Thu, 25 Jun 2026 17:29:46 +0200 Subject: [PATCH 01/24] refactor(assets-controller): remove websocket balance freshness guard The polling-source freshness lock is unnecessary alongside fetch-then-subscribe and Accounts API cache bypass on forceUpdate. --- packages/assets-controller/CHANGELOG.md | 2 +- .../src/AssetsController.test.ts | 76 ------------ .../assets-controller/src/AssetsController.ts | 115 ++---------------- packages/assets-controller/src/types.ts | 7 -- 4 files changed, 9 insertions(+), 191 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 3e9c4373da..5ed78f5e1a 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fix stale token balances after transactions when switching accounts or when websocket subscriptions reconnect; `AssetsController` now fetches before re-subscribing on account switch, serializes overlapping refresh work, treats `getAssets({ forceUpdate: true })` as authoritative over recent websocket freshness guards, and prevents passive polling from overwriting websocket balances for 120 seconds ([#9265](https://github.com/MetaMask/core/pull/9265)) +- Fix stale token balances after transactions when switching accounts or when websocket subscriptions reconnect; `AssetsController` now fetches before re-subscribing on account switch and serializes overlapping refresh work ([#9265](https://github.com/MetaMask/core/pull/9265)) - `AccountsApiDataSource` bypasses the TanStack Query balance cache when `forceUpdate` is true so forced refreshes return up-to-date balances instead of 60-second cached values ([#9265](https://github.com/MetaMask/core/pull/9265)) - `BackendWebsocketDataSource` re-subscribes when subscribed accounts change (case-insensitive EVM address matching), serializes subscribe/unsubscribe to prevent races on account switch, and registers optional channel callbacks for more reliable notification delivery ([#9265](https://github.com/MetaMask/core/pull/9265)) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index df9d52a6b1..b81c411902 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1720,82 +1720,6 @@ describe('AssetsController', () => { }); }); - it('does not let subscription polling overwrite a recent websocket balance update', async () => { - const initialState: Partial = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_ID]: { amount: '8.185173' }, - }, - }, - }; - - await withController({ state: initialState }, async ({ controller }) => { - await controller.handleAssetsUpdate( - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_ID]: { amount: '7.185173' }, - }, - }, - }, - 'BackendWebsocketDataSource', - ); - - await controller.handleAssetsUpdate( - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_ID]: { amount: '8.185173' }, - }, - }, - }, - 'AccountsApiDataSource', - ); - - expect( - controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], - ).toStrictEqual({ amount: '7.185173' }); - }); - }); - - it('applies getAssets forceUpdate over a recent websocket balance update', async () => { - const initialState: Partial = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_ID]: { amount: '8.185173' }, - }, - }, - }; - - await withController({ state: initialState }, async ({ controller }) => { - await controller.handleAssetsUpdate( - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_ID]: { amount: '7.185173' }, - }, - }, - }, - 'BackendWebsocketDataSource', - ); - - await controller.handleAssetsUpdate( - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_ID]: { amount: '8.185173' }, - }, - }, - }, - 'getAssets:forceUpdate', - ); - - expect( - controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], - ).toStrictEqual({ amount: '8.185173' }); - }); - }); - it('replaces state when full update has authoritative data', async () => { const initialState: Partial = { assetsBalance: { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index d559705351..4c68670525 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -188,17 +188,6 @@ const MESSENGER_EXPOSED_METHODS = [ /** Default polling interval hint for data sources (30 seconds) */ const DEFAULT_POLLING_INTERVAL_MS = 30_000; -/** Sources whose passive polling must not overwrite recent websocket balances. */ -const POLLING_BALANCE_SOURCES = new Set([ - 'AccountsApiDataSource', - 'RpcDataSource', - 'SnapDataSource', - 'StakedBalanceDataSource', -]); - -/** How long websocket balance updates block stale polling overwrites. */ -const WS_BALANCE_FRESHNESS_MS = 120_000; - // ============================================================================ // TRACE NAMES — used in Sentry spans (search these strings in Discover) // ============================================================================ @@ -536,10 +525,6 @@ function normalizeResponse(response: DataResponse): DataResponse { normalized.updateMode = response.updateMode; } - if (response.sourceId) { - normalized.sourceId = response.sourceId; - } - return normalized; } @@ -703,12 +688,6 @@ export class AssetsController extends BaseController< */ #lastKnownAccountIds: ReadonlySet = new Set(); - /** - * Per `accountId:assetId`, timestamp until which websocket balance updates - * should not be overwritten by polling/API fetches. - */ - readonly #wsBalanceFreshUntil = new Map(); - /** * Get the currently selected accounts from AccountTreeController. * This includes all accounts in the same group as the selected account @@ -1471,11 +1450,7 @@ export class AssetsController extends BaseController< // The fast pipeline only contains a subset of data sources (AccountsApi + // StakedBalance), so it must always merge to avoid wiping Snap/RPC // balances that the background pipeline hasn't yet replaced. - await this.#updateState({ - ...response, - updateMode: 'merge', - sourceId: 'getAssets:forceUpdate', - }); + await this.#updateState({ ...response, updateMode: 'merge' }); // Background pipeline: snap and RPC run in parallel after the fast path // commits to state. Their balances are merged together before detection. @@ -1501,11 +1476,7 @@ export class AssetsController extends BaseController< request, ) .then(({ response: slowResponse }) => - this.#updateState({ - ...slowResponse, - updateMode: 'merge', - sourceId: 'getAssets:forceUpdate', - }), + this.#updateState({ ...slowResponse, updateMode: 'merge' }), ) .catch((error) => log('Background pipeline failed', { error })); @@ -2164,58 +2135,10 @@ export class AssetsController extends BaseController< }); } - #filterBalancesRespectingWsFreshness( - accountId: string, - accountBalances: Record, - sourceId?: string, - ): Record { - if (!sourceId || !POLLING_BALANCE_SOURCES.has(sourceId)) { - return accountBalances; - } - - const now = Date.now(); - const filtered: Record = {}; - - for (const [assetId, balance] of Object.entries(accountBalances)) { - const freshUntil = this.#wsBalanceFreshUntil.get( - `${accountId}:${assetId}`, - ); - if (freshUntil !== undefined && now < freshUntil) { - continue; - } - filtered[assetId] = balance; - } - - return filtered; - } - - #markWsBalancesFresh( - assetsBalance: Record>, - ): void { - const freshUntil = Date.now() + WS_BALANCE_FRESHNESS_MS; - for (const [accountId, balances] of Object.entries(assetsBalance)) { - for (const assetId of Object.keys(balances)) { - this.#wsBalanceFreshUntil.set(`${accountId}:${assetId}`, freshUntil); - } - } - } - - #clearWsBalanceFreshness( - assetsBalance: Record>, - ): void { - for (const [accountId, balances] of Object.entries(assetsBalance)) { - for (const assetId of Object.keys(balances)) { - this.#wsBalanceFreshUntil.delete(`${accountId}:${assetId}`); - } - } - } - async #updateState(response: DataResponse): Promise { const normalizedResponse = normalizeResponse(response); const mode: AssetsUpdateMode = normalizedResponse.updateMode ?? 'merge'; - const assetsBalanceToApply = normalizedResponse.assetsBalance; - const releaseLock = await this.#controllerMutex.acquire(); try { @@ -2298,16 +2221,10 @@ export class AssetsController extends BaseController< } } - if (assetsBalanceToApply) { + if (normalizedResponse.assetsBalance) { for (const [accountId, accountBalances] of Object.entries( - assetsBalanceToApply, + normalizedResponse.assetsBalance, )) { - const filteredAccountBalances = - this.#filterBalancesRespectingWsFreshness( - accountId, - accountBalances, - normalizedResponse.sourceId, - ); const previousBalances = previousState.assetsBalance[accountId] ?? {}; const customAssetIds = @@ -2322,11 +2239,11 @@ export class AssetsController extends BaseController< // Merge: response overlays previous balances. const effective: Record = mode === 'merge' - ? { ...previousBalances, ...filteredAccountBalances } + ? { ...previousBalances, ...accountBalances } : ((): Record => { // Determine which chain namespaces this response covers. const coveredChains = new Set( - Object.keys(filteredAccountBalances).map( + Object.keys(accountBalances).map( (assetId) => assetId.split('/')[0], ), ); @@ -2343,7 +2260,7 @@ export class AssetsController extends BaseController< } // Apply the response (authoritative for covered chains). - Object.assign(next, filteredAccountBalances); + Object.assign(next, accountBalances); // Preserve custom assets that the response omitted. for (const customId of customAssetIds) { @@ -2490,15 +2407,6 @@ export class AssetsController extends BaseController< }); } } - - // Authoritative fetch on account switch — drop WS freshness locks so API - // balances (e.g. receiver +1 USDC) replace stale values from a prior send. - if ( - normalizedResponse.sourceId === 'getAssets:forceUpdate' && - assetsBalanceToApply - ) { - this.#clearWsBalanceFreshness(assetsBalanceToApply); - } } finally { releaseLock(); } @@ -3323,14 +3231,7 @@ export class AssetsController extends BaseController< response, ); - await this.#updateState({ ...enrichedResponse, sourceId }); - - if ( - sourceId === 'BackendWebsocketDataSource' && - enrichedResponse.assetsBalance - ) { - this.#markWsBalancesFresh(enrichedResponse.assetsBalance); - } + await this.#updateState(enrichedResponse); this.#emitTrace(TRACE_UPDATE_PIPELINE, { source: sourceId, diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 90acb98f7a..038fe74df9 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -371,13 +371,6 @@ export type DataResponse = { * Defaults to `'merge'` if omitted. */ updateMode?: AssetsUpdateMode; - /** - * Set by AssetsController when applying updates. Data sources must - * not populate this field. - * - * @internal - */ - sourceId?: string; }; /** From eec90eb07fe90d6d0d1a8aa929c92aebfca66ed0 Mon Sep 17 00:00:00 2001 From: salimtb Date: Thu, 25 Jun 2026 23:37:27 +0200 Subject: [PATCH 02/24] fix: route account-activity WS notifications and refresh balances reliably Improve BackendWebSocketService routing for wildcard channels and stale subscription IDs, harden BackendWebsocketDataSource subscribe/notify flow, and use update-mode force refresh so partial API snapshots do not leave stale token balances in state. --- packages/assets-controller/CHANGELOG.md | 3 +- .../src/AssetsController.test.ts | 79 +++++++ .../assets-controller/src/AssetsController.ts | 26 ++- .../src/data-sources/AccountsApiDataSource.ts | 2 +- .../BackendWebsocketDataSource.test.ts | 2 +- .../BackendWebsocketDataSource.ts | 49 +++-- .../src/data-sources/SnapDataSource.test.ts | 4 +- .../src/data-sources/SnapDataSource.ts | 6 +- .../src/middlewares/ParallelMiddleware.ts | 11 + packages/assets-controller/src/types.ts | 7 +- packages/core-backend/CHANGELOG.md | 4 + .../src/ws/BackendWebSocketService.test.ts | 63 ++++++ .../src/ws/BackendWebSocketService.ts | 193 +++++++++++++++++- 13 files changed, 395 insertions(+), 54 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 5ed78f5e1a..01aab86c0f 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -15,7 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix stale token balances after transactions when switching accounts or when websocket subscriptions reconnect; `AssetsController` now fetches before re-subscribing on account switch and serializes overlapping refresh work ([#9265](https://github.com/MetaMask/core/pull/9265)) - `AccountsApiDataSource` bypasses the TanStack Query balance cache when `forceUpdate` is true so forced refreshes return up-to-date balances instead of 60-second cached values ([#9265](https://github.com/MetaMask/core/pull/9265)) -- `BackendWebsocketDataSource` re-subscribes when subscribed accounts change (case-insensitive EVM address matching), serializes subscribe/unsubscribe to prevent races on account switch, and registers optional channel callbacks for more reliable notification delivery ([#9265](https://github.com/MetaMask/core/pull/9265)) +- `BackendWebsocketDataSource` re-subscribes when subscribed accounts change (case-insensitive EVM address matching), serializes subscribe/unsubscribe to prevent races on account switch, and registers channel callbacks as a fallback when server `subscriptionId` values do not match ([#9265](https://github.com/MetaMask/core/pull/9265)) +- Add `update` balance update mode so force-refresh pipelines patch balances without removing tokens omitted from partial API snapshots; `AccountsApiDataSource` and `SnapDataSource` use this mode for fetch responses ([#9265](https://github.com/MetaMask/core/pull/9265)) ## [9.1.0] diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index b81c411902..a4165fa1c4 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1755,6 +1755,85 @@ describe('AssetsController', () => { }); }); + it('overlays balances without removing tokens when update mode is used', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '6.185173' }, + [MOCK_NATIVE_ASSET_ID]: { amount: '0.000390285791392' }, + }, + }, + assetsInfo: { + [MOCK_ASSET_ID]: { + type: 'erc20', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + }, + }; + + await withController({ state: initialState }, async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'update', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '0.000389261286724' }, + }, + }, + assetsInfo: { + [MOCK_ASSET_ID]: { + type: 'erc20', + symbol: 'REPLACED', + name: 'Replaced', + decimals: 18, + }, + }, + }, + 'TestSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toStrictEqual({ amount: '6.185173' }); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + MOCK_NATIVE_ASSET_ID + ], + ).toStrictEqual({ amount: '0.000389261286724' }); + expect(controller.state.assetsInfo[MOCK_ASSET_ID]?.symbol).toBe('USDC'); + }); + }); + + it('updates balance amounts present in update mode response', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '1' }, + }, + }, + }; + + await withController({ state: initialState }, async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'update', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '2' }, + }, + }, + }, + 'TestSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toStrictEqual({ amount: '2' }); + }); + }); + it('updates state with price data', async () => { await withController(async ({ controller }) => { await controller.handleAssetsUpdate( diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 4c68670525..5ba75514ce 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -324,6 +324,7 @@ type AllowedEvents = | KeyringControllerUnlockEvent | PreferencesControllerStateChangeEvent | TransactionControllerUnapprovedTransactionAddedEvent + | TransactionControllerTransactionConfirmedEvent // RpcDataSource, StakedBalanceDataSource | NetworkControllerStateChangeEvent // AssetsController (default-asset seeding + cross-source asset refresh @@ -331,7 +332,6 @@ type AllowedEvents = // NetworkController) | NetworkControllerNetworkAddedEvent | NetworkControllerNetworkRemovedEvent - | TransactionControllerTransactionConfirmedEvent // StakedBalanceDataSource | NetworkEnablementControllerEvents // SnapDataSource @@ -1422,9 +1422,9 @@ export class AssetsController extends BaseController< // creation, RPC is slow on many chains). Results are committed to state // immediately so the UI can display balances without waiting for them. // - // Both the fast and background pipelines use 'merge' mode because neither - // alone represents the full set of data sources. Using 'full' in either - // would wipe balances from the sources handled by the other pipeline. + // Fast/slow pipelines use 'update' so partial API snapshots cannot wipe + // tokens missing from the response (e.g. USDC when only native balance + // is returned). Balances present in the response are still refreshed. const fastSources = this.#isBasicFunctionality() ? [ createParallelBalanceMiddleware([ @@ -1447,10 +1447,7 @@ export class AssetsController extends BaseController< fastSources, request, ); - // The fast pipeline only contains a subset of data sources (AccountsApi + - // StakedBalance), so it must always merge to avoid wiping Snap/RPC - // balances that the background pipeline hasn't yet replaced. - await this.#updateState({ ...response, updateMode: 'merge' }); + await this.#updateState({ ...response, updateMode: 'update' }); // Background pipeline: snap and RPC run in parallel after the fast path // commits to state. Their balances are merged together before detection. @@ -1476,7 +1473,7 @@ export class AssetsController extends BaseController< request, ) .then(({ response: slowResponse }) => - this.#updateState({ ...slowResponse, updateMode: 'merge' }), + this.#updateState({ ...slowResponse, updateMode: 'update' }), ) .catch((error) => log('Background pipeline failed', { error })); @@ -2166,7 +2163,7 @@ export class AssetsController extends BaseController< >; const prices = state.assetsPrice as Record; - if (normalizedResponse.assetsInfo) { + if (normalizedResponse.assetsInfo && mode !== 'update') { for (const [key, value] of Object.entries( normalizedResponse.assetsInfo, )) { @@ -2236,9 +2233,10 @@ export class AssetsController extends BaseController< // balances for chains not in the response are preserved from // previous state so unsupported chains (e.g. Ink on AccountsAPI) // are never inadvertently reset to zero. - // Merge: response overlays previous balances. + // Merge / update: response overlays previous balances (update never + // removes tokens missing from a partial snapshot). const effective: Record = - mode === 'merge' + mode === 'merge' || mode === 'update' ? { ...previousBalances, ...accountBalances } : ((): Record => { // Determine which chain namespaces this response covers. @@ -2323,8 +2321,8 @@ export class AssetsController extends BaseController< } } - // Update prices in state - if (normalizedResponse.assetsPrice) { + // Update prices in state (skipped for balance-only update mode) + if (normalizedResponse.assetsPrice && mode !== 'update') { for (const [key, value] of Object.entries( normalizedResponse.assetsPrice, )) { diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index ecd614432a..23d07389fe 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -351,7 +351,7 @@ export class AccountsApiDataSource extends AbstractDataSource< ); response.assetsBalance = assetsBalance; - response.updateMode = 'merge'; + response.updateMode = 'update'; } catch (error) { log('Fetch FAILED', { error, chains: chainsToFetch }); diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts index 676bff2c8f..56f0da024f 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts @@ -667,7 +667,7 @@ describe('BackendWebsocketDataSource', () => { channelCallback( createMockNotification({ - channel, + channel: `account-activity.v1.eip155:42161:${MOCK_ADDRESS.toLowerCase()}`, subscriptionId: 'stale-server-sub-id', data: { address: MOCK_ADDRESS, diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts index 945272d8e0..65132a7eb8 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts @@ -588,6 +588,18 @@ export class BackendWebsocketDataSource extends AbstractDataSource< } try { + // Register request/callback before awaiting server subscribe so notifications + // that arrive during the subscribe handshake are not dropped. + this.#subscriptionRequests.set(subscriptionId, subscriptionRequest); + this.activeSubscriptions.set(subscriptionId, { + cleanup: () => { + this.#teardownSubscription(subscriptionId).catch(() => undefined); + }, + chains: chainsToSubscribe, + addresses, + onAssetsUpdate: subscriptionRequest.onAssetsUpdate, + }); + // Create WebSocket subscription const wsSubscription = await this.#messenger.call( 'BackendWebSocketService:subscribe', @@ -600,21 +612,8 @@ export class BackendWebsocketDataSource extends AbstractDataSource< }, ); - // Store WebSocket subscription and subscription state before optional - // channel callbacks — wsCallback routing works without them. this.#wsSubscriptions.set(subscriptionId, wsSubscription); - this.activeSubscriptions.set(subscriptionId, { - cleanup: () => { - this.#teardownSubscription(subscriptionId).catch(() => undefined); - }, - chains: chainsToSubscribe, - addresses, - onAssetsUpdate: subscriptionRequest.onAssetsUpdate, - }); - - this.#subscriptionRequests.set(subscriptionId, subscriptionRequest); - try { this.#registerChannelCallbacks(subscriptionId, channels); } catch (channelCallbackError) { @@ -624,6 +623,8 @@ export class BackendWebsocketDataSource extends AbstractDataSource< ); } } catch (error) { + this.activeSubscriptions.delete(subscriptionId); + this.#subscriptionRequests.delete(subscriptionId); log('WebSocket subscription FAILED', { subscriptionId, error, @@ -641,14 +642,19 @@ export class BackendWebsocketDataSource extends AbstractDataSource< subscriptionId: string, ): void { try { - const subscription = this.activeSubscriptions.get(subscriptionId); - const request = this.#subscriptionRequests.get(subscriptionId)?.request; + const activityMessage = + notification.data as unknown as AccountActivityMessage; + + const storedSubscription = this.#subscriptionRequests.get(subscriptionId); + const request = storedSubscription?.request; + const onAssetsUpdate = + this.activeSubscriptions.get(subscriptionId)?.onAssetsUpdate ?? + storedSubscription?.onAssetsUpdate; + if (!request) { return; } - const activityMessage = - notification.data as unknown as AccountActivityMessage; const { address, tx, updates } = activityMessage; if (!address || !tx || !updates) { @@ -674,10 +680,11 @@ export class BackendWebsocketDataSource extends AbstractDataSource< // Process all balance updates from the activity message const response = this.#processBalanceUpdates(updates, chainId, accountId); - if (Object.keys(response).length > 0 && subscription) { - Promise.resolve(subscription.onAssetsUpdate(response, request)).catch( - console.error, - ); + const hasBalances = + Object.keys(response.assetsBalance?.[accountId] ?? {}).length > 0; + + if (hasBalances && onAssetsUpdate) { + Promise.resolve(onAssetsUpdate(response, request)).catch(console.error); } } catch (error) { log('Error handling notification', error); diff --git a/packages/assets-controller/src/data-sources/SnapDataSource.test.ts b/packages/assets-controller/src/data-sources/SnapDataSource.test.ts index 8518b3d694..412b0bb79e 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.test.ts @@ -435,7 +435,7 @@ describe('SnapDataSource', () => { expect(response).toStrictEqual({ assetsBalance: {}, assetsInfo: {}, - updateMode: 'merge', + updateMode: 'update', }); cleanup(); @@ -470,7 +470,7 @@ describe('SnapDataSource', () => { expect(response).toStrictEqual({ assetsBalance: {}, assetsInfo: {}, - updateMode: 'merge', + updateMode: 'update', }); cleanup(); diff --git a/packages/assets-controller/src/data-sources/SnapDataSource.ts b/packages/assets-controller/src/data-sources/SnapDataSource.ts index 17da7b0b5e..75ea392531 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.ts @@ -304,7 +304,7 @@ export class SnapDataSource extends AbstractDataSource< // Only report if we have snap-related updates if (assetsBalance) { - const response: DataResponse = { assetsBalance, updateMode: 'merge' }; + const response: DataResponse = { assetsBalance, updateMode: 'update' }; for (const subscription of this.activeSubscriptions.values()) { subscription.onAssetsUpdate(response)?.catch(console.error); } @@ -445,13 +445,13 @@ export class SnapDataSource extends AbstractDataSource< return {}; } if (!request?.accountsWithSupportedChains?.length) { - return { assetsBalance: {}, assetsInfo: {}, updateMode: 'merge' }; + return { assetsBalance: {}, assetsInfo: {}, updateMode: 'update' }; } const results: DataResponse = { assetsBalance: {}, assetsInfo: {}, - updateMode: 'merge', + updateMode: 'update', }; // Fetch balances for each account using its snap ID from metadata diff --git a/packages/assets-controller/src/middlewares/ParallelMiddleware.ts b/packages/assets-controller/src/middlewares/ParallelMiddleware.ts index 3947bb356d..bc285346dd 100644 --- a/packages/assets-controller/src/middlewares/ParallelMiddleware.ts +++ b/packages/assets-controller/src/middlewares/ParallelMiddleware.ts @@ -61,6 +61,17 @@ export function mergeDataResponses(responses: DataResponse[]): DataResponse { } if (response.updateMode === 'full') { merged.updateMode = 'full'; + } else if ( + response.updateMode === 'merge' && + merged.updateMode !== 'full' + ) { + merged.updateMode = 'merge'; + } else if ( + response.updateMode === 'update' && + merged.updateMode !== 'full' && + merged.updateMode !== 'merge' + ) { + merged.updateMode = 'update'; } } merged.updateMode ??= 'merge'; diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 038fe74df9..cfc4dadb5e 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -379,9 +379,12 @@ export type DataResponse = { * - **full**: Response is the full set for the scope. Assets in state but not in the * response are cleared (except custom assets). Use for initial fetch or full refresh. * - **merge**: Only assets present in the response are updated; nothing is removed. - * Use for event-driven or incremental updates. + * Metadata and prices from the response are applied. Use for event-driven updates. + * - **update**: Balance-only overlay — incoming balance amounts are patched in place; + * existing balances, metadata, and prices are never removed or overwritten. + * Use for force refresh when the API may return a partial chain snapshot. */ -export type AssetsUpdateMode = 'full' | 'merge'; +export type AssetsUpdateMode = 'full' | 'merge' | 'update'; // ============================================================================ // DATA SOURCE <-> CONTROLLER (DIRECT CALLS, NO MESSENGER PER SOURCE) diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index bacdc818ee..ffbac444a8 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) - Bump `@metamask/accounts-controller` from `^39.0.1` to `^39.0.3` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9231](https://github.com/MetaMask/core/pull/9231)) +### Fixed + +- `BackendWebSocketService` routes account-activity notifications when subscribed channels use chain wildcard `0` but the server sends a specific chain id, falls back to channel-based subscription lookup when `subscriptionId` is stale, and normalizes nested notification payloads ([#9265](https://github.com/MetaMask/core/pull/9265)) + ## [6.3.3] ### Changed diff --git a/packages/core-backend/src/ws/BackendWebSocketService.test.ts b/packages/core-backend/src/ws/BackendWebSocketService.test.ts index 9c17e430c2..1badf9557f 100644 --- a/packages/core-backend/src/ws/BackendWebSocketService.test.ts +++ b/packages/core-backend/src/ws/BackendWebSocketService.test.ts @@ -1805,6 +1805,69 @@ describe('BackendWebSocketService', () => { }); }); + it('should route account-activity notifications to wildcard channel callbacks', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + const subscribedChannel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + const notificationChannel = + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890'; + + service.addChannelCallback({ + channelName: subscribedChannel, + callback: channelCallback, + }); + + const notification = { + event: 'notification', + channel: notificationChannel, + subscriptionId: 'stale-subscription-id', + data: { address: '0x1234567890123456789012345678901234567890' }, + timestamp: 1760344704595, + }; + + mockWs.simulateMessage(notification); + + expect(channelCallback).toHaveBeenCalledWith(notification); + }); + }); + + it('should route account-activity notifications to subscriptions by channel when subscriptionId is stale', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const subscriptionCallback = jest.fn(); + const subscribedChannel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + const notificationChannel = + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890'; + + await createSubscription(service, mockWs, { + channels: [subscribedChannel], + callback: subscriptionCallback, + requestId: 'test-wildcard-subscribe', + subscriptionId: 'sub-123', + channelType: 'account-activity.v1', + }); + + subscriptionCallback.mockClear(); + + const notification = { + event: 'notification', + channel: notificationChannel, + subscriptionId: 'stale-server-subscription-id', + data: { address: '0x1234567890123456789012345678901234567890' }, + timestamp: 1760344704595, + }; + + mockWs.simulateMessage(notification); + + expect(subscriptionCallback).toHaveBeenCalledWith(notification); + }); + }); + it('should handle sendRequest errors when sendMessage fails', async () => { await withService(async ({ service }) => { await service.connect(); diff --git a/packages/core-backend/src/ws/BackendWebSocketService.ts b/packages/core-backend/src/ws/BackendWebSocketService.ts index c71075c372..d3c426e544 100644 --- a/packages/core-backend/src/ws/BackendWebSocketService.ts +++ b/packages/core-backend/src/ws/BackendWebSocketService.ts @@ -16,6 +16,115 @@ const SERVICE_NAME = 'BackendWebSocketService' as const; const log = createModuleLogger(projectLogger, SERVICE_NAME); +function isAccountActivityChannel(channel: string): boolean { + return channel.includes('account-activity'); +} + +const ACCOUNT_ACTIVITY_CHANNEL_REGEX = + /^account-activity\.v1\.([^:]+):([^:]+):(.+)$/u; + +type ParsedAccountActivityChannel = { + namespace: string; + chainRef: string; + address: string; +}; + +/** + * Parse an account-activity channel name into namespace, chain reference, and address. + * + * @param channel - Channel name (e.g. account-activity.v1.eip155:42161:0xabc...). + * @returns Parsed components, or null when the channel is not account-activity format. + */ +function parseAccountActivityChannel( + channel: string, +): ParsedAccountActivityChannel | null { + const match = ACCOUNT_ACTIVITY_CHANNEL_REGEX.exec(channel); + if (!match) { + return null; + } + + const [, namespace, chainRef, address] = match; + return { + namespace, + chainRef, + address: address.startsWith('0x') ? address.toLowerCase() : address, + }; +} + +/** + * Whether a notification channel matches a subscribed channel. + * Subscriptions use chain ref `0` (all chains); notifications often use a specific chain id. + * + * @param subscribedChannel - Channel registered at subscribe / addChannelCallback time. + * @param notificationChannel - Channel from the server notification. + * @returns True when the notification should route to the subscribed channel. + */ +function accountActivityChannelsMatch( + subscribedChannel: string, + notificationChannel: string, +): boolean { + if (subscribedChannel === notificationChannel) { + return true; + } + + const subscribed = parseAccountActivityChannel(subscribedChannel); + const notification = parseAccountActivityChannel(notificationChannel); + if (!subscribed || !notification) { + return false; + } + + return ( + subscribed.namespace === notification.namespace && + subscribed.address === notification.address && + (subscribed.chainRef === '0' || + subscribed.chainRef === notification.chainRef) + ); +} + +/** + * Promote nested channel/subscription fields to the top level when the server + * wraps notifications inside `data`. + * + * @param message - Parsed WebSocket message. + * @returns Normalized message for routing. + */ +function normalizeIncomingMessage(message: WebSocketMessage): WebSocketMessage { + const topLevel = message as Partial & + Record; + + if (typeof topLevel.channel === 'string') { + return message; + } + + const nestedData = topLevel.data; + if (!nestedData || typeof nestedData !== 'object') { + return message; + } + + const nested = nestedData as Record; + if (typeof nested.channel !== 'string') { + return message; + } + + const payload = + nested.data ?? nested.payload ?? nested.message ?? nested.activity; + + return { + ...topLevel, + channel: nested.channel, + subscriptionId: + topLevel.subscriptionId ?? (nested.subscriptionId as string | undefined), + timestamp: + topLevel.timestamp ?? + (typeof nested.timestamp === 'number' ? nested.timestamp : undefined) ?? + Date.now(), + data: + payload && typeof payload === 'object' + ? (payload as Record) + : (nestedData as Record), + } as WebSocketMessage; +} + // WebSocket close codes and reasons for internal operations const MANUAL_DISCONNECT_CODE = 4999 as const; const MANUAL_DISCONNECT_REASON = 'Internal: Manual disconnect' as const; @@ -1093,7 +1202,11 @@ export class BackendWebSocketService { // Set up message handler immediately - no need to wait for connection ws.onmessage = (event: MessageEvent): void => { try { - const message = this.#parseMessage(event.data); + const rawData = + typeof event.data === 'string' ? event.data : String(event.data); + const message = normalizeIncomingMessage( + this.#parseMessage(rawData), + ); this.#handleMessage(message); } catch { // Silently ignore invalid JSON messages @@ -1116,8 +1229,14 @@ export class BackendWebSocketService { #handleMessage(message: WebSocketMessage): void { // Handle server responses (correlated with requests) first if (this.#isServerResponse(message)) { - this.#handleServerResponse(message); - return; + const maybeNotification = message as Partial; + if ( + typeof maybeNotification.channel !== 'string' || + !isAccountActivityChannel(maybeNotification.channel) + ) { + this.#handleServerResponse(message); + return; + } } // Handle subscription notifications with valid subscriptionId @@ -1208,11 +1327,57 @@ export class BackendWebSocketService { * @param message - The message with channel property to handle */ #handleChannelMessage(message: ServerNotificationMessage): void { - if (this.#channelCallbacks.size === 0) { - return; + const callback = this.#resolveChannelCallback(message.channel); + callback?.(message); + } + + /** + * Resolve a channel callback by exact name or account-activity wildcard (chain ref 0). + * + * @param channel - Notification channel from the server. + * @returns Matching callback, if registered. + */ + #resolveChannelCallback( + channel: string, + ): ((notification: ServerNotificationMessage) => void) | undefined { + const exactMatch = this.#channelCallbacks.get(channel); + if (exactMatch) { + return exactMatch.callback; + } + + if (!isAccountActivityChannel(channel)) { + return undefined; + } + + for (const [registeredChannel, channelCallback] of this.#channelCallbacks) { + if (accountActivityChannelsMatch(registeredChannel, channel)) { + return channelCallback.callback; + } + } + + return undefined; + } + + /** + * Find a subscription whose channels match the notification (including chain wildcard). + * + * @param channel - Notification channel from the server. + * @returns Matching subscription entry, if any. + */ + #findSubscriptionForAccountActivityChannel( + channel: string, + ): WebSocketSubscription | undefined { + for (const subscription of this.#subscriptions.values()) { + if ( + subscription.channels.some((subscribedChannel) => + accountActivityChannelsMatch(subscribedChannel, channel), + ) + ) { + return subscription; + } } - this.#channelCallbacks.get(message.channel)?.callback(message); + return undefined; } /** @@ -1226,11 +1391,21 @@ export class BackendWebSocketService { // Only handle if subscriptionId is defined and not null (allows "0" as valid ID) if (subscriptionId !== null && subscriptionId !== undefined) { - const subscription = this.#subscriptions.get(subscriptionId); + let subscription = this.#subscriptions.get(subscriptionId); + if (!subscription && channel) { + subscription = this.#findSubscriptionForAccountActivityChannel(channel); + } + if (!subscription) { return false; } + const activeSubscription = subscription; + + if (!activeSubscription.callback) { + return false; + } + // Calculate notification latency: time from server sent to client received const receivedAt = Date.now(); const latency = receivedAt - timestamp; @@ -1249,11 +1424,11 @@ export class BackendWebSocketService { }, tags: { service: SERVICE_NAME, - notification_type: subscription.channelType, + notification_type: activeSubscription.channelType, }, }, () => { - subscription.callback?.(message); + activeSubscription.callback?.(message); }, ); return true; From 9c1e6af1986be241c2c60f83113112d74b67fa15 Mon Sep 17 00:00:00 2001 From: salimtb Date: Thu, 25 Jun 2026 23:43:39 +0200 Subject: [PATCH 03/24] fix: fix changelog --- packages/assets-controller/CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 01aab86c0f..0da7f15a1d 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -16,7 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix stale token balances after transactions when switching accounts or when websocket subscriptions reconnect; `AssetsController` now fetches before re-subscribing on account switch and serializes overlapping refresh work ([#9265](https://github.com/MetaMask/core/pull/9265)) - `AccountsApiDataSource` bypasses the TanStack Query balance cache when `forceUpdate` is true so forced refreshes return up-to-date balances instead of 60-second cached values ([#9265](https://github.com/MetaMask/core/pull/9265)) - `BackendWebsocketDataSource` re-subscribes when subscribed accounts change (case-insensitive EVM address matching), serializes subscribe/unsubscribe to prevent races on account switch, and registers channel callbacks as a fallback when server `subscriptionId` values do not match ([#9265](https://github.com/MetaMask/core/pull/9265)) -- Add `update` balance update mode so force-refresh pipelines patch balances without removing tokens omitted from partial API snapshots; `AccountsApiDataSource` and `SnapDataSource` use this mode for fetch responses ([#9265](https://github.com/MetaMask/core/pull/9265)) +- Remove the 120-second websocket balance freshness guard that blocked force-refresh and polling updates from correcting stale websocket balances ([#9273](https://github.com/MetaMask/core/pull/9273)) +- Add `update` balance update mode so fetch and force-refresh pipelines patch balances without removing tokens omitted from partial API snapshots; `getAssets({ forceUpdate: true })`, `AccountsApiDataSource`, and `SnapDataSource` use this mode ([#9273](https://github.com/MetaMask/core/pull/9273)) +- `BackendWebsocketDataSource` registers subscription handlers before the subscribe handshake so in-flight account-activity notifications are not dropped, cleans up subscription state on subscribe failure, and resolves balance updates from stored subscription state when notifications arrive with stale subscription IDs ([#9273](https://github.com/MetaMask/core/pull/9273)) ## [9.1.0] From 59ff1b416f424fe8a2ea6e2b9185d78bc40476a1 Mon Sep 17 00:00:00 2001 From: salimtb Date: Thu, 25 Jun 2026 23:52:52 +0200 Subject: [PATCH 04/24] chore: fix oxfmt formatting in BackendWebSocketService --- packages/core-backend/src/ws/BackendWebSocketService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core-backend/src/ws/BackendWebSocketService.ts b/packages/core-backend/src/ws/BackendWebSocketService.ts index d3c426e544..a8a42dd61c 100644 --- a/packages/core-backend/src/ws/BackendWebSocketService.ts +++ b/packages/core-backend/src/ws/BackendWebSocketService.ts @@ -1203,7 +1203,9 @@ export class BackendWebSocketService { ws.onmessage = (event: MessageEvent): void => { try { const rawData = - typeof event.data === 'string' ? event.data : String(event.data); + typeof event.data === 'string' + ? event.data + : String(event.data); const message = normalizeIncomingMessage( this.#parseMessage(rawData), ); From 9bfadcffb764b15b3124e3db001b320101ce48a8 Mon Sep 17 00:00:00 2001 From: salimtb Date: Thu, 25 Jun 2026 23:56:44 +0200 Subject: [PATCH 05/24] chore: link core-backend changelog entry to PR #9273 --- packages/core-backend/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index ffbac444a8..e11cd5f2d5 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `BackendWebSocketService` routes account-activity notifications when subscribed channels use chain wildcard `0` but the server sends a specific chain id, falls back to channel-based subscription lookup when `subscriptionId` is stale, and normalizes nested notification payloads ([#9265](https://github.com/MetaMask/core/pull/9265)) +- `BackendWebSocketService` routes account-activity notifications when subscribed channels use chain wildcard `0` but the server sends a specific chain id, falls back to channel-based subscription lookup when `subscriptionId` is stale, and normalizes nested notification payloads ([#9273](https://github.com/MetaMask/core/pull/9273)) ## [6.3.3] From 5e0bda517be3f8aae70fa71397ee14d23f5322dc Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 00:10:23 +0200 Subject: [PATCH 06/24] fix: increase changelog --- .../src/api/accounts/client.test.ts | 74 +++ .../src/ws/BackendWebSocketService.test.ts | 477 +++++++++++++++++- .../src/ws/BackendWebSocketService.ts | 14 +- 3 files changed, 560 insertions(+), 5 deletions(-) diff --git a/packages/core-backend/src/api/accounts/client.test.ts b/packages/core-backend/src/api/accounts/client.test.ts index 9cd850c918..14cf3b07cc 100644 --- a/packages/core-backend/src/api/accounts/client.test.ts +++ b/packages/core-backend/src/api/accounts/client.test.ts @@ -472,6 +472,80 @@ describe('AccountsApiClient', () => { expect(result.staleTime).toBe(60_000); expect(result.gcTime).toBe(120_000); }); + + it('queryFn fetches paginated transactions and getNextPageParam uses endCursor', async () => { + const mockResponse = { + unprocessedNetworks: [], + pageInfo: { + count: 1, + hasNextPage: true, + endCursor: 'cursor-page-2', + }, + data: [ + { + hash: '0xabc123', + timestamp: '2024-01-01T00:00:00Z', + chainId: 1, + blockNumber: 12345, + blockHash: '0xdef', + gas: 21000, + gasUsed: 21000, + gasPrice: '20000000000', + effectiveGasPrice: '20000000000', + nonce: 0, + cumulativeGasUsed: 21000, + value: '1000000000000000000', + to: '0x456', + from: '0x123', + }, + ], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const queryOptions = + client.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions( + { + accountAddresses: ['eip155:1:0x123'], + networks: ['eip155:1'], + sortDirection: 'DESC', + includeLogs: true, + includeTxMetadata: true, + maxLogsPerTx: 10, + lang: 'en', + }, + { initialPageParam: 'cursor-page-1' }, + ); + + expect(typeof queryOptions.queryFn).toBe('function'); + expect(queryOptions.getNextPageParam).toBeDefined(); + + const page = await queryOptions.queryFn({ + pageParam: 'cursor-page-1', + signal: undefined, + }); + expect(page).toStrictEqual(mockResponse); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v4/multiaccount/transactions'), + expect.objectContaining({ + method: 'GET', + signal: undefined, + }), + ); + expect(mockFetch.mock.calls[0]?.[0]).toContain('cursor=cursor-page-1'); + expect(mockFetch.mock.calls[0]?.[0]).toContain( + 'accountAddresses=eip155%3A1%3A0x123', + ); + + expect(queryOptions.getNextPageParam?.(mockResponse)).toBe( + 'cursor-page-2', + ); + expect( + queryOptions.getNextPageParam?.({ + ...mockResponse, + pageInfo: { count: 1, hasNextPage: false }, + }), + ).toBeUndefined(); + }); }); }); diff --git a/packages/core-backend/src/ws/BackendWebSocketService.test.ts b/packages/core-backend/src/ws/BackendWebSocketService.test.ts index 1badf9557f..d1fcd2c515 100644 --- a/packages/core-backend/src/ws/BackendWebSocketService.test.ts +++ b/packages/core-backend/src/ws/BackendWebSocketService.test.ts @@ -171,6 +171,16 @@ class MockWebSocket extends EventTarget { this.dispatchEvent(event); } + public simulateRawMessage(data: unknown): void { + const event = new MessageEvent('message', { data: data as string }); + + if (this.onmessage) { + this.onmessage(event); + } + + this.dispatchEvent(event); + } + public simulateError(): void { const event = new Event('error'); this.onerror?.(event); @@ -884,8 +894,7 @@ describe('BackendWebSocketService', () => { }); // Temporarily disabled due to intermittent failures - // eslint-disable-next-line jest/no-disabled-tests - it.skip('should handle connection timeout', async () => { + it('should handle connection timeout', async () => { await withService( { options: { timeout: 100 }, @@ -968,8 +977,7 @@ describe('BackendWebSocketService', () => { }); // Temporarily disabled due to intermittent failures - // eslint-disable-next-line jest/no-disabled-tests - it.skip('should resolve connection promise when manual disconnect occurs during CONNECTING phase', async () => { + it('should resolve connection promise when manual disconnect occurs during CONNECTING phase', async () => { await withService( { mockWebSocketOptions: { autoConnect: false } }, async ({ service, getMockWebSocket, completeAsyncOperations }) => { @@ -1868,6 +1876,467 @@ describe('BackendWebSocketService', () => { }); }); + it('should normalize nested account-activity notifications before routing', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + const subscribedChannel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + const notificationChannel = + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890'; + + service.addChannelCallback({ + channelName: subscribedChannel, + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'data', + timestamp: 1760344704595, + data: { + channel: notificationChannel, + subscriptionId: 'stale-subscription-id', + message: { address: '0x1234567890123456789012345678901234567890' }, + }, + }); + + expect(channelCallback).toHaveBeenCalledWith( + expect.objectContaining({ + channel: notificationChannel, + data: { address: '0x1234567890123456789012345678901234567890' }, + }), + ); + }); + }); + + it('should normalize nested account-activity notifications using activity payload', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + const subscribedChannel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + const notificationChannel = + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890'; + + service.addChannelCallback({ + channelName: subscribedChannel, + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'data', + timestamp: 1760344704595, + data: { + channel: notificationChannel, + subscriptionId: 'stale-subscription-id', + activity: { address: '0x1234567890123456789012345678901234567890' }, + }, + }); + + expect(channelCallback).toHaveBeenCalledWith( + expect.objectContaining({ + channel: notificationChannel, + data: { address: '0x1234567890123456789012345678901234567890' }, + }), + ); + }); + }); + + it('should normalize nested account-activity notifications using nested timestamp and scalar payload', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + const subscribedChannel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + const notificationChannel = + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890'; + + service.addChannelCallback({ + channelName: subscribedChannel, + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'data', + data: { + channel: notificationChannel, + timestamp: 1760344704595, + payload: 'scalar-payload', + }, + }); + + expect(channelCallback).toHaveBeenCalledWith( + expect.objectContaining({ + channel: notificationChannel, + timestamp: 1760344704595, + data: { + channel: notificationChannel, + timestamp: 1760344704595, + payload: 'scalar-payload', + }, + }), + ); + }); + }); + + it('should preserve non-0x account addresses when parsing account-activity channels', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + const address = 'AbCdEf123456789'; + const subscribedChannel = `account-activity.v1.eip155:0:${address}`; + const notificationChannel = `account-activity.v1.eip155:42161:${address}`; + + service.addChannelCallback({ + channelName: subscribedChannel, + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'notification', + channel: notificationChannel, + subscriptionId: 'stale-subscription-id', + data: { address }, + timestamp: 1760344704595, + }); + + expect(channelCallback).toHaveBeenCalledTimes(1); + }); + }); + + it('should stringify non-string WebSocket message payloads before parsing', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + + service.addChannelCallback({ + channelName: 'test-channel', + callback: channelCallback, + }); + + mockWs.simulateRawMessage({ + toString() { + return JSON.stringify({ + channel: 'test-channel', + event: 'notification', + data: { value: 1 }, + timestamp: 1760344704595, + }); + }, + }); + + expect(channelCallback).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'test-channel', + data: { value: 1 }, + }), + ); + }); + }); + + it('should default nested notification timestamps when nested timestamp is not numeric', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + const notificationChannel = + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890'; + + service.addChannelCallback({ + channelName: + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890', + callback: channelCallback, + }); + + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1760344704999); + + mockWs.simulateMessage({ + event: 'data', + data: { + channel: notificationChannel, + timestamp: 'not-a-number', + payload: { address: '0x1234567890123456789012345678901234567890' }, + }, + }); + + expect(channelCallback).toHaveBeenCalledWith( + expect.objectContaining({ + channel: notificationChannel, + timestamp: 1760344704999, + }), + ); + + nowSpy.mockRestore(); + }); + }); + + it('should leave messages unchanged when nested data has no channel', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + + service.addChannelCallback({ + channelName: 'test-channel', + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'data', + data: { foo: 'bar' }, + }); + + expect(channelCallback).not.toHaveBeenCalled(); + }); + }); + + it('should leave messages unchanged when nested data is not an object', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + + service.addChannelCallback({ + channelName: 'test-channel', + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'data', + data: 'not-an-object', + }); + + expect(channelCallback).not.toHaveBeenCalled(); + }); + }); + + it('should route subscription notifications via channel fallback when subscriptionId is stale but channel matches exactly', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const subscriptionCallback = jest.fn(); + const channel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + + await createSubscription(service, mockWs, { + channels: [channel], + callback: subscriptionCallback, + requestId: 'test-exact-channel-subscribe', + subscriptionId: 'sub-exact', + channelType: 'account-activity.v1', + }); + + subscriptionCallback.mockClear(); + + const notification = { + event: 'notification', + channel, + subscriptionId: 'stale-subscription-id', + data: { address: '0x1234567890123456789012345678901234567890' }, + timestamp: 1760344704595, + }; + + mockWs.simulateMessage(notification); + + expect(subscriptionCallback).toHaveBeenCalledWith(notification); + }); + }); + + it('should fall through to channel callbacks when subscription lookup matches a channel without a callback', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + const channel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + const subscriptionId = 'sub-no-callback'; + const originalMapSet = Map.prototype.set; + + jest + .spyOn(Map.prototype, 'set') + .mockImplementation(function mapSet(key, value) { + if ( + key === subscriptionId && + value && + typeof value === 'object' && + 'callback' in value + ) { + return originalMapSet.call(this, key, { + ...(value as Record), + callback: undefined, + }); + } + + return originalMapSet.call(this, key, value); + }); + + await createSubscription(service, mockWs, { + channels: [channel], + callback: jest.fn(), + requestId: 'test-no-callback-subscribe', + subscriptionId, + channelType: 'account-activity.v1', + }); + + service.addChannelCallback({ + channelName: channel, + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'notification', + channel, + subscriptionId, + data: { address: '0x1234567890123456789012345678901234567890' }, + timestamp: 1760344704595, + }); + + expect(channelCallback).toHaveBeenCalledTimes(1); + }); + }); + + it('should not wildcard-match account-activity channels with different chain refs', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + + service.addChannelCallback({ + channelName: + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890', + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'notification', + channel: + 'account-activity.v1.eip155:137:0x1234567890123456789012345678901234567890', + subscriptionId: 'stale-subscription-id', + data: { address: '0x1234567890123456789012345678901234567890' }, + timestamp: 1760344704595, + }); + + expect(channelCallback).not.toHaveBeenCalled(); + }); + }); + + it('should not match subscriptions when channel format cannot be parsed', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const subscriptionCallback = jest.fn(); + + await createSubscription(service, mockWs, { + channels: ['legacy-channel.v1.test-topic'], + callback: subscriptionCallback, + requestId: 'test-unparseable-channel-subscribe', + subscriptionId: 'sub-unparseable', + channelType: 'legacy-channel.v1', + }); + + subscriptionCallback.mockClear(); + + mockWs.simulateMessage({ + event: 'notification', + channel: + 'account-activity.v1.eip155:42161:0x1234567890123456789012345678901234567890', + subscriptionId: 'stale-subscription-id', + data: { address: '0x1234567890123456789012345678901234567890' }, + timestamp: 1760344704595, + }); + + expect(subscriptionCallback).not.toHaveBeenCalled(); + }); + }); + + it('should treat server responses with requestId as non-subscription messages for non-account-activity channels', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + + service.addChannelCallback({ + channelName: 'market-data.v1.test', + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'notification', + channel: 'market-data.v1.test', + subscriptionId: 'sub-market-data', + data: { requestId: 'orphaned-request-id', price: '100' }, + timestamp: 1760344704595, + }); + + expect(channelCallback).not.toHaveBeenCalled(); + }); + }); + + it('should not wildcard-match account-activity channels with different addresses', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channelCallback = jest.fn(); + + service.addChannelCallback({ + channelName: + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890', + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'notification', + channel: + 'account-activity.v1.eip155:42161:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + subscriptionId: 'stale-subscription-id', + data: { address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' }, + timestamp: 1760344704595, + }); + + expect(channelCallback).not.toHaveBeenCalled(); + }); + }); + + it('should route account-activity notifications that include requestId in data', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const subscriptionCallback = jest.fn(); + const channel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + + await createSubscription(service, mockWs, { + channels: [channel], + callback: subscriptionCallback, + requestId: 'test-request-id-in-data-subscribe', + subscriptionId: 'sub-with-request-id', + channelType: 'account-activity.v1', + }); + + subscriptionCallback.mockClear(); + + const notification = { + event: 'notification', + channel, + subscriptionId: 'sub-with-request-id', + data: { + requestId: 'orphaned-request-id', + address: '0x1234567890123456789012345678901234567890', + }, + timestamp: 1760344704595, + }; + + mockWs.simulateMessage(notification); + + expect(subscriptionCallback).toHaveBeenCalledWith(notification); + }); + }); + it('should handle sendRequest errors when sendMessage fails', async () => { await withService(async ({ service }) => { await service.connect(); diff --git a/packages/core-backend/src/ws/BackendWebSocketService.ts b/packages/core-backend/src/ws/BackendWebSocketService.ts index a8a42dd61c..5980ffd0e9 100644 --- a/packages/core-backend/src/ws/BackendWebSocketService.ts +++ b/packages/core-backend/src/ws/BackendWebSocketService.ts @@ -1282,7 +1282,19 @@ export class BackendWebSocketService { * @returns True if the message is a subscription notification with subscriptionId */ #isSubscriptionNotification(message: WebSocketMessage): boolean { - return 'subscriptionId' in message && !this.#isServerResponse(message); + if (!('subscriptionId' in message)) { + return false; + } + + if (this.#isServerResponse(message)) { + const maybeNotification = message as Partial; + return ( + typeof maybeNotification.channel === 'string' && + isAccountActivityChannel(maybeNotification.channel) + ); + } + + return true; } /** From 12040e2d07f790644878bc0859db64531c16a0fa Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 00:16:01 +0200 Subject: [PATCH 07/24] fix: increase test coverage --- .../src/data-sources/BackendWebsocketDataSource.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts index 56f0da024f..b2d5af5958 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts @@ -1204,11 +1204,7 @@ describe('BackendWebsocketDataSource', () => { notificationCallback(notification); await new Promise(process.nextTick); - // No valid updates → response has only updateMode, no assetsBalance - expect(assetsUpdateHandler).toHaveBeenCalledWith( - { updateMode: 'merge' }, - expect.objectContaining({ dataTypes: ['balance'] }), - ); + expect(assetsUpdateHandler).not.toHaveBeenCalled(); controller.destroy(); }); From 7b5df18a6a2292ef29d8951deb64f85b9bca2a43 Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 00:27:03 +0200 Subject: [PATCH 08/24] fix: increase test coverage --- .../src/ws/BackendWebSocketService.test.ts | 54 +------------------ .../src/ws/BackendWebSocketService.ts | 6 +-- 2 files changed, 4 insertions(+), 56 deletions(-) diff --git a/packages/core-backend/src/ws/BackendWebSocketService.test.ts b/packages/core-backend/src/ws/BackendWebSocketService.test.ts index d1fcd2c515..d78107244f 100644 --- a/packages/core-backend/src/ws/BackendWebSocketService.test.ts +++ b/packages/core-backend/src/ws/BackendWebSocketService.test.ts @@ -232,6 +232,7 @@ const getMessenger = (): { rootMessenger.delegate({ actions: ['AuthenticationController:getBearerToken'], events: [ + // eslint-disable-next-line no-restricted-syntax -- AuthenticationController messenger types still expose stateChange 'AuthenticationController:stateChange', 'KeyringController:lock', 'KeyringController:unlock', @@ -2147,59 +2148,6 @@ describe('BackendWebSocketService', () => { }); }); - it('should fall through to channel callbacks when subscription lookup matches a channel without a callback', async () => { - await withService(async ({ service, getMockWebSocket }) => { - await service.connect(); - const mockWs = getMockWebSocket(); - const channelCallback = jest.fn(); - const channel = - 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; - const subscriptionId = 'sub-no-callback'; - const originalMapSet = Map.prototype.set; - - jest - .spyOn(Map.prototype, 'set') - .mockImplementation(function mapSet(key, value) { - if ( - key === subscriptionId && - value && - typeof value === 'object' && - 'callback' in value - ) { - return originalMapSet.call(this, key, { - ...(value as Record), - callback: undefined, - }); - } - - return originalMapSet.call(this, key, value); - }); - - await createSubscription(service, mockWs, { - channels: [channel], - callback: jest.fn(), - requestId: 'test-no-callback-subscribe', - subscriptionId, - channelType: 'account-activity.v1', - }); - - service.addChannelCallback({ - channelName: channel, - callback: channelCallback, - }); - - mockWs.simulateMessage({ - event: 'notification', - channel, - subscriptionId, - data: { address: '0x1234567890123456789012345678901234567890' }, - timestamp: 1760344704595, - }); - - expect(channelCallback).toHaveBeenCalledTimes(1); - }); - }); - it('should not wildcard-match account-activity channels with different chain refs', async () => { await withService(async ({ service, getMockWebSocket }) => { await service.connect(); diff --git a/packages/core-backend/src/ws/BackendWebSocketService.ts b/packages/core-backend/src/ws/BackendWebSocketService.ts index 5980ffd0e9..71aeaf0705 100644 --- a/packages/core-backend/src/ws/BackendWebSocketService.ts +++ b/packages/core-backend/src/ws/BackendWebSocketService.ts @@ -101,7 +101,7 @@ function normalizeIncomingMessage(message: WebSocketMessage): WebSocketMessage { return message; } - const nested = nestedData as Record; + const nested = nestedData; if (typeof nested.channel !== 'string') { return message; } @@ -120,8 +120,8 @@ function normalizeIncomingMessage(message: WebSocketMessage): WebSocketMessage { Date.now(), data: payload && typeof payload === 'object' - ? (payload as Record) - : (nestedData as Record), + ? payload + : nestedData, } as WebSocketMessage; } From a35d740d203ef3e99911d9aa5c0435991c98d503 Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 00:35:02 +0200 Subject: [PATCH 09/24] fix: fix linter --- eslint-suppressions.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index bb20541c19..c62bf8120a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -851,11 +851,6 @@ "count": 1 } }, - "packages/core-backend/src/ws/BackendWebSocketService.test.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, "packages/core-backend/src/ws/BackendWebSocketService.ts": { "no-restricted-syntax": { "count": 5 @@ -2392,4 +2387,4 @@ "count": 10 } } -} +} \ No newline at end of file From aabb3f2e3d8acfd72cdcf074a2dbf174c77b8f6f Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 00:39:44 +0200 Subject: [PATCH 10/24] fix: fix linter --- eslint-suppressions.json | 2 +- packages/core-backend/src/ws/BackendWebSocketService.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index c62bf8120a..03d0b4c819 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2387,4 +2387,4 @@ "count": 10 } } -} \ No newline at end of file +} diff --git a/packages/core-backend/src/ws/BackendWebSocketService.ts b/packages/core-backend/src/ws/BackendWebSocketService.ts index 71aeaf0705..89e090c944 100644 --- a/packages/core-backend/src/ws/BackendWebSocketService.ts +++ b/packages/core-backend/src/ws/BackendWebSocketService.ts @@ -118,10 +118,7 @@ function normalizeIncomingMessage(message: WebSocketMessage): WebSocketMessage { topLevel.timestamp ?? (typeof nested.timestamp === 'number' ? nested.timestamp : undefined) ?? Date.now(), - data: - payload && typeof payload === 'object' - ? payload - : nestedData, + data: payload && typeof payload === 'object' ? payload : nestedData, } as WebSocketMessage; } From ba933fc1b766121e56bf16a747bb72d8799e9593 Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 01:30:02 +0200 Subject: [PATCH 11/24] fix: fix rpc data source --- .../src/AssetsController.test.ts | 31 +++++++ .../assets-controller/src/AssetsController.ts | 93 ++++++++++++------- .../src/data-sources/RpcDataSource.ts | 6 +- packages/assets-controller/src/types.ts | 4 +- 4 files changed, 99 insertions(+), 35 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index a4165fa1c4..e00d1f4e88 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1806,6 +1806,37 @@ describe('AssetsController', () => { }); }); + it('seeds missing metadata in update mode for RPC-only chains', async () => { + const avaxNative = 'eip155:43114/slip44:9005' as Caip19AssetId; + + await withController({ state: {} }, async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'update', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [avaxNative]: { amount: '1.5' }, + }, + }, + assetsInfo: { + [avaxNative]: { + type: 'native', + symbol: 'AVAX', + name: 'Avalanche', + decimals: 18, + }, + }, + }, + 'RpcDataSource', + ); + + expect(controller.state.assetsInfo[avaxNative]?.symbol).toBe('AVAX'); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[avaxNative], + ).toStrictEqual({ amount: '1.5' }); + }); + }); + it('updates balance amounts present in update mode response', async () => { const initialState: Partial = { assetsBalance: { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 5ba75514ce..516aadff44 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -2163,35 +2163,52 @@ export class AssetsController extends BaseController< >; const prices = state.assetsPrice as Record; - if (normalizedResponse.assetsInfo && mode !== 'update') { - for (const [key, value] of Object.entries( - normalizedResponse.assetsInfo, - )) { - if ( - !isEqual(previousState.assetsInfo[key as Caip19AssetId], value) - ) { + if (normalizedResponse.assetsInfo) { + if (mode === 'update') { + // Balance-only refresh: patch amounts without overwriting metadata, + // but seed entries that are missing so RPC-only chains (e.g. + // Avalanche) can render native balances on first fetch. + for (const [key, value] of Object.entries( + normalizedResponse.assetsInfo, + )) { + if (metadata[key]) { + continue; + } + metadata[key] = value; changedMetadata.push(key); } + } else { + for (const [key, value] of Object.entries( + normalizedResponse.assetsInfo, + )) { + if ( + !isEqual(previousState.assetsInfo[key as Caip19AssetId], value) + ) { + changedMetadata.push(key); + } - const existing = metadata[key] as FungibleAssetMetadata | undefined; - const incoming = value as FungibleAssetMetadata; - - // When the API returns no symbol or name for this token, it has no - // real knowledge of it (e.g. a user-deployed token not indexed by - // the API). Preserve richer metadata already in state (e.g. from - // pendingMetadata set by addCustomAsset) so that the correct - // decimals/symbol/name/image are not overwritten with empty values. - if (existing && !incoming.symbol && !incoming.name) { - metadata[key] = { - ...existing, - ...incoming, - symbol: existing.symbol, - name: existing.name, - decimals: existing.decimals ?? incoming.decimals, - image: existing.image ?? incoming.image, - }; - } else { - metadata[key] = value; + const existing = metadata[key] as + | FungibleAssetMetadata + | undefined; + const incoming = value as FungibleAssetMetadata; + + // When the API returns no symbol or name for this token, it has no + // real knowledge of it (e.g. a user-deployed token not indexed by + // the API). Preserve richer metadata already in state (e.g. from + // pendingMetadata set by addCustomAsset) so that the correct + // decimals/symbol/name/image are not overwritten with empty values. + if (existing && !incoming.symbol && !incoming.name) { + metadata[key] = { + ...existing, + ...incoming, + symbol: existing.symbol, + name: existing.name, + decimals: existing.decimals ?? incoming.decimals, + image: existing.image ?? incoming.image, + }; + } else { + metadata[key] = value; + } } } } @@ -2321,12 +2338,24 @@ export class AssetsController extends BaseController< } } - // Update prices in state (skipped for balance-only update mode) - if (normalizedResponse.assetsPrice && mode !== 'update') { - for (const [key, value] of Object.entries( - normalizedResponse.assetsPrice, - )) { - prices[key] = value; + // Update prices in state. Update mode only seeds missing entries so + // balance-only refreshes do not clobber existing price data. + if (normalizedResponse.assetsPrice) { + if (mode === 'update') { + for (const [key, value] of Object.entries( + normalizedResponse.assetsPrice, + )) { + if (prices[key]) { + continue; + } + prices[key] = value; + } + } else { + for (const [key, value] of Object.entries( + normalizedResponse.assetsPrice, + )) { + prices[key] = value; + } } } }); diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index 8bc03217f4..d09f4f4d37 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -523,7 +523,7 @@ export class RpcDataSource extends AbstractDataSource< [result.accountId]: newBalances, }, assetsInfo, - updateMode: 'merge', + updateMode: 'update', }; const request: DataRequest = { @@ -603,7 +603,7 @@ export class RpcDataSource extends AbstractDataSource< assetsBalance: { [result.accountId]: newBalances, }, - updateMode: 'merge', + updateMode: 'update', }; const chainIdDecimal = parseInt(result.chainId, 16); @@ -1139,6 +1139,8 @@ export class RpcDataSource extends AbstractDataSource< response.assetsInfo = assetsInfo; } + response.updateMode = 'update'; + return response; } diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index cfc4dadb5e..f7ce1b91b7 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -382,7 +382,9 @@ export type DataResponse = { * Metadata and prices from the response are applied. Use for event-driven updates. * - **update**: Balance-only overlay — incoming balance amounts are patched in place; * existing balances, metadata, and prices are never removed or overwritten. - * Use for force refresh when the API may return a partial chain snapshot. + * Missing metadata and prices from the response are seeded so RPC-only chains + * can render on first fetch. Use for force refresh when the API may return a + * partial chain snapshot. */ export type AssetsUpdateMode = 'full' | 'merge' | 'update'; From 20e0dde01f95980c46406fe1cde389812ce552dc Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 10:50:22 +0200 Subject: [PATCH 12/24] fix(assets-controller): apply real-time WS balances via AccountActivityService Wire AssetsController to AccountActivityService:balanceUpdated so unified assetsBalance updates when AccountActivityService owns the websocket subscription. Share account-activity payload conversion with BackendWebsocketDataSource, skip slow RPC for Accounts API chains, and add opt-in WS routing debug logs. --- packages/assets-controller/CHANGELOG.md | 1 + .../src/AssetsController.test.ts | 84 ++++++ .../assets-controller/src/AssetsController.ts | 279 ++++++++++++++++-- .../MockAssetControllerMessenger.ts | 2 + .../BackendWebsocketDataSource.ts | 134 +++++---- .../src/utils/balanceWsDebug.ts | 56 ++++ ...ocessAccountActivityBalanceUpdates.test.ts | 39 +++ .../processAccountActivityBalanceUpdates.ts | 70 +++++ .../src/ws/BackendWebSocketService.ts | 120 +++++++- 9 files changed, 696 insertions(+), 89 deletions(-) create mode 100644 packages/assets-controller/src/utils/balanceWsDebug.ts create mode 100644 packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.test.ts create mode 100644 packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 0da7f15a1d..ba49da5e56 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove the 120-second websocket balance freshness guard that blocked force-refresh and polling updates from correcting stale websocket balances ([#9273](https://github.com/MetaMask/core/pull/9273)) - Add `update` balance update mode so fetch and force-refresh pipelines patch balances without removing tokens omitted from partial API snapshots; `getAssets({ forceUpdate: true })`, `AccountsApiDataSource`, and `SnapDataSource` use this mode ([#9273](https://github.com/MetaMask/core/pull/9273)) - `BackendWebsocketDataSource` registers subscription handlers before the subscribe handshake so in-flight account-activity notifications are not dropped, cleans up subscription state on subscribe failure, and resolves balance updates from stored subscription state when notifications arrive with stale subscription IDs ([#9273](https://github.com/MetaMask/core/pull/9273)) +- `AssetsController` subscribes to `AccountActivityService:balanceUpdated` so unified `assetsBalance` state updates from real-time websocket notifications when `AccountActivityService` owns the server subscription ([#9273](https://github.com/MetaMask/core/pull/9273)) ## [9.1.0] diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index e00d1f4e88..5861e34616 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1865,6 +1865,90 @@ describe('AssetsController', () => { }); }); + it('updates state from AccountActivityService:balanceUpdated', async () => { + const arbNative = 'eip155:42161/slip44:60' as Caip19AssetId; + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [arbNative]: { amount: '1' }, + }, + }, + }; + + await withController({ state: initialState }, async ({ controller, messenger }) => { + messenger.publish('AccountActivityService:balanceUpdated', { + address: '0x1234567890123456789012345678901234567890', + chain: 'eip155:42161', + updates: [ + { + asset: { + fungible: true, + type: arbNative, + unit: 'ETH', + decimals: 18, + }, + postBalance: { amount: '0x10aa6d94e80' }, + transfers: [], + }, + ], + }); + + await flushPromises(); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[arbNative], + ).toStrictEqual({ amount: '0.00000114526056' }); + }); + }); + + it('zeros stale native in update mode when the chain is covered but native is omitted', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '1.5' }, + [MOCK_ASSET_ID]: { amount: '10' }, + }, + }, + assetsInfo: { + [MOCK_NATIVE_ASSET_ID]: { + type: 'native', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + }, + [MOCK_ASSET_ID]: { + type: 'erc20', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + }, + }; + + await withController({ state: initialState }, async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'update', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '0' }, + }, + }, + }, + 'AccountsApiDataSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toStrictEqual({ amount: '0' }); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + MOCK_NATIVE_ASSET_ID + ], + ).toStrictEqual({ amount: '0' }); + }); + }); + it('updates state with price data', async () => { await withController(async ({ controller }) => { await controller.handleAssetsUpdate( diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 516aadff44..bb0fc6ef1b 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -15,8 +15,10 @@ import { clientControllerSelectors } from '@metamask/client-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import type { ApiPlatformClient, + AccountActivityServiceBalanceUpdatedEvent, BackendWebSocketServiceActions, BackendWebSocketServiceEvents, + BalanceUpdate, SupportedCurrency, } from '@metamask/core-backend'; import type { @@ -140,6 +142,8 @@ import type { } from './utils'; import { ZERO_ADDRESS } from './utils/constants'; import { pickRpcCustomAssetsSupplement } from './utils/customAssetsRpcSupplement'; +import { balanceWsDebug } from './utils/balanceWsDebug'; +import { processAccountActivityBalanceUpdates } from './utils/processAccountActivityBalanceUpdates'; const NATIVE_ASSETS_QUERY_KEY = ['nativeAssets']; @@ -339,7 +343,9 @@ type AllowedEvents = | PermissionControllerStateChange | SnapControllerSnapInstalledEvent // BackendWebsocketDataSource - | BackendWebSocketServiceEvents; + | BackendWebSocketServiceEvents + // AccountActivityService (real-time balance updates for unified assets) + | AccountActivityServiceBalanceUpdatedEvent; export type AssetsControllerMessenger = Messenger< typeof CONTROLLER_NAME, @@ -1072,6 +1078,16 @@ export class AssetsController extends BaseController< this.#onUnapprovedTransactionAdded(transactionMeta); }, ); + + // Real-time post-tx balances from AccountActivityService (same WS payload as + // TokenBalancesController; BackendWebsocketDataSource may not receive the + // callback when AccountActivityService owns the server subscription). + this.messenger.subscribe( + 'AccountActivityService:balanceUpdated', + (event) => { + this.#onAccountActivityBalanceUpdated(event); + }, + ); } #onUnapprovedTransactionAdded(transactionMeta: TransactionMeta): void { @@ -1103,6 +1119,66 @@ export class AssetsController extends BaseController< }); } + #onAccountActivityBalanceUpdated({ + address, + chain, + updates, + }: { + address: string; + chain: string; + updates: BalanceUpdate[]; + }): void { + balanceWsDebug('aa:enter', { + address, + chain, + updateCount: updates.length, + updates, + }); + + const account = this.#getSelectedAccounts().find((a) => + a.address.startsWith('0x') + ? a.address.toLowerCase() === address.toLowerCase() + : a.address === address, + ); + + if (!account) { + balanceWsDebug('aa:drop', { reason: 'account-not-found', address }); + return; + } + + const chainId = chain as ChainId; + const response = processAccountActivityBalanceUpdates( + updates, + account.id, + (assetId) => this.#getAssetType(assetId), + ); + + if (!response.assetsBalance) { + balanceWsDebug('aa:drop', { reason: 'no-balances', address, chain }); + return; + } + + const request: DataRequest = { + accountsWithSupportedChains: [{ account, supportedChains: [chainId] }], + chainIds: [chainId], + dataTypes: ['balance', 'metadata'], + }; + + balanceWsDebug('aa:apply', { + accountId: account.id, + chainId, + balances: response.assetsBalance, + updateMode: response.updateMode, + }); + + this.handleAssetsUpdate(response, 'AccountActivityService', request).catch( + (error) => { + balanceWsDebug('aa:error', { error: String(error) }); + log('Failed to apply AccountActivityService balance update', { error }); + }, + ); + } + /** * Start or stop asset tracking based on client (UI) open state and keyring * unlock state. Only runs when both UI is open and keyring is unlocked. @@ -1453,29 +1529,38 @@ export class AssetsController extends BaseController< // commits to state. Their balances are merged together before detection. // Token + price enrichment matches the pre-split behavior: only when basic // functionality is on (RPC-only mode must not call token/price APIs). - const slowSources = this.#isBasicFunctionality() - ? [this.#snapDataSource, this.#rpcDataSource] - : [this.#rpcDataSource]; - - this.#executeMiddlewares( - [ - createParallelBalanceMiddleware(slowSources), - this.#detectionMiddleware, - ...(this.#isBasicFunctionality() - ? [ - createParallelMiddleware([ - this.#tokenDataSource, - this.#priceDataSource, - ]), - ] - : []), - ], - request, - ) - .then(({ response: slowResponse }) => - this.#updateState({ ...slowResponse, updateMode: 'update' }), + const slowPipelineChainIds = this.#getSlowPipelineChainIds( + chainIds, + response, + ); + + if (slowPipelineChainIds.length > 0) { + const slowSources = this.#isBasicFunctionality() + ? [this.#snapDataSource, this.#rpcDataSource] + : [this.#rpcDataSource]; + + const slowRequest = { ...request, chainIds: slowPipelineChainIds }; + + this.#executeMiddlewares( + [ + createParallelBalanceMiddleware(slowSources), + this.#detectionMiddleware, + ...(this.#isBasicFunctionality() + ? [ + createParallelMiddleware([ + this.#tokenDataSource, + this.#priceDataSource, + ]), + ] + : []), + ], + slowRequest, ) - .catch((error) => log('Background pipeline failed', { error })); + .then(({ response: slowResponse }) => + this.#updateState({ ...slowResponse, updateMode: 'update' }), + ) + .catch((error) => log('Background pipeline failed', { error })); + } const durationMs = performance.now() - startTime; @@ -2025,6 +2110,34 @@ export class AssetsController extends BaseController< ); } + /** + * Chains for the post-commit slow pipeline (Snap + RPC). Excludes chains the + * fast Accounts API path already handled without error so stale RPC data cannot + * overwrite fresh API zeros (e.g. after max send). + * + * @param chainIds - Chains requested by the caller. + * @param fastResponse - Response committed by the fast pipeline. + * @returns Chain IDs that still need the slow pipeline. + */ + #getSlowPipelineChainIds( + chainIds: ChainId[], + fastResponse: DataResponse, + ): ChainId[] { + const accountsApiChains = new Set( + this.#accountsApiDataSource.getActiveChainsSync(), + ); + + return chainIds.filter((chainId) => { + if (fastResponse.errors?.[chainId]) { + return true; + } + if (!accountsApiChains.has(chainId)) { + return true; + } + return false; + }); + } + /** * Ensures assetsBalance has a 0 balance for each native token (from * NetworkEnablementController.nativeAssetIdentifiers) for each selected account. @@ -2136,6 +2249,16 @@ export class AssetsController extends BaseController< const normalizedResponse = normalizeResponse(response); const mode: AssetsUpdateMode = normalizedResponse.updateMode ?? 'merge'; + balanceWsDebug('updateState:enter', { + updateMode: mode, + balanceAccounts: normalizedResponse.assetsBalance + ? Object.keys(normalizedResponse.assetsBalance) + : [], + metadataCount: normalizedResponse.assetsInfo + ? Object.keys(normalizedResponse.assetsInfo).length + : 0, + }); + const releaseLock = await this.#controllerMutex.acquire(); try { @@ -2288,6 +2411,14 @@ export class AssetsController extends BaseController< return next; })(); + // Chains present in this partial response (e.g. Accounts API returned + // ERC-20 zeros but omitted native after a max send). + const coveredChainsInResponse = new Set( + Object.keys(accountBalances).map( + (assetId) => assetId.split('/')[0], + ), + ); + // Ensure native tokens have an entry (0 if missing) for chains this account supports const account = this.#getSelectedAccounts().find( (a) => a.id === accountId, @@ -2296,8 +2427,18 @@ export class AssetsController extends BaseController< ? this.#getNativeAssetIdsForAccount(account) : this.#getNativeAssetIdsForEnabledChains(); for (const nativeAssetId of nativeAssetIdsForAccount) { + const nativeChain = nativeAssetId.split('/')[0]; if (!(nativeAssetId in effective)) { effective[nativeAssetId] = { amount: '0' } as AssetBalance; + } else if ( + mode === 'update' && + coveredChainsInResponse.has(nativeChain) && + !(nativeAssetId in accountBalances) + ) { + // Update-mode overlay keeps previous native when the response + // omits it. If the API returned any balance on this chain, + // treat omitted native as zero instead of a stale pre-tx amount. + effective[nativeAssetId] = { amount: '0' } as AssetBalance; } } @@ -2380,6 +2521,13 @@ export class AssetsController extends BaseController< changedMetadata.length > 0 || changedPriceAssets.length > 0 ) { + balanceWsDebug('updateState:applied', { + updateMode: mode, + changedBalances, + changedMetadataCount: changedMetadata.length, + changedPricesCount: changedPriceAssets.length, + }); + log('State updated', { changedBalances: changedBalances.length > 0 ? changedBalances : undefined, @@ -2397,6 +2545,65 @@ export class AssetsController extends BaseController< })) : undefined, }); + } else if (normalizedResponse.assetsBalance) { + const balanceComparisons: { + accountId: string; + assetId: string; + incomingAmount: string; + previousAmount: string | undefined; + normalizedIncoming?: string; + normalizedPrevious?: string; + amountsEqual: boolean; + }[] = []; + + for (const [accountId, accountBalances] of Object.entries( + normalizedResponse.assetsBalance, + )) { + const previousBalances = + previousState.assetsBalance[accountId] ?? {}; + + for (const [assetId, balance] of Object.entries(accountBalances)) { + const rawAmount = (balance as { amount?: unknown }).amount; + const incomingAmount = + typeof rawAmount === 'string' || typeof rawAmount === 'number' + ? String(rawAmount) + : ''; + const previousAmount = ( + previousBalances[assetId as Caip19AssetId] as + | { amount?: string } + | undefined + )?.amount; + const assetDecimals = ( + previousState.assetsInfo[assetId as Caip19AssetId] as + | { decimals?: number } + | undefined + )?.decimals; + + balanceComparisons.push({ + accountId, + assetId, + incomingAmount, + previousAmount, + normalizedIncoming: normalizeAmountString( + incomingAmount, + assetDecimals, + ), + normalizedPrevious: + previousAmount === undefined + ? undefined + : normalizeAmountString(previousAmount, assetDecimals), + amountsEqual: + previousAmount !== undefined && + normalizeAmountString(incomingAmount, assetDecimals) === + normalizeAmountString(previousAmount, assetDecimals), + }); + } + } + + balanceWsDebug('updateState:noChanges', { + updateMode: mode, + balanceComparisons, + }); } // Publish balance changed events @@ -3207,6 +3414,18 @@ export class AssetsController extends BaseController< ): Promise { const updateStart = performance.now(); + if (sourceId === 'BackendWebsocketDataSource') { + balanceWsDebug('handleAssetsUpdate:enter', { + sourceId, + updateMode: response.updateMode, + balances: response.assetsBalance, + metadataKeys: response.assetsInfo + ? Object.keys(response.assetsInfo) + : [], + dataTypes: request?.dataTypes, + }); + } + log('Assets updated from data source', { sourceId, hasBalance: Boolean(response.assetsBalance), @@ -3235,7 +3454,8 @@ export class AssetsController extends BaseController< // chains the rule does not apply to, so skip the middleware for those. const shouldGraduateCustomAssets = sourceId === 'AccountsApiDataSource' || - sourceId === 'BackendWebsocketDataSource'; + sourceId === 'BackendWebsocketDataSource' || + sourceId === 'AccountActivityService'; const enrichmentSources: AssetsDataSource[] = [ ...(shouldGraduateCustomAssets @@ -3260,6 +3480,17 @@ export class AssetsController extends BaseController< await this.#updateState(enrichedResponse); + if (sourceId === 'BackendWebsocketDataSource') { + balanceWsDebug('handleAssetsUpdate:done', { + sourceId, + durationMs: performance.now() - updateStart, + enrichedMetadataKeys: enrichedResponse.assetsInfo + ? Object.keys(enrichedResponse.assetsInfo) + : [], + enrichedBalances: enrichedResponse.assetsBalance, + }); + } + this.#emitTrace(TRACE_UPDATE_PIPELINE, { source: sourceId, duration_ms: performance.now() - updateStart, diff --git a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts index 793d3e79e5..1145370ac9 100644 --- a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts +++ b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts @@ -92,6 +92,8 @@ export function createMockAssetControllerMessenger(): { 'PermissionController:stateChange', // BackendWebsocketDataSource 'BackendWebSocketService:connectionStateChanged', + // AccountActivityService + 'AccountActivityService:balanceUpdated', ], }); diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts index 65132a7eb8..9e424bdb65 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts @@ -13,22 +13,17 @@ import { KnownCaipNamespace, toCaipChainId, } from '@metamask/utils'; -import BigNumberJS from 'bignumber.js'; import type { AssetsControllerMessenger } from '../AssetsController'; import { projectLogger, createModuleLogger } from '../logger'; -import type { - ChainId, - Caip19AssetId, - AssetMetadata, - AssetBalance, - DataResponse, -} from '../types'; +import type { ChainId, Caip19AssetId, DataResponse } from '../types'; import { AbstractDataSource } from './AbstractDataSource'; import type { DataSourceState, SubscriptionRequest, } from './AbstractDataSource'; +import { balanceWsDebug } from '../utils/balanceWsDebug'; +import { processAccountActivityBalanceUpdates } from '../utils/processAccountActivityBalanceUpdates'; // ============================================================================ // CONSTANTS @@ -645,6 +640,13 @@ export class BackendWebsocketDataSource extends AbstractDataSource< const activityMessage = notification.data as unknown as AccountActivityMessage; + balanceWsDebug('ws:enter', { + subscriptionId, + channel: notification.channel, + event: (notification as { event?: string }).event, + data: activityMessage, + }); + const storedSubscription = this.#subscriptionRequests.get(subscriptionId); const request = storedSubscription?.request; const onAssetsUpdate = @@ -652,12 +654,20 @@ export class BackendWebsocketDataSource extends AbstractDataSource< storedSubscription?.onAssetsUpdate; if (!request) { + balanceWsDebug('ws:drop', { reason: 'no-request', subscriptionId }); return; } const { address, tx, updates } = activityMessage; if (!address || !tx || !updates) { + balanceWsDebug('ws:drop', { + reason: 'missing-activity-fields', + subscriptionId, + hasAddress: Boolean(address), + hasTx: Boolean(tx), + hasUpdates: Boolean(updates), + }); return; } @@ -673,6 +683,14 @@ export class BackendWebsocketDataSource extends AbstractDataSource< : a.address === address, ); if (!account) { + balanceWsDebug('ws:drop', { + reason: 'account-not-in-request', + subscriptionId, + address, + requestedAddresses: request.accountsWithSupportedChains.map( + (entry) => entry.account.address, + ), + }); return; } const accountId = account.id; @@ -680,13 +698,51 @@ export class BackendWebsocketDataSource extends AbstractDataSource< // Process all balance updates from the activity message const response = this.#processBalanceUpdates(updates, chainId, accountId); - const hasBalances = - Object.keys(response.assetsBalance?.[accountId] ?? {}).length > 0; + const balanceEntries = response.assetsBalance?.[accountId] ?? {}; + const hasBalances = Object.keys(balanceEntries).length > 0; + + balanceWsDebug('ws:processed', { + subscriptionId, + accountId, + chainId, + updateCount: updates.length, + balanceCount: Object.keys(balanceEntries).length, + balances: balanceEntries, + hasCallback: Boolean(onAssetsUpdate), + }); if (hasBalances && onAssetsUpdate) { - Promise.resolve(onAssetsUpdate(response, request)).catch(console.error); + balanceWsDebug('ws:apply', { + subscriptionId, + accountId, + updateMode: response.updateMode, + }); + Promise.resolve(onAssetsUpdate(response, request)).catch((error) => { + balanceWsDebug('ws:applyError', { + subscriptionId, + accountId, + error: String(error), + }); + console.error(error); + }); + } else if (hasBalances) { + balanceWsDebug('ws:drop', { + reason: 'no-callback', + subscriptionId, + accountId, + }); + } else { + balanceWsDebug('ws:drop', { + reason: 'no-valid-balances', + subscriptionId, + accountId, + }); } } catch (error) { + balanceWsDebug('ws:error', { + subscriptionId, + error: String(error), + }); log('Error handling notification', error); } } @@ -705,57 +761,11 @@ export class BackendWebsocketDataSource extends AbstractDataSource< _chainId: ChainId, accountId: string, ): DataResponse { - const assetsBalance: Record> = { - [accountId]: {}, - }; - const assetsMetadata: Record = {}; - - for (const update of updates) { - const { asset, postBalance } = update; - - if (!asset || !postBalance) { - continue; - } - - // Asset type is in CAIP format: "eip155:1/erc20:0x..." or "eip155:1/slip44:60" - // We can use it directly as the asset ID - const assetId = asset.type as Caip19AssetId; - - const tokenType = this.#getAssetType(assetId); - - // We assume decimals are always present; skip malformed updates - if (asset.decimals === undefined) { - continue; - } - - // Parse raw balance (hex like "0x26f0e5" or decimal string) - const rawBalanceStr = postBalance.amount.startsWith('0x') - ? BigInt(postBalance.amount).toString() - : postBalance.amount; - - const humanReadableAmount = new BigNumberJS(rawBalanceStr) - .dividedBy(new BigNumberJS(10).pow(asset.decimals)) - .toFixed(); - - assetsBalance[accountId][assetId] = { - amount: humanReadableAmount, - }; - - assetsMetadata[assetId] = { - type: tokenType, - symbol: asset.unit, - name: asset.unit, // Use unit as name (actual name may not be in the message) - decimals: asset.decimals, - }; - } - - const response: DataResponse = { updateMode: 'merge' }; - if (Object.keys(assetsBalance[accountId]).length > 0) { - response.assetsBalance = assetsBalance; - response.assetsInfo = assetsMetadata; - } - - return response; + return processAccountActivityBalanceUpdates( + updates, + accountId, + (assetId) => this.#getAssetType(assetId), + ); } // ============================================================================ diff --git a/packages/assets-controller/src/utils/balanceWsDebug.ts b/packages/assets-controller/src/utils/balanceWsDebug.ts new file mode 100644 index 0000000000..c8ddd36148 --- /dev/null +++ b/packages/assets-controller/src/utils/balanceWsDebug.ts @@ -0,0 +1,56 @@ +type BalanceWsDebugPayload = Record; + +type BalanceWsDebugGlobal = typeof globalThis & { + // eslint-disable-next-line @typescript-eslint/naming-convention -- DevTools toggle + METAMASK_BALANCE_WS_DEBUG?: boolean; +}; + +const LOG_PREFIX = '+++ [Balances][WS]'; + +/** + * @param value - Value to serialize for console output. + * @returns JSON string safe for logging. + */ +function safeStringify(value: unknown): string { + try { + return JSON.stringify( + value, + (_key, nestedValue) => + typeof nestedValue === 'bigint' ? nestedValue.toString() : nestedValue, + 2, + ); + } catch { + return String(value); + } +} + +/** + * Temporary WS balance pipeline logging for max-send / post-tx debugging. + * Filter DevTools console with `+++` or `[Balances][WS]`. + * + * Set `globalThis.METAMASK_BALANCE_WS_DEBUG = false` to silence. + * + * @param step - Pipeline step label. + * @param payload - Optional structured debug data. + */ +export function balanceWsDebug( + step: string, + payload?: BalanceWsDebugPayload, +): void { + if (typeof globalThis === 'undefined') { + return; + } + + const debugGlobal = globalThis as BalanceWsDebugGlobal; + + if (debugGlobal.METAMASK_BALANCE_WS_DEBUG === false) { + return; + } + + if (payload === undefined) { + console.log(`${LOG_PREFIX} ${step}`); + return; + } + + console.log(`${LOG_PREFIX} ${step}\n${safeStringify(payload)}`); +} diff --git a/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.test.ts b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.test.ts new file mode 100644 index 0000000000..6f4f09e58c --- /dev/null +++ b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.test.ts @@ -0,0 +1,39 @@ +import type { BalanceUpdate } from '@metamask/core-backend'; + +import type { Caip19AssetId } from '../types'; +import { processAccountActivityBalanceUpdates } from './processAccountActivityBalanceUpdates'; + +describe('processAccountActivityBalanceUpdates', () => { + it('converts hex postBalance to human-readable amount', () => { + const accountId = 'account-1'; + const assetId = 'eip155:42161/slip44:60' as Caip19AssetId; + const updates = [ + { + asset: { + fungible: true, + type: assetId, + unit: 'ETH', + decimals: 18, + }, + postBalance: { amount: '0x10aa6d94e80' }, + transfers: [], + }, + ] as BalanceUpdate[]; + + const response = processAccountActivityBalanceUpdates( + updates, + accountId, + () => 'native', + ); + + expect(response.updateMode).toBe('merge'); + expect(response.assetsBalance?.[accountId]?.[assetId]).toStrictEqual({ + amount: '0.00000114526056', + }); + expect(response.assetsInfo?.[assetId]).toMatchObject({ + type: 'native', + symbol: 'ETH', + decimals: 18, + }); + }); +}); diff --git a/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts new file mode 100644 index 0000000000..7ec99fef3f --- /dev/null +++ b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts @@ -0,0 +1,70 @@ +import type { BalanceUpdate } from '@metamask/core-backend'; +import BigNumberJS from 'bignumber.js'; + +import type { + AssetBalance, + AssetMetadata, + Caip19AssetId, + DataResponse, +} from '../types'; + +/** + * Convert AccountActivityMessage balance updates into a {@link DataResponse} + * for AssetsController (same shape as BackendWebsocketDataSource). + * + * @param updates - Balance updates from account-activity websocket payload. + * @param accountId - Internal account UUID. + * @param getAssetType - Resolver for asset metadata type. + * @returns DataResponse with merge mode when balances are present. + */ +export function processAccountActivityBalanceUpdates( + updates: BalanceUpdate[], + accountId: string, + getAssetType: (assetId: Caip19AssetId) => 'native' | 'erc20' | 'spl', +): DataResponse { + const assetsBalance: Record> = { + [accountId]: {}, + }; + const assetsMetadata: Record = {}; + + for (const update of updates) { + const { asset, postBalance } = update; + + if (!asset || !postBalance) { + continue; + } + + const assetId = asset.type as Caip19AssetId; + + if (asset.decimals === undefined) { + continue; + } + + const rawBalanceStr = postBalance.amount.startsWith('0x') + ? BigInt(postBalance.amount).toString() + : postBalance.amount; + + const humanReadableAmount = new BigNumberJS(rawBalanceStr) + .dividedBy(new BigNumberJS(10).pow(asset.decimals)) + .toFixed(); + + assetsBalance[accountId][assetId] = { + amount: humanReadableAmount, + }; + + assetsMetadata[assetId] = { + type: getAssetType(assetId), + symbol: asset.unit, + name: asset.unit, + decimals: asset.decimals, + }; + } + + const response: DataResponse = { updateMode: 'merge' }; + if (Object.keys(assetsBalance[accountId]).length > 0) { + response.assetsBalance = assetsBalance; + response.assetsInfo = assetsMetadata; + } + + return response; +} diff --git a/packages/core-backend/src/ws/BackendWebSocketService.ts b/packages/core-backend/src/ws/BackendWebSocketService.ts index 89e090c944..5be28d4800 100644 --- a/packages/core-backend/src/ws/BackendWebSocketService.ts +++ b/packages/core-backend/src/ws/BackendWebSocketService.ts @@ -16,6 +16,58 @@ const SERVICE_NAME = 'BackendWebSocketService' as const; const log = createModuleLogger(projectLogger, SERVICE_NAME); +/** @see packages/assets-controller/src/utils/balanceWsDebug.ts */ +type BalanceWsRouteDebugGlobal = typeof globalThis & { + // eslint-disable-next-line @typescript-eslint/naming-convention -- DevTools toggle + METAMASK_BALANCE_WS_DEBUG?: boolean; +}; + +const LOG_PREFIX = '+++ [Balances][WS]'; + +/** + * @param value - Value to serialize for console output. + * @returns JSON string safe for logging. + */ +function balanceWsSafeStringify(value: unknown): string { + try { + return JSON.stringify( + value, + (_key, nestedValue) => + typeof nestedValue === 'bigint' ? nestedValue.toString() : nestedValue, + 2, + ); + } catch { + return String(value); + } +} + +/** + * Temporary WS routing logging. Filter DevTools with `+++`. + * + * @param step - Routing step label. + * @param payload - Optional structured debug data. + */ +function balanceWsRouteDebug( + step: string, + payload?: Record, +): void { + if (typeof globalThis === 'undefined') { + return; + } + + const debugGlobal = globalThis as BalanceWsRouteDebugGlobal; + if (debugGlobal.METAMASK_BALANCE_WS_DEBUG === false) { + return; + } + + if (payload === undefined) { + console.log(`${LOG_PREFIX} ${step}`); + return; + } + + console.log(`${LOG_PREFIX} ${step}\n${balanceWsSafeStringify(payload)}`); +} + function isAccountActivityChannel(channel: string): boolean { return channel.includes('account-activity'); } @@ -1226,8 +1278,37 @@ export class BackendWebSocketService { * @param message - The WebSocket message to handle */ #handleMessage(message: WebSocketMessage): void { + const notificationMessage = message as Partial; + const { channel, subscriptionId } = notificationMessage; + const isServerResponse = this.#isServerResponse(message); + const isSubscriptionNotification = + this.#isSubscriptionNotification(message); + const isChannelMessage = this.#isChannelMessage(message); + + if ( + typeof channel === 'string' && + isAccountActivityChannel(channel) && + (isSubscriptionNotification || isChannelMessage) + ) { + balanceWsRouteDebug('wsService:handleMessage', { + channel, + subscriptionId, + isServerResponse, + isSubscriptionNotification, + isChannelMessage, + hasRequestIdInData: + Object.prototype.hasOwnProperty.call(message, 'data') && + message.data !== null && + typeof message.data === 'object' && + Object.prototype.hasOwnProperty.call( + message.data as object, + 'requestId', + ), + }); + } + // Handle server responses (correlated with requests) first - if (this.#isServerResponse(message)) { + if (isServerResponse) { const maybeNotification = message as Partial; if ( typeof maybeNotification.channel !== 'string' || @@ -1239,9 +1320,14 @@ export class BackendWebSocketService { } // Handle subscription notifications with valid subscriptionId - if (this.#isSubscriptionNotification(message)) { + if (isSubscriptionNotification) { const notificationMsg = message as ServerNotificationMessage; const handled = this.#handleSubscriptionNotification(notificationMsg); + balanceWsRouteDebug('wsService:subscriptionRoute', { + channel: notificationMsg.channel, + subscriptionId: notificationMsg.subscriptionId, + handled, + }); // If subscription notification wasn't handled (falsy subscriptionId), fall through to channel handling if (handled) { return; @@ -1249,8 +1335,12 @@ export class BackendWebSocketService { } // Trigger channel callbacks for any message with a channel property - if (this.#isChannelMessage(message)) { + if (isChannelMessage) { const channelMsg = message; + balanceWsRouteDebug('wsService:channelFallback', { + channel: channelMsg.channel, + subscriptionId: channelMsg.subscriptionId, + }); this.#handleChannelMessage(channelMsg); } } @@ -1339,6 +1429,10 @@ export class BackendWebSocketService { */ #handleChannelMessage(message: ServerNotificationMessage): void { const callback = this.#resolveChannelCallback(message.channel); + balanceWsRouteDebug('wsService:channelCallback', { + channel: message.channel, + hasCallback: Boolean(callback), + }); callback?.(message); } @@ -1408,15 +1502,29 @@ export class BackendWebSocketService { } if (!subscription) { + balanceWsRouteDebug('wsService:subscriptionMiss', { + channel, + subscriptionId, + }); return false; } const activeSubscription = subscription; if (!activeSubscription.callback) { + balanceWsRouteDebug('wsService:subscriptionNoCallback', { + channel, + subscriptionId, + }); return false; } + balanceWsRouteDebug('wsService:subscriptionHit', { + channel, + subscriptionId, + channelType: activeSubscription.channelType, + }); + // Calculate notification latency: time from server sent to client received const receivedAt = Date.now(); const latency = receivedAt - timestamp; @@ -1439,6 +1547,12 @@ export class BackendWebSocketService { }, }, () => { + balanceWsRouteDebug('wsService:callbackInvoke', { + channel, + subscriptionId, + channelType: activeSubscription.channelType, + data: message.data, + }); activeSubscription.callback?.(message); }, ); From a634c412b2494697e4dd7e9b6259528934366dd6 Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 11:07:51 +0200 Subject: [PATCH 13/24] fix: clean up --- packages/assets-controller/CHANGELOG.md | 2 +- .../src/AssetsController.test.ts | 14 ++--- .../assets-controller/src/AssetsController.ts | 60 ++++++++++++++----- .../MockAssetControllerMessenger.ts | 3 + 4 files changed, 56 insertions(+), 23 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index ba49da5e56..02d93f996f 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** `AssetsController` messenger must now allow `AccountActivityService:balanceUpdated` so unified `assetsBalance` state receives real-time websocket balance updates when `AccountActivityService` owns the server subscription ([#9273](https://github.com/MetaMask/core/pull/9273)) - Bump `@metamask/transaction-controller` from `^68.1.1` to `^68.2.0` ([#9253](https://github.com/MetaMask/core/pull/9253)) ### Fixed @@ -19,7 +20,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove the 120-second websocket balance freshness guard that blocked force-refresh and polling updates from correcting stale websocket balances ([#9273](https://github.com/MetaMask/core/pull/9273)) - Add `update` balance update mode so fetch and force-refresh pipelines patch balances without removing tokens omitted from partial API snapshots; `getAssets({ forceUpdate: true })`, `AccountsApiDataSource`, and `SnapDataSource` use this mode ([#9273](https://github.com/MetaMask/core/pull/9273)) - `BackendWebsocketDataSource` registers subscription handlers before the subscribe handshake so in-flight account-activity notifications are not dropped, cleans up subscription state on subscribe failure, and resolves balance updates from stored subscription state when notifications arrive with stale subscription IDs ([#9273](https://github.com/MetaMask/core/pull/9273)) -- `AssetsController` subscribes to `AccountActivityService:balanceUpdated` so unified `assetsBalance` state updates from real-time websocket notifications when `AccountActivityService` owns the server subscription ([#9273](https://github.com/MetaMask/core/pull/9273)) ## [9.1.0] diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 5861e34616..9cbc82bbee 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -2161,7 +2161,7 @@ describe('AssetsController', () => { messenger as unknown as { publish: (topic: string, payload?: unknown) => void; } - ).publish('ClientController:stateChange', { isUiOpen: true }); + ).publish('ClientController:stateChanged', { isUiOpen: true }); messenger.publish('KeyringController:unlock'); // Allow #start() -> getAssets() to resolve so the callback runs @@ -2214,7 +2214,7 @@ describe('AssetsController', () => { messenger as unknown as { publish: (topic: string, payload?: unknown) => void; } - ).publish('ClientController:stateChange', { isUiOpen: true }); + ).publish('ClientController:stateChanged', { isUiOpen: true }); messenger.publish('KeyringController:unlock'); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -2296,7 +2296,7 @@ describe('AssetsController', () => { it('handles enabled networks change', async () => { await withController(async ({ messenger }) => { (messenger.publish as CallableFunction)( - 'NetworkEnablementController:stateChange', + 'NetworkEnablementController:stateChanged', { enabledNetworkMap: { eip155: { @@ -2321,7 +2321,7 @@ describe('AssetsController', () => { it('handles network being disabled', async () => { await withController(async ({ messenger }) => { (messenger.publish as CallableFunction)( - 'NetworkEnablementController:stateChange', + 'NetworkEnablementController:stateChanged', { enabledNetworkMap: { eip155: { @@ -2340,7 +2340,7 @@ describe('AssetsController', () => { await new Promise(process.nextTick); (messenger.publish as CallableFunction)( - 'NetworkEnablementController:stateChange', + 'NetworkEnablementController:stateChanged', { enabledNetworkMap: { eip155: { @@ -2453,7 +2453,7 @@ describe('AssetsController', () => { messenger as unknown as { publish: (topic: string, payload?: unknown) => void; } - ).publish('ClientController:stateChange', { isUiOpen: true }); + ).publish('ClientController:stateChanged', { isUiOpen: true }); messenger.publish('KeyringController:unlock'); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -2462,7 +2462,7 @@ describe('AssetsController', () => { // Step 2: AccountTreeController.init() completes — accounts now available getAccountsMock.mockReturnValue([createMockInternalAccount()]); (messenger.publish as CallableFunction)( - 'AccountTreeController:stateChange', + 'AccountTreeController:stateChanged', {}, [], ); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index bb0fc6ef1b..252c0760cf 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1,16 +1,17 @@ import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, AccountTreeControllerSelectedAccountGroupChangeEvent, - AccountTreeControllerStateChangeEvent, + AccountTreeControllerState, } from '@metamask/account-tree-controller'; import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import { BaseController } from '@metamask/base-controller'; import type { ControllerGetStateAction, ControllerStateChangeEvent, + ControllerStateChangedEvent, StateMetadata, } from '@metamask/base-controller'; -import type { ClientControllerStateChangeEvent } from '@metamask/client-controller'; +import type { ClientControllerState } from '@metamask/client-controller'; import { clientControllerSelectors } from '@metamask/client-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import type { @@ -319,11 +320,26 @@ type AllowedActions = // PhishingController | PhishingControllerBulkScanTokensAction; +type AccountTreeControllerStateChangedEvent = ControllerStateChangedEvent< + 'AccountTreeController', + AccountTreeControllerState +>; + +type ClientControllerStateChangedEvent = ControllerStateChangedEvent< + 'ClientController', + ClientControllerState +>; + +type NetworkEnablementControllerStateChangedEvent = ControllerStateChangedEvent< + 'NetworkEnablementController', + NetworkEnablementControllerState +>; + type AllowedEvents = // AssetsController | AccountTreeControllerSelectedAccountGroupChangeEvent - | AccountTreeControllerStateChangeEvent - | ClientControllerStateChangeEvent + | AccountTreeControllerStateChangedEvent + | ClientControllerStateChangedEvent | KeyringControllerLockEvent | KeyringControllerUnlockEvent | PreferencesControllerStateChangeEvent @@ -338,6 +354,7 @@ type AllowedEvents = | NetworkControllerNetworkRemovedEvent // StakedBalanceDataSource | NetworkEnablementControllerEvents + | NetworkEnablementControllerStateChangedEvent // SnapDataSource | AccountsControllerAccountBalancesUpdatedEvent | PermissionControllerStateChange @@ -1020,13 +1037,13 @@ export class AssetsController extends BaseController< // The base-controller `:stateChange` event is guaranteed to fire // when init() calls this.update(). #start() is idempotent so // repeated fires are safe. - this.messenger.subscribe('AccountTreeController:stateChange', () => { + this.messenger.subscribe('AccountTreeController:stateChanged', () => { this.#handleAccountTreeStateChange(); }); // Subscribe to network enablement changes (only enabledNetworkMap) this.messenger.subscribe( - 'NetworkEnablementController:stateChange', + 'NetworkEnablementController:stateChanged', ({ enabledNetworkMap }) => { this.#handleEnabledNetworksChanged(enabledNetworkMap).catch( console.error, @@ -1054,7 +1071,7 @@ export class AssetsController extends BaseController< // Client + Keyring lifecycle: only run when UI is open AND keyring is unlocked this.messenger.subscribe( - 'ClientController:stateChange', + 'ClientController:stateChanged', (isUiOpen: boolean) => { this.#uiOpen = isUiOpen; this.#updateActive(); @@ -2163,7 +2180,12 @@ export class AssetsController extends BaseController< balances[accountId] = {}; } for (const nativeAssetId of nativeAssetIds) { - if (!(nativeAssetId in balances[accountId])) { + if ( + !Object.prototype.hasOwnProperty.call( + balances[accountId], + nativeAssetId, + ) + ) { balances[accountId][nativeAssetId] = { amount: '0' }; } } @@ -2402,7 +2424,7 @@ export class AssetsController extends BaseController< // Preserve custom assets that the response omitted. for (const customId of customAssetIds) { - if (!(customId in next)) { + if (!Object.prototype.hasOwnProperty.call(next, customId)) { const prev = previousBalances[customId]; next[customId] = prev ?? ({ amount: '0' } as AssetBalance); @@ -2428,12 +2450,17 @@ export class AssetsController extends BaseController< : this.#getNativeAssetIdsForEnabledChains(); for (const nativeAssetId of nativeAssetIdsForAccount) { const nativeChain = nativeAssetId.split('/')[0]; - if (!(nativeAssetId in effective)) { + if ( + !Object.prototype.hasOwnProperty.call(effective, nativeAssetId) + ) { effective[nativeAssetId] = { amount: '0' } as AssetBalance; } else if ( mode === 'update' && coveredChainsInResponse.has(nativeChain) && - !(nativeAssetId in accountBalances) + !Object.prototype.hasOwnProperty.call( + accountBalances, + nativeAssetId, + ) ) { // Update-mode overlay keeps previous native when the response // omits it. If the API returned any balance on this chain, @@ -2936,10 +2963,13 @@ export class AssetsController extends BaseController< const seenIds = new Set(); const accountsForSource = assignedChains .flatMap((chainId) => chainToAccounts.get(chainId) ?? []) - .filter( - (account) => - !seenIds.has(account.id) && (seenIds.add(account.id), true), - ); + .filter((account) => { + if (seenIds.has(account.id)) { + return false; + } + seenIds.add(account.id); + return true; + }); if (accountsForSource.length > 0) { this.#subscribeDataSource(source, accountsForSource, assignedChains); } diff --git a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts index 1145370ac9..d3c9e3e541 100644 --- a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts +++ b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts @@ -79,6 +79,8 @@ export function createMockAssetControllerMessenger(): { events: [ // AssetsController 'AccountTreeController:selectedAccountGroupChange', + 'AccountTreeController:stateChanged', + 'ClientController:stateChanged', 'KeyringController:lock', 'KeyringController:unlock', 'PreferencesController:stateChange', @@ -87,6 +89,7 @@ export function createMockAssetControllerMessenger(): { 'TransactionController:transactionConfirmed', // StakedBalanceDataSource 'NetworkEnablementController:stateChange', + 'NetworkEnablementController:stateChanged', // SnapDataSource 'AccountsController:accountBalancesUpdated', 'PermissionController:stateChange', From a0474be7a4d434b8fcb234e73ebed257b5d96edc Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 11:22:58 +0200 Subject: [PATCH 14/24] fix: clean up --- eslint-suppressions.json | 5 --- .../src/AssetsController.test.ts | 45 ++++++++++--------- .../assets-controller/src/AssetsController.ts | 9 ++-- .../BackendWebsocketDataSource.ts | 10 ++--- 4 files changed, 33 insertions(+), 36 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 03d0b4c819..7de43a0fc6 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -158,11 +158,6 @@ "count": 6 } }, - "packages/assets-controller/src/AssetsController.ts": { - "no-restricted-syntax": { - "count": 7 - } - }, "packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts": { "no-restricted-syntax": { "count": 4 diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 9cbc82bbee..33fbd3541a 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1875,30 +1875,33 @@ describe('AssetsController', () => { }, }; - await withController({ state: initialState }, async ({ controller, messenger }) => { - messenger.publish('AccountActivityService:balanceUpdated', { - address: '0x1234567890123456789012345678901234567890', - chain: 'eip155:42161', - updates: [ - { - asset: { - fungible: true, - type: arbNative, - unit: 'ETH', - decimals: 18, + await withController( + { state: initialState }, + async ({ controller, messenger }) => { + messenger.publish('AccountActivityService:balanceUpdated', { + address: '0x1234567890123456789012345678901234567890', + chain: 'eip155:42161', + updates: [ + { + asset: { + fungible: true, + type: arbNative, + unit: 'ETH', + decimals: 18, + }, + postBalance: { amount: '0x10aa6d94e80' }, + transfers: [], }, - postBalance: { amount: '0x10aa6d94e80' }, - transfers: [], - }, - ], - }); + ], + }); - await flushPromises(); + await flushPromises(); - expect( - controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[arbNative], - ).toStrictEqual({ amount: '0.00000114526056' }); - }); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[arbNative], + ).toStrictEqual({ amount: '0.00000114526056' }); + }, + ); }); it('zeros stale native in update mode when the chain is covered but native is omitted', async () => { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 252c0760cf..7b4fc8a5fa 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -141,9 +141,9 @@ import type { BridgeExchangeRatesFormat, TransactionPayLegacyFormat, } from './utils'; +import { balanceWsDebug } from './utils/balanceWsDebug'; import { ZERO_ADDRESS } from './utils/constants'; import { pickRpcCustomAssetsSupplement } from './utils/customAssetsRpcSupplement'; -import { balanceWsDebug } from './utils/balanceWsDebug'; import { processAccountActivityBalanceUpdates } from './utils/processAccountActivityBalanceUpdates'; const NATIVE_ASSETS_QUERY_KEY = ['nativeAssets']; @@ -2424,7 +2424,9 @@ export class AssetsController extends BaseController< // Preserve custom assets that the response omitted. for (const customId of customAssetIds) { - if (!Object.prototype.hasOwnProperty.call(next, customId)) { + if ( + !Object.prototype.hasOwnProperty.call(next, customId) + ) { const prev = previousBalances[customId]; next[customId] = prev ?? ({ amount: '0' } as AssetBalance); @@ -2586,8 +2588,7 @@ export class AssetsController extends BaseController< for (const [accountId, accountBalances] of Object.entries( normalizedResponse.assetsBalance, )) { - const previousBalances = - previousState.assetsBalance[accountId] ?? {}; + const previousBalances = previousState.assetsBalance[accountId] ?? {}; for (const [assetId, balance] of Object.entries(accountBalances)) { const rawAmount = (balance as { amount?: unknown }).amount; diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts index 9e424bdb65..7bd341e440 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts @@ -17,13 +17,13 @@ import { import type { AssetsControllerMessenger } from '../AssetsController'; import { projectLogger, createModuleLogger } from '../logger'; import type { ChainId, Caip19AssetId, DataResponse } from '../types'; +import { balanceWsDebug } from '../utils/balanceWsDebug'; +import { processAccountActivityBalanceUpdates } from '../utils/processAccountActivityBalanceUpdates'; import { AbstractDataSource } from './AbstractDataSource'; import type { DataSourceState, SubscriptionRequest, } from './AbstractDataSource'; -import { balanceWsDebug } from '../utils/balanceWsDebug'; -import { processAccountActivityBalanceUpdates } from '../utils/processAccountActivityBalanceUpdates'; // ============================================================================ // CONSTANTS @@ -761,10 +761,8 @@ export class BackendWebsocketDataSource extends AbstractDataSource< _chainId: ChainId, accountId: string, ): DataResponse { - return processAccountActivityBalanceUpdates( - updates, - accountId, - (assetId) => this.#getAssetType(assetId), + return processAccountActivityBalanceUpdates(updates, accountId, (assetId) => + this.#getAssetType(assetId), ); } From b8ead26eec313d5ab91aa81c138213d098807800 Mon Sep 17 00:00:00 2001 From: Salim TOUBAL Date: Fri, 26 Jun 2026 11:30:52 +0200 Subject: [PATCH 15/24] Potential fix for pull request finding 'CodeQL / Prototype-polluting assignment' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../processAccountActivityBalanceUpdates.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts index 7ec99fef3f..16c3844ce3 100644 --- a/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts +++ b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts @@ -22,10 +22,18 @@ export function processAccountActivityBalanceUpdates( accountId: string, getAssetType: (assetId: Caip19AssetId) => 'native' | 'erc20' | 'spl', ): DataResponse { - const assetsBalance: Record> = { - [accountId]: {}, - }; - const assetsMetadata: Record = {}; + const assetsBalance = Object.create(null) as Record< + string, + Record + >; + assetsBalance[accountId] = Object.create(null) as Record< + Caip19AssetId, + AssetBalance + >; + const assetsMetadata = Object.create(null) as Record< + Caip19AssetId, + AssetMetadata + >; for (const update of updates) { const { asset, postBalance } = update; From b6f495baedc25ca2d2fbe8c30b69755c13c7d262 Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 12:18:21 +0200 Subject: [PATCH 16/24] fix: clean up --- .../assets-controller/src/AssetsController.ts | 116 ------------------ .../BackendWebsocketDataSource.ts | 60 --------- .../src/utils/balanceWsDebug.ts | 56 --------- .../src/ws/BackendWebSocketService.test.ts | 47 +++++++ .../src/ws/BackendWebSocketService.ts | 116 +----------------- 5 files changed, 49 insertions(+), 346 deletions(-) delete mode 100644 packages/assets-controller/src/utils/balanceWsDebug.ts diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 7b4fc8a5fa..8fceff26a3 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -141,7 +141,6 @@ import type { BridgeExchangeRatesFormat, TransactionPayLegacyFormat, } from './utils'; -import { balanceWsDebug } from './utils/balanceWsDebug'; import { ZERO_ADDRESS } from './utils/constants'; import { pickRpcCustomAssetsSupplement } from './utils/customAssetsRpcSupplement'; import { processAccountActivityBalanceUpdates } from './utils/processAccountActivityBalanceUpdates'; @@ -1145,13 +1144,6 @@ export class AssetsController extends BaseController< chain: string; updates: BalanceUpdate[]; }): void { - balanceWsDebug('aa:enter', { - address, - chain, - updateCount: updates.length, - updates, - }); - const account = this.#getSelectedAccounts().find((a) => a.address.startsWith('0x') ? a.address.toLowerCase() === address.toLowerCase() @@ -1159,7 +1151,6 @@ export class AssetsController extends BaseController< ); if (!account) { - balanceWsDebug('aa:drop', { reason: 'account-not-found', address }); return; } @@ -1171,7 +1162,6 @@ export class AssetsController extends BaseController< ); if (!response.assetsBalance) { - balanceWsDebug('aa:drop', { reason: 'no-balances', address, chain }); return; } @@ -1181,16 +1171,8 @@ export class AssetsController extends BaseController< dataTypes: ['balance', 'metadata'], }; - balanceWsDebug('aa:apply', { - accountId: account.id, - chainId, - balances: response.assetsBalance, - updateMode: response.updateMode, - }); - this.handleAssetsUpdate(response, 'AccountActivityService', request).catch( (error) => { - balanceWsDebug('aa:error', { error: String(error) }); log('Failed to apply AccountActivityService balance update', { error }); }, ); @@ -2271,16 +2253,6 @@ export class AssetsController extends BaseController< const normalizedResponse = normalizeResponse(response); const mode: AssetsUpdateMode = normalizedResponse.updateMode ?? 'merge'; - balanceWsDebug('updateState:enter', { - updateMode: mode, - balanceAccounts: normalizedResponse.assetsBalance - ? Object.keys(normalizedResponse.assetsBalance) - : [], - metadataCount: normalizedResponse.assetsInfo - ? Object.keys(normalizedResponse.assetsInfo).length - : 0, - }); - const releaseLock = await this.#controllerMutex.acquire(); try { @@ -2550,13 +2522,6 @@ export class AssetsController extends BaseController< changedMetadata.length > 0 || changedPriceAssets.length > 0 ) { - balanceWsDebug('updateState:applied', { - updateMode: mode, - changedBalances, - changedMetadataCount: changedMetadata.length, - changedPricesCount: changedPriceAssets.length, - }); - log('State updated', { changedBalances: changedBalances.length > 0 ? changedBalances : undefined, @@ -2574,64 +2539,6 @@ export class AssetsController extends BaseController< })) : undefined, }); - } else if (normalizedResponse.assetsBalance) { - const balanceComparisons: { - accountId: string; - assetId: string; - incomingAmount: string; - previousAmount: string | undefined; - normalizedIncoming?: string; - normalizedPrevious?: string; - amountsEqual: boolean; - }[] = []; - - for (const [accountId, accountBalances] of Object.entries( - normalizedResponse.assetsBalance, - )) { - const previousBalances = previousState.assetsBalance[accountId] ?? {}; - - for (const [assetId, balance] of Object.entries(accountBalances)) { - const rawAmount = (balance as { amount?: unknown }).amount; - const incomingAmount = - typeof rawAmount === 'string' || typeof rawAmount === 'number' - ? String(rawAmount) - : ''; - const previousAmount = ( - previousBalances[assetId as Caip19AssetId] as - | { amount?: string } - | undefined - )?.amount; - const assetDecimals = ( - previousState.assetsInfo[assetId as Caip19AssetId] as - | { decimals?: number } - | undefined - )?.decimals; - - balanceComparisons.push({ - accountId, - assetId, - incomingAmount, - previousAmount, - normalizedIncoming: normalizeAmountString( - incomingAmount, - assetDecimals, - ), - normalizedPrevious: - previousAmount === undefined - ? undefined - : normalizeAmountString(previousAmount, assetDecimals), - amountsEqual: - previousAmount !== undefined && - normalizeAmountString(incomingAmount, assetDecimals) === - normalizeAmountString(previousAmount, assetDecimals), - }); - } - } - - balanceWsDebug('updateState:noChanges', { - updateMode: mode, - balanceComparisons, - }); } // Publish balance changed events @@ -3445,18 +3352,6 @@ export class AssetsController extends BaseController< ): Promise { const updateStart = performance.now(); - if (sourceId === 'BackendWebsocketDataSource') { - balanceWsDebug('handleAssetsUpdate:enter', { - sourceId, - updateMode: response.updateMode, - balances: response.assetsBalance, - metadataKeys: response.assetsInfo - ? Object.keys(response.assetsInfo) - : [], - dataTypes: request?.dataTypes, - }); - } - log('Assets updated from data source', { sourceId, hasBalance: Boolean(response.assetsBalance), @@ -3511,17 +3406,6 @@ export class AssetsController extends BaseController< await this.#updateState(enrichedResponse); - if (sourceId === 'BackendWebsocketDataSource') { - balanceWsDebug('handleAssetsUpdate:done', { - sourceId, - durationMs: performance.now() - updateStart, - enrichedMetadataKeys: enrichedResponse.assetsInfo - ? Object.keys(enrichedResponse.assetsInfo) - : [], - enrichedBalances: enrichedResponse.assetsBalance, - }); - } - this.#emitTrace(TRACE_UPDATE_PIPELINE, { source: sourceId, duration_ms: performance.now() - updateStart, diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts index 7bd341e440..d63fa16fcd 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts @@ -17,7 +17,6 @@ import { import type { AssetsControllerMessenger } from '../AssetsController'; import { projectLogger, createModuleLogger } from '../logger'; import type { ChainId, Caip19AssetId, DataResponse } from '../types'; -import { balanceWsDebug } from '../utils/balanceWsDebug'; import { processAccountActivityBalanceUpdates } from '../utils/processAccountActivityBalanceUpdates'; import { AbstractDataSource } from './AbstractDataSource'; import type { @@ -640,13 +639,6 @@ export class BackendWebsocketDataSource extends AbstractDataSource< const activityMessage = notification.data as unknown as AccountActivityMessage; - balanceWsDebug('ws:enter', { - subscriptionId, - channel: notification.channel, - event: (notification as { event?: string }).event, - data: activityMessage, - }); - const storedSubscription = this.#subscriptionRequests.get(subscriptionId); const request = storedSubscription?.request; const onAssetsUpdate = @@ -654,20 +646,12 @@ export class BackendWebsocketDataSource extends AbstractDataSource< storedSubscription?.onAssetsUpdate; if (!request) { - balanceWsDebug('ws:drop', { reason: 'no-request', subscriptionId }); return; } const { address, tx, updates } = activityMessage; if (!address || !tx || !updates) { - balanceWsDebug('ws:drop', { - reason: 'missing-activity-fields', - subscriptionId, - hasAddress: Boolean(address), - hasTx: Boolean(tx), - hasUpdates: Boolean(updates), - }); return; } @@ -683,14 +667,6 @@ export class BackendWebsocketDataSource extends AbstractDataSource< : a.address === address, ); if (!account) { - balanceWsDebug('ws:drop', { - reason: 'account-not-in-request', - subscriptionId, - address, - requestedAddresses: request.accountsWithSupportedChains.map( - (entry) => entry.account.address, - ), - }); return; } const accountId = account.id; @@ -701,48 +677,12 @@ export class BackendWebsocketDataSource extends AbstractDataSource< const balanceEntries = response.assetsBalance?.[accountId] ?? {}; const hasBalances = Object.keys(balanceEntries).length > 0; - balanceWsDebug('ws:processed', { - subscriptionId, - accountId, - chainId, - updateCount: updates.length, - balanceCount: Object.keys(balanceEntries).length, - balances: balanceEntries, - hasCallback: Boolean(onAssetsUpdate), - }); - if (hasBalances && onAssetsUpdate) { - balanceWsDebug('ws:apply', { - subscriptionId, - accountId, - updateMode: response.updateMode, - }); Promise.resolve(onAssetsUpdate(response, request)).catch((error) => { - balanceWsDebug('ws:applyError', { - subscriptionId, - accountId, - error: String(error), - }); console.error(error); }); - } else if (hasBalances) { - balanceWsDebug('ws:drop', { - reason: 'no-callback', - subscriptionId, - accountId, - }); - } else { - balanceWsDebug('ws:drop', { - reason: 'no-valid-balances', - subscriptionId, - accountId, - }); } } catch (error) { - balanceWsDebug('ws:error', { - subscriptionId, - error: String(error), - }); log('Error handling notification', error); } } diff --git a/packages/assets-controller/src/utils/balanceWsDebug.ts b/packages/assets-controller/src/utils/balanceWsDebug.ts deleted file mode 100644 index c8ddd36148..0000000000 --- a/packages/assets-controller/src/utils/balanceWsDebug.ts +++ /dev/null @@ -1,56 +0,0 @@ -type BalanceWsDebugPayload = Record; - -type BalanceWsDebugGlobal = typeof globalThis & { - // eslint-disable-next-line @typescript-eslint/naming-convention -- DevTools toggle - METAMASK_BALANCE_WS_DEBUG?: boolean; -}; - -const LOG_PREFIX = '+++ [Balances][WS]'; - -/** - * @param value - Value to serialize for console output. - * @returns JSON string safe for logging. - */ -function safeStringify(value: unknown): string { - try { - return JSON.stringify( - value, - (_key, nestedValue) => - typeof nestedValue === 'bigint' ? nestedValue.toString() : nestedValue, - 2, - ); - } catch { - return String(value); - } -} - -/** - * Temporary WS balance pipeline logging for max-send / post-tx debugging. - * Filter DevTools console with `+++` or `[Balances][WS]`. - * - * Set `globalThis.METAMASK_BALANCE_WS_DEBUG = false` to silence. - * - * @param step - Pipeline step label. - * @param payload - Optional structured debug data. - */ -export function balanceWsDebug( - step: string, - payload?: BalanceWsDebugPayload, -): void { - if (typeof globalThis === 'undefined') { - return; - } - - const debugGlobal = globalThis as BalanceWsDebugGlobal; - - if (debugGlobal.METAMASK_BALANCE_WS_DEBUG === false) { - return; - } - - if (payload === undefined) { - console.log(`${LOG_PREFIX} ${step}`); - return; - } - - console.log(`${LOG_PREFIX} ${step}\n${safeStringify(payload)}`); -} diff --git a/packages/core-backend/src/ws/BackendWebSocketService.test.ts b/packages/core-backend/src/ws/BackendWebSocketService.test.ts index d78107244f..21dc19dcd9 100644 --- a/packages/core-backend/src/ws/BackendWebSocketService.test.ts +++ b/packages/core-backend/src/ws/BackendWebSocketService.test.ts @@ -15,6 +15,7 @@ import { import type { BackendWebSocketServiceOptions, BackendWebSocketServiceMessenger, + ServerNotificationMessage, } from './BackendWebSocketService'; // ===================================================== @@ -2285,6 +2286,52 @@ describe('BackendWebSocketService', () => { }); }); + it('should skip subscription notifications when the matched subscription has no callback', async () => { + await withService(async ({ service, getMockWebSocket }) => { + await service.connect(); + const mockWs = getMockWebSocket(); + const channel = + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890'; + const subscriptionId = 'sub-no-callback'; + const requestId = 'test-no-callback-subscribe'; + + const subscriptionPromise = service.subscribe({ + channels: [channel], + channelType: 'account-activity.v1', + callback: undefined as unknown as ( + notification: ServerNotificationMessage, + ) => void, + requestId, + }); + + mockWs.simulateMessage( + createResponseMessage(requestId, { + subscriptionId, + successful: [channel], + failed: [], + }), + ); + + await subscriptionPromise; + + const channelCallback = jest.fn(); + service.addChannelCallback({ + channelName: channel, + callback: channelCallback, + }); + + mockWs.simulateMessage({ + event: 'notification', + channel, + subscriptionId, + data: { address: '0x1234567890123456789012345678901234567890' }, + timestamp: 1760344704595, + }); + + expect(channelCallback).toHaveBeenCalled(); + }); + }); + it('should handle sendRequest errors when sendMessage fails', async () => { await withService(async ({ service }) => { await service.connect(); diff --git a/packages/core-backend/src/ws/BackendWebSocketService.ts b/packages/core-backend/src/ws/BackendWebSocketService.ts index 5be28d4800..b456a678c7 100644 --- a/packages/core-backend/src/ws/BackendWebSocketService.ts +++ b/packages/core-backend/src/ws/BackendWebSocketService.ts @@ -16,58 +16,6 @@ const SERVICE_NAME = 'BackendWebSocketService' as const; const log = createModuleLogger(projectLogger, SERVICE_NAME); -/** @see packages/assets-controller/src/utils/balanceWsDebug.ts */ -type BalanceWsRouteDebugGlobal = typeof globalThis & { - // eslint-disable-next-line @typescript-eslint/naming-convention -- DevTools toggle - METAMASK_BALANCE_WS_DEBUG?: boolean; -}; - -const LOG_PREFIX = '+++ [Balances][WS]'; - -/** - * @param value - Value to serialize for console output. - * @returns JSON string safe for logging. - */ -function balanceWsSafeStringify(value: unknown): string { - try { - return JSON.stringify( - value, - (_key, nestedValue) => - typeof nestedValue === 'bigint' ? nestedValue.toString() : nestedValue, - 2, - ); - } catch { - return String(value); - } -} - -/** - * Temporary WS routing logging. Filter DevTools with `+++`. - * - * @param step - Routing step label. - * @param payload - Optional structured debug data. - */ -function balanceWsRouteDebug( - step: string, - payload?: Record, -): void { - if (typeof globalThis === 'undefined') { - return; - } - - const debugGlobal = globalThis as BalanceWsRouteDebugGlobal; - if (debugGlobal.METAMASK_BALANCE_WS_DEBUG === false) { - return; - } - - if (payload === undefined) { - console.log(`${LOG_PREFIX} ${step}`); - return; - } - - console.log(`${LOG_PREFIX} ${step}\n${balanceWsSafeStringify(payload)}`); -} - function isAccountActivityChannel(channel: string): boolean { return channel.includes('account-activity'); } @@ -1278,35 +1226,11 @@ export class BackendWebSocketService { * @param message - The WebSocket message to handle */ #handleMessage(message: WebSocketMessage): void { - const notificationMessage = message as Partial; - const { channel, subscriptionId } = notificationMessage; const isServerResponse = this.#isServerResponse(message); const isSubscriptionNotification = this.#isSubscriptionNotification(message); const isChannelMessage = this.#isChannelMessage(message); - if ( - typeof channel === 'string' && - isAccountActivityChannel(channel) && - (isSubscriptionNotification || isChannelMessage) - ) { - balanceWsRouteDebug('wsService:handleMessage', { - channel, - subscriptionId, - isServerResponse, - isSubscriptionNotification, - isChannelMessage, - hasRequestIdInData: - Object.prototype.hasOwnProperty.call(message, 'data') && - message.data !== null && - typeof message.data === 'object' && - Object.prototype.hasOwnProperty.call( - message.data as object, - 'requestId', - ), - }); - } - // Handle server responses (correlated with requests) first if (isServerResponse) { const maybeNotification = message as Partial; @@ -1322,26 +1246,14 @@ export class BackendWebSocketService { // Handle subscription notifications with valid subscriptionId if (isSubscriptionNotification) { const notificationMsg = message as ServerNotificationMessage; - const handled = this.#handleSubscriptionNotification(notificationMsg); - balanceWsRouteDebug('wsService:subscriptionRoute', { - channel: notificationMsg.channel, - subscriptionId: notificationMsg.subscriptionId, - handled, - }); - // If subscription notification wasn't handled (falsy subscriptionId), fall through to channel handling - if (handled) { + if (this.#handleSubscriptionNotification(notificationMsg)) { return; } } // Trigger channel callbacks for any message with a channel property if (isChannelMessage) { - const channelMsg = message; - balanceWsRouteDebug('wsService:channelFallback', { - channel: channelMsg.channel, - subscriptionId: channelMsg.subscriptionId, - }); - this.#handleChannelMessage(channelMsg); + this.#handleChannelMessage(message); } } @@ -1429,10 +1341,6 @@ export class BackendWebSocketService { */ #handleChannelMessage(message: ServerNotificationMessage): void { const callback = this.#resolveChannelCallback(message.channel); - balanceWsRouteDebug('wsService:channelCallback', { - channel: message.channel, - hasCallback: Boolean(callback), - }); callback?.(message); } @@ -1502,29 +1410,15 @@ export class BackendWebSocketService { } if (!subscription) { - balanceWsRouteDebug('wsService:subscriptionMiss', { - channel, - subscriptionId, - }); return false; } const activeSubscription = subscription; if (!activeSubscription.callback) { - balanceWsRouteDebug('wsService:subscriptionNoCallback', { - channel, - subscriptionId, - }); return false; } - balanceWsRouteDebug('wsService:subscriptionHit', { - channel, - subscriptionId, - channelType: activeSubscription.channelType, - }); - // Calculate notification latency: time from server sent to client received const receivedAt = Date.now(); const latency = receivedAt - timestamp; @@ -1547,12 +1441,6 @@ export class BackendWebSocketService { }, }, () => { - balanceWsRouteDebug('wsService:callbackInvoke', { - channel, - subscriptionId, - channelType: activeSubscription.channelType, - data: message.data, - }); activeSubscription.callback?.(message); }, ); From 3dd6a758dd71bb8645b38c71ce3beee1d82a81cf Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 12:42:35 +0200 Subject: [PATCH 17/24] fix: clean up --- .../assets-controller/src/AssetsController.test.ts | 8 +++++--- packages/assets-controller/src/AssetsController.ts | 12 ++++++------ .../src/data-sources/PriceDataSource.ts | 2 +- .../src/data-sources/StakedBalanceDataSource.ts | 4 ++-- .../src/middlewares/ParallelMiddleware.ts | 2 +- packages/assets-controller/src/types.ts | 2 +- .../processAccountActivityBalanceUpdates.test.ts | 2 +- .../utils/processAccountActivityBalanceUpdates.ts | 4 ++-- 8 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 33fbd3541a..631e797bff 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -807,7 +807,7 @@ describe('AssetsController', () => { await flushPromises(); - // Background pipelines use 'merge' mode — they don't wipe existing entries. + // Background pipelines use 'update' mode — they don't wipe existing entries. expect( controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ MOCK_NATIVE_ASSET_ID @@ -1075,6 +1075,7 @@ describe('AssetsController', () => { }, async ({ controller }) => { const emptyApiResponse: DataResponse = { + updateMode: 'merge', assetsInfo: { [MOCK_ASSET_ID]: { type: 'erc20', @@ -1116,6 +1117,7 @@ describe('AssetsController', () => { }, async ({ controller }) => { const apiResponse: DataResponse = { + updateMode: 'merge', assetsInfo: { [MOCK_ASSET_ID]: { type: 'erc20', @@ -1682,7 +1684,7 @@ describe('AssetsController', () => { }); }); - it('preserves existing balances when merge update adds new chain data', async () => { + it('preserves existing balances when update mode adds new chain data', async () => { const polygonNative = 'eip155:137/slip44:966' as Caip19AssetId; const initialState: Partial = { assetsBalance: { @@ -1696,7 +1698,7 @@ describe('AssetsController', () => { await withController({ state: initialState }, async ({ controller }) => { await controller.handleAssetsUpdate( { - updateMode: 'merge', + updateMode: 'update', assetsBalance: { [MOCK_ACCOUNT_ID]: { [polygonNative]: { amount: '10' }, diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 8fceff26a3..e291a75316 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1464,7 +1464,7 @@ export class AssetsController extends BaseController< forceUpdate?: boolean; dataTypes?: DataType[]; assetsForPriceUpdate?: Caip19AssetId[]; - /** When set to 'merge', fetch result is merged with existing state instead of replacing. Use for partial fetches (e.g. newly added chains). */ + /** When set to `'update'`, fetch result overlays existing state without removing tokens. Use for partial fetches (e.g. newly added chains). */ updateMode?: AssetsUpdateMode; }, ): Promise>> { @@ -1787,7 +1787,7 @@ export class AssetsController extends BaseController< balances[accountId][normalizedAssetId] ??= { amount: '0' }; }); - // Fetch data for the newly added custom asset (merge to preserve other chains) + // Fetch data for the newly added custom asset (update to preserve other chains) const account = this.#getSelectedAccounts().find((a) => a.id === accountId); if (account) { const chainId = extractChainId(normalizedAssetId); @@ -1796,7 +1796,7 @@ export class AssetsController extends BaseController< dataTypes: ['balance', 'metadata', 'price'], assetTypes: ['fungible'], forceUpdate: true, - updateMode: 'merge', + updateMode: 'update', }); } @@ -2251,7 +2251,7 @@ export class AssetsController extends BaseController< async #updateState(response: DataResponse): Promise { const normalizedResponse = normalizeResponse(response); - const mode: AssetsUpdateMode = normalizedResponse.updateMode ?? 'merge'; + const mode: AssetsUpdateMode = normalizedResponse.updateMode ?? 'update'; const releaseLock = await this.#controllerMutex.acquire(); @@ -3270,12 +3270,12 @@ export class AssetsController extends BaseController< // Refresh subscriptions for new chain set this.#subscribeAssets(); - // Do one-time fetch for newly enabled chains; merge so we keep existing chain balances + // Do one-time fetch for newly enabled chains; update so we keep existing chain balances if (addedChains.length > 0 && this.#getSelectedAccounts().length > 0) { await this.getAssets(this.#getSelectedAccounts(), { chainIds: addedChains, forceUpdate: true, - updateMode: 'merge', + updateMode: 'update', }); } diff --git a/packages/assets-controller/src/data-sources/PriceDataSource.ts b/packages/assets-controller/src/data-sources/PriceDataSource.ts index 13ac5ecf05..0239979598 100644 --- a/packages/assets-controller/src/data-sources/PriceDataSource.ts +++ b/packages/assets-controller/src/data-sources/PriceDataSource.ts @@ -556,7 +556,7 @@ export class PriceDataSource { ) { await subscription.onAssetsUpdate({ ...fetchResponse, - updateMode: 'merge', + updateMode: 'update', }); } } catch (error) { diff --git a/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts index df747c5495..5a7bba28bd 100644 --- a/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts +++ b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts @@ -445,7 +445,7 @@ export class StakedBalanceDataSource extends AbstractDataSource< const response: DataResponse = { assetsInfo, assetsBalance, - updateMode: 'merge', + updateMode: 'update', }; for (const subscription of this.#activeSubscriptions.values()) { subscription @@ -607,7 +607,7 @@ export class StakedBalanceDataSource extends AbstractDataSource< [assetId]: { amount: result.balance.amount }, }, }, - updateMode: 'merge', + updateMode: 'update', }; const request: DataRequest = { diff --git a/packages/assets-controller/src/middlewares/ParallelMiddleware.ts b/packages/assets-controller/src/middlewares/ParallelMiddleware.ts index bc285346dd..5fa2e4bfa5 100644 --- a/packages/assets-controller/src/middlewares/ParallelMiddleware.ts +++ b/packages/assets-controller/src/middlewares/ParallelMiddleware.ts @@ -74,7 +74,7 @@ export function mergeDataResponses(responses: DataResponse[]): DataResponse { merged.updateMode = 'update'; } } - merged.updateMode ??= 'merge'; + merged.updateMode ??= 'update'; return merged; } diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index f7ce1b91b7..e643354665 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -368,7 +368,7 @@ export type DataResponse = { detectedAssets?: Record; /** * How to apply this response to state. See {@link AssetsUpdateMode}. - * Defaults to `'merge'` if omitted. + * Defaults to `'update'` if omitted. */ updateMode?: AssetsUpdateMode; }; diff --git a/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.test.ts b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.test.ts index 6f4f09e58c..b97a65a72f 100644 --- a/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.test.ts +++ b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.test.ts @@ -26,7 +26,7 @@ describe('processAccountActivityBalanceUpdates', () => { () => 'native', ); - expect(response.updateMode).toBe('merge'); + expect(response.updateMode).toBe('update'); expect(response.assetsBalance?.[accountId]?.[assetId]).toStrictEqual({ amount: '0.00000114526056', }); diff --git a/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts index 16c3844ce3..83f19946ee 100644 --- a/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts +++ b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts @@ -15,7 +15,7 @@ import type { * @param updates - Balance updates from account-activity websocket payload. * @param accountId - Internal account UUID. * @param getAssetType - Resolver for asset metadata type. - * @returns DataResponse with merge mode when balances are present. + * @returns DataResponse with update mode when balances are present. */ export function processAccountActivityBalanceUpdates( updates: BalanceUpdate[], @@ -68,7 +68,7 @@ export function processAccountActivityBalanceUpdates( }; } - const response: DataResponse = { updateMode: 'merge' }; + const response: DataResponse = { updateMode: 'update' }; if (Object.keys(assetsBalance[accountId]).length > 0) { response.assetsBalance = assetsBalance; response.assetsInfo = assetsMetadata; From 9298fa429311c03e7a6d0f8dfeca7bf0a695b549 Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 13:16:55 +0200 Subject: [PATCH 18/24] fix: clean up --- .../assets-controller/src/AssetsController.test.ts | 6 +++--- packages/assets-controller/src/AssetsController.ts | 12 ++++++------ .../src/data-sources/PriceDataSource.ts | 4 +++- .../src/data-sources/StakedBalanceDataSource.ts | 4 ++-- .../src/middlewares/ParallelMiddleware.ts | 2 +- packages/assets-controller/src/types.ts | 2 +- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 631e797bff..325c056af3 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -807,7 +807,7 @@ describe('AssetsController', () => { await flushPromises(); - // Background pipelines use 'update' mode — they don't wipe existing entries. + // Background pipelines overlay balances without wiping fast-pipeline results. expect( controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ MOCK_NATIVE_ASSET_ID @@ -1684,7 +1684,7 @@ describe('AssetsController', () => { }); }); - it('preserves existing balances when update mode adds new chain data', async () => { + it('preserves existing balances when merge update adds new chain data', async () => { const polygonNative = 'eip155:137/slip44:966' as Caip19AssetId; const initialState: Partial = { assetsBalance: { @@ -1698,7 +1698,7 @@ describe('AssetsController', () => { await withController({ state: initialState }, async ({ controller }) => { await controller.handleAssetsUpdate( { - updateMode: 'update', + updateMode: 'merge', assetsBalance: { [MOCK_ACCOUNT_ID]: { [polygonNative]: { amount: '10' }, diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index e291a75316..ff6715d03a 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1464,7 +1464,7 @@ export class AssetsController extends BaseController< forceUpdate?: boolean; dataTypes?: DataType[]; assetsForPriceUpdate?: Caip19AssetId[]; - /** When set to `'update'`, fetch result overlays existing state without removing tokens. Use for partial fetches (e.g. newly added chains). */ + /** When set to `'merge'`, fetch result is merged with existing state instead of replacing. Use for partial fetches (e.g. newly added chains). */ updateMode?: AssetsUpdateMode; }, ): Promise>> { @@ -1787,7 +1787,7 @@ export class AssetsController extends BaseController< balances[accountId][normalizedAssetId] ??= { amount: '0' }; }); - // Fetch data for the newly added custom asset (update to preserve other chains) + // Fetch data for the newly added custom asset (merge to preserve other chains) const account = this.#getSelectedAccounts().find((a) => a.id === accountId); if (account) { const chainId = extractChainId(normalizedAssetId); @@ -1796,7 +1796,7 @@ export class AssetsController extends BaseController< dataTypes: ['balance', 'metadata', 'price'], assetTypes: ['fungible'], forceUpdate: true, - updateMode: 'update', + updateMode: 'merge', }); } @@ -2251,7 +2251,7 @@ export class AssetsController extends BaseController< async #updateState(response: DataResponse): Promise { const normalizedResponse = normalizeResponse(response); - const mode: AssetsUpdateMode = normalizedResponse.updateMode ?? 'update'; + const mode: AssetsUpdateMode = normalizedResponse.updateMode ?? 'merge'; const releaseLock = await this.#controllerMutex.acquire(); @@ -3270,12 +3270,12 @@ export class AssetsController extends BaseController< // Refresh subscriptions for new chain set this.#subscribeAssets(); - // Do one-time fetch for newly enabled chains; update so we keep existing chain balances + // Do one-time fetch for newly enabled chains; merge so we keep existing chain balances if (addedChains.length > 0 && this.#getSelectedAccounts().length > 0) { await this.getAssets(this.#getSelectedAccounts(), { chainIds: addedChains, forceUpdate: true, - updateMode: 'update', + updateMode: 'merge', }); } diff --git a/packages/assets-controller/src/data-sources/PriceDataSource.ts b/packages/assets-controller/src/data-sources/PriceDataSource.ts index 0239979598..ac68008c68 100644 --- a/packages/assets-controller/src/data-sources/PriceDataSource.ts +++ b/packages/assets-controller/src/data-sources/PriceDataSource.ts @@ -556,7 +556,9 @@ export class PriceDataSource { ) { await subscription.onAssetsUpdate({ ...fetchResponse, - updateMode: 'update', + // merge overwrites existing spot prices on each poll; update would + // seed-only and leave the first price forever. + updateMode: 'merge', }); } } catch (error) { diff --git a/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts index 5a7bba28bd..df747c5495 100644 --- a/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts +++ b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts @@ -445,7 +445,7 @@ export class StakedBalanceDataSource extends AbstractDataSource< const response: DataResponse = { assetsInfo, assetsBalance, - updateMode: 'update', + updateMode: 'merge', }; for (const subscription of this.#activeSubscriptions.values()) { subscription @@ -607,7 +607,7 @@ export class StakedBalanceDataSource extends AbstractDataSource< [assetId]: { amount: result.balance.amount }, }, }, - updateMode: 'update', + updateMode: 'merge', }; const request: DataRequest = { diff --git a/packages/assets-controller/src/middlewares/ParallelMiddleware.ts b/packages/assets-controller/src/middlewares/ParallelMiddleware.ts index 5fa2e4bfa5..bc285346dd 100644 --- a/packages/assets-controller/src/middlewares/ParallelMiddleware.ts +++ b/packages/assets-controller/src/middlewares/ParallelMiddleware.ts @@ -74,7 +74,7 @@ export function mergeDataResponses(responses: DataResponse[]): DataResponse { merged.updateMode = 'update'; } } - merged.updateMode ??= 'update'; + merged.updateMode ??= 'merge'; return merged; } diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index e643354665..f7ce1b91b7 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -368,7 +368,7 @@ export type DataResponse = { detectedAssets?: Record; /** * How to apply this response to state. See {@link AssetsUpdateMode}. - * Defaults to `'update'` if omitted. + * Defaults to `'merge'` if omitted. */ updateMode?: AssetsUpdateMode; }; From 9c3cb7a6e41d147881a6760bb574b027705f1f80 Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 16:53:06 +0200 Subject: [PATCH 19/24] fix(assets-controller): refresh balances after transaction confirmed Force-refresh the sender account via getAssets when a transaction confirms, and harden RpcDataSource post-tx RPC refresh to pass the request and apply update mode when pushing balance snapshots to the controller. --- .../src/AssetsController.test.ts | 25 +++++++++ .../assets-controller/src/AssetsController.ts | 37 ++++++++++++++ .../src/data-sources/RpcDataSource.test.ts | 48 ++++++++++++----- .../src/data-sources/RpcDataSource.ts | 51 ++++++++++++++----- 4 files changed, 135 insertions(+), 26 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 325c056af3..5fbe52e861 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -2061,6 +2061,31 @@ describe('AssetsController', () => { }); describe('events', () => { + it('force refreshes assets when transaction is confirmed', async () => { + await withController(async ({ controller, messenger }) => { + const getAssetsSpy = jest + .spyOn(controller, 'getAssets') + .mockResolvedValue({}); + + messenger.publish('TransactionController:transactionConfirmed', { + chainId: '0xa4b1', + txParams: { from: '0x1234567890123456789012345678901234567890' }, + }); + + await flushPromises(); + + expect(getAssetsSpy).toHaveBeenCalledWith( + [expect.objectContaining({ id: MOCK_ACCOUNT_ID })], + { + chainIds: ['eip155:42161'], + forceUpdate: true, + }, + ); + + getAssetsSpy.mockRestore(); + }); + }); + it('publishes balanceChanged event when balance updates', async () => { await withController(async ({ controller, messenger }) => { const balanceChangedHandler = jest.fn(); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index ff6715d03a..26f3005b22 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1095,6 +1095,16 @@ export class AssetsController extends BaseController< }, ); + // Post-tx refresh via the full fetch pipeline (Accounts API + RPC fallback). + // RpcDataSource also listens for transactionConfirmed, but only refreshes + // chains it owns via an active subscription. + this.messenger.subscribe( + 'TransactionController:transactionConfirmed', + (transactionMeta: TransactionMeta) => { + this.#onTransactionConfirmed(transactionMeta); + }, + ); + // Real-time post-tx balances from AccountActivityService (same WS payload as // TokenBalancesController; BackendWebsocketDataSource may not receive the // callback when AccountActivityService owns the server subscription). @@ -1135,6 +1145,33 @@ export class AssetsController extends BaseController< }); } + #onTransactionConfirmed(transactionMeta: TransactionMeta): void { + const hexChainId = transactionMeta.chainId; + if (!hexChainId) { + return; + } + + const caipChainId = `eip155:${parseInt(hexChainId, 16)}` as ChainId; + const fromAddress = transactionMeta.txParams.from?.toLowerCase(); + if (!fromAddress) { + return; + } + + const matchedAccount = this.#getSelectedAccounts().find( + (account) => account.address.toLowerCase() === fromAddress, + ); + if (!matchedAccount) { + return; + } + + this.getAssets([matchedAccount], { + chainIds: [caipChainId], + forceUpdate: true, + }).catch((error) => { + log('Failed to refresh assets after transaction confirmed', { error }); + }); + } + #onAccountActivityBalanceUpdated({ address, chain, diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index ade8c0068d..1c60892bf8 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -1894,21 +1894,43 @@ describe('RpcDataSource', () => { }); describe('transaction events', () => { - it('refreshes balance when transaction confirmed', async () => { - await withController(async ({ controller, rootMessenger }) => { - await controller.subscribe({ - request: createDataRequest(), - subscriptionId: 'test-sub', - isUpdate: false, - onAssetsUpdate: jest.fn(), + it('refreshes balance with update mode when transaction confirmed', async () => { + const onAssetsUpdate = jest.fn().mockResolvedValue(undefined); + const fetchSpy = jest + .spyOn(RpcDataSource.prototype, 'fetch') + .mockResolvedValue({ + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + 'eip155:1/slip44:60': { amount: '2' }, + }, + }, + updateMode: 'update', }); - rootMessenger.publish('TransactionController:transactionConfirmed', { - chainId: MOCK_CHAIN_ID_HEX, - } as unknown as TransactionMeta); - await new Promise(process.nextTick); - expect(controller).toBeDefined(); - }); + try { + await withController(async ({ controller, rootMessenger }) => { + await controller.subscribe({ + request: createDataRequest(), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate, + }); + + rootMessenger.publish('TransactionController:transactionConfirmed', { + chainId: MOCK_CHAIN_ID_HEX, + txParams: { from: MOCK_ADDRESS }, + } as unknown as TransactionMeta); + await new Promise(process.nextTick); + + expect(fetchSpy).toHaveBeenCalled(); + expect(onAssetsUpdate).toHaveBeenCalledWith( + expect.objectContaining({ updateMode: 'update' }), + expect.objectContaining({ dataTypes: ['balance'] }), + ); + }); + } finally { + fetchSpy.mockRestore(); + } }); it('does not refresh when transaction confirmed has no chainId', async () => { diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index d09f4f4d37..b35c3d4a06 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -647,9 +647,11 @@ export class RpcDataSource extends AbstractDataSource< return; } const caipChainId = `eip155:${parseInt(hexChainId, 16)}` as ChainId; - this.#refreshBalanceForChains([caipChainId]).catch((error) => { - log('Failed to refresh balance after transaction confirmed', { error }); - }); + this.#refreshBalanceForChains([caipChainId], 'transactionConfirmed').catch( + (error) => { + log('Failed to refresh balance after transaction confirmed', { error }); + }, + ); } /** @@ -657,16 +659,23 @@ export class RpcDataSource extends AbstractDataSource< * push updates to the controller. * * @param chainIds - CAIP-2 chain IDs to refresh. + * @param context - Why the refresh was triggered (for logging). */ - async #refreshBalanceForChains(chainIds: ChainId[]): Promise { + async #refreshBalanceForChains( + chainIds: ChainId[], + context: 'transactionConfirmed' | 'polling' = 'polling', + ): Promise { const chainIdsSet = new Set(chainIds); const chainsToFetch = chainIds.filter((chainId) => this.#activeChains.includes(chainId), ); + if (chainsToFetch.length === 0) { return; } + let appliedCount = 0; + for (const subscription of this.#activeSubscriptions.values()) { const subscriptionChains = subscription.chains.filter((chainId) => chainIdsSet.has(chainId), @@ -686,23 +695,39 @@ export class RpcDataSource extends AbstractDataSource< try { const response = await this.fetch(request); - if ( - response.assetsBalance && - Object.keys(response.assetsBalance).length > 0 - ) { - subscription.onAssetsUpdate(response)?.catch((error) => { - log('Failed to report balance update after transaction', { - error, - }); - }); + const balanceCount = response.assetsBalance + ? Object.values(response.assetsBalance).reduce( + (sum, accountBalances) => + sum + Object.keys(accountBalances).length, + 0, + ) + : 0; + + if (balanceCount === 0) { + continue; } + + const responseWithMode: DataResponse = { + ...response, + updateMode: response.updateMode ?? 'update', + }; + + await subscription.onAssetsUpdate(responseWithMode, request); + appliedCount += 1; } catch (error) { log('Failed to fetch balance after transaction', { + context, chains: subscriptionChains, error, }); } } + + if (appliedCount === 0 && context === 'transactionConfirmed') { + log('No RpcDataSource subscription covers chain after transaction', { + chainsToFetch, + }); + } } #initializeFromNetworkController(): void { From adcf89be59f765f896ee4b0bf68c8c1a6e517cec Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 26 Jun 2026 20:09:46 +0200 Subject: [PATCH 20/24] fix(assets-controller): refresh assets and activeChains on EVM network switch Subscribe to NetworkController:networkDidChange so switching the selected network re-fetches supported chains, re-subscribes data sources, and force-fetches balances for the newly selected chain. --- packages/assets-controller/CHANGELOG.md | 1 + .../src/AssetsController.test.ts | 119 ++++++++++++++++++ .../assets-controller/src/AssetsController.ts | 94 ++++++++++++++ .../AccountsApiDataSource.test.ts | 27 ++++ .../src/data-sources/AccountsApiDataSource.ts | 9 ++ .../BackendWebsocketDataSource.ts | 9 ++ .../src/data-sources/RpcDataSource.ts | 8 ++ 7 files changed, 267 insertions(+) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 02d93f996f..90dd013439 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove the 120-second websocket balance freshness guard that blocked force-refresh and polling updates from correcting stale websocket balances ([#9273](https://github.com/MetaMask/core/pull/9273)) - Add `update` balance update mode so fetch and force-refresh pipelines patch balances without removing tokens omitted from partial API snapshots; `getAssets({ forceUpdate: true })`, `AccountsApiDataSource`, and `SnapDataSource` use this mode ([#9273](https://github.com/MetaMask/core/pull/9273)) - `BackendWebsocketDataSource` registers subscription handlers before the subscribe handshake so in-flight account-activity notifications are not dropped, cleans up subscription state on subscribe failure, and resolves balance updates from stored subscription state when notifications arrive with stale subscription IDs ([#9273](https://github.com/MetaMask/core/pull/9273)) +- `AssetsController` refreshes data-source `activeChains`, re-subscribes, and force-fetches balances for the newly selected EVM chain when the user switches networks via `NetworkController:networkDidChange` ## [9.1.0] diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 5fbe52e861..dca9bc1689 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -8,6 +8,7 @@ import type { MessengerActions, MessengerEvents, } from '@metamask/messenger'; +import type { NetworkState } from '@metamask/network-controller'; import { AssetsController, @@ -2407,6 +2408,124 @@ describe('AssetsController', () => { expect(true).toBe(true); }); }); + + it('subscribes and fetches assets when the selected EVM network switches', async () => { + const sepoliaHex = '0xaa36a7'; + const mainnetHex = '0x1'; + let selectedNetworkClientId = 'sepolia'; + + const getNetworkState = (): NetworkState => ({ + networkConfigurationsByChainId: { + [sepoliaHex]: { chainId: sepoliaHex }, + [mainnetHex]: { chainId: mainnetHex }, + } as NetworkState['networkConfigurationsByChainId'], + networksMetadata: {} as NetworkState['networksMetadata'], + selectedNetworkClientId, + }); + + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + messenger.registerActionHandler( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + () => [createMockInternalAccount()], + ); + messenger.registerActionHandler( + 'NetworkEnablementController:getState', + () => ({ + enabledNetworkMap: { eip155: { '1': true, '11155111': true } }, + nativeAssetIdentifiers: { + 'eip155:1': MOCK_NATIVE_ASSET_ID, + 'eip155:11155111': 'eip155:11155111/slip44:60' as Caip19AssetId, + }, + }), + ); + messenger.registerActionHandler( + 'NetworkController:getState', + getNetworkState, + ); + ( + messenger as { + registerActionHandler: (a: string, h: (id: string) => unknown) => void; + } + ).registerActionHandler( + 'NetworkController:getNetworkClientById', + (networkClientId: string) => ({ + provider: {}, + configuration: { + chainId: networkClientId === 'mainnet' ? mainnetHex : sepoliaHex, + }, + }), + ); + ( + messenger as { + registerActionHandler: (a: string, h: () => unknown) => void; + } + ).registerActionHandler('ClientController:getState', () => ({ + isUiOpen: true, + })); + + const fetchV2SupportedNetworks = jest.fn().mockResolvedValue({ + fullSupport: [1, 11155111], + partialSupport: [], + }); + + const queryApiClient = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks, + fetchV5MultiAccountBalances: jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + }), + }, + } as unknown as ApiPlatformClient; + + const controller = new AssetsController({ + messenger: messenger as unknown as AssetsControllerMessenger, + queryApiClient, + subscribeToBasicFunctionalityChange: (): void => { + /* no-op */ + }, + }); + + const getAssetsSpy = jest.spyOn(controller, 'getAssets'); + + try { + ( + messenger as unknown as { + publish: (topic: string, payload?: unknown) => void; + } + ).publish('ClientController:stateChanged', { isUiOpen: true }); + messenger.publish('KeyringController:unlock'); + await flushPromises(); + + getAssetsSpy.mockClear(); + fetchV2SupportedNetworks.mockClear(); + + selectedNetworkClientId = 'mainnet'; + (messenger.publish as CallableFunction)( + 'NetworkController:networkDidChange', + getNetworkState(), + ); + + await flushPromises(); + + expect(fetchV2SupportedNetworks).toHaveBeenCalled(); + expect(getAssetsSpy).toHaveBeenCalledWith( + [expect.objectContaining({ id: MOCK_ACCOUNT_ID })], + expect.objectContaining({ + chainIds: ['eip155:1'], + forceUpdate: true, + }), + ); + } finally { + getAssetsSpy.mockRestore(); + await flushPromises(); + controller.destroy(); + } + }); }); describe('account group changes', () => { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 26f3005b22..72b42af62d 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -32,7 +32,9 @@ import type { NetworkControllerGetNetworkClientByIdAction, NetworkControllerGetStateAction, NetworkControllerNetworkAddedEvent, + NetworkControllerNetworkDidChangeEvent, NetworkControllerNetworkRemovedEvent, + NetworkState, NetworkControllerStateChangeEvent, } from '@metamask/network-controller'; import type { @@ -350,6 +352,7 @@ type AllowedEvents = // whenever a network configuration is added to or removed from // NetworkController) | NetworkControllerNetworkAddedEvent + | NetworkControllerNetworkDidChangeEvent | NetworkControllerNetworkRemovedEvent // StakedBalanceDataSource | NetworkEnablementControllerEvents @@ -1068,6 +1071,15 @@ export class AssetsController extends BaseController< this.#refreshAssetsAfterNetworkChange(); }); + // Selected EVM network switch (network picker). Enablement changes are + // handled separately via NetworkEnablementController:stateChanged. + this.messenger.subscribe( + 'NetworkController:networkDidChange', + (networkState) => { + this.#handleNetworkDidChange(networkState).catch(console.error); + }, + ); + // Client + Keyring lifecycle: only run when UI is open AND keyring is unlocked this.messenger.subscribe( 'ClientController:stateChanged', @@ -3356,6 +3368,88 @@ export class AssetsController extends BaseController< this.#ensureDefaultTrackedAssetsSeeded([caipChainId]); } + /** + * Refresh data-source `activeChains` after an EVM network switch so API/WS/Rpc + * chain claiming is not stuck on an empty or stale init-time list. + */ + async #refreshActiveChainsOnNetworkSwitch(): Promise { + await Promise.all([ + this.#accountsApiDataSource.refreshActiveChains(), + this.#backendWebsocketDataSource.refreshActiveChains(), + ]); + this.#rpcDataSource.refreshActiveChainsFromNetworkState(); + } + + /** + * Resolve the CAIP-2 chain ID for the currently selected EVM network client. + * + * @param networkState - NetworkController state from `networkDidChange`. + * @returns CAIP-2 chain ID (e.g. `eip155:1`) or undefined when unavailable. + */ + #getSelectedEvmChainIdFromNetworkState( + networkState: NetworkState, + ): ChainId | undefined { + try { + const networkClient = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkState.selectedNetworkClientId, + ); + const hexChainId = networkClient.configuration?.chainId; + if (!hexChainId) { + return undefined; + } + return `eip155:${parseInt(hexChainId, 16)}` as ChainId; + } catch { + return undefined; + } + } + + /** + * Refresh subscriptions and fetch balances when the user switches the + * selected EVM network in the UI (`NetworkController:networkDidChange`). + * + * @param networkState - NetworkController state after the switch. + */ + async #handleNetworkDidChange( + networkState: NetworkState, + ): Promise { + if (!this.#uiOpen || !this.#keyringUnlocked || !this.#isEnabled()) { + return; + } + + const accounts = this.#getSelectedAccounts(); + if (accounts.length === 0) { + return; + } + + const selectedChainId = + this.#getSelectedEvmChainIdFromNetworkState(networkState); + if (!selectedChainId) { + return; + } + + log('Selected EVM network switched', { + selectedNetworkClientId: networkState.selectedNetworkClientId, + selectedChainId, + }); + + const releaseLock = await this.#accountRefreshMutex.acquire(); + try { + await this.#refreshActiveChainsOnNetworkSwitch(); + + this.#subscribeAssets(); + + await this.getAssets(accounts, { + chainIds: [selectedChainId], + forceUpdate: true, + }); + + this.#ensureNativeBalancesDefaultZero(); + } finally { + releaseLock(); + } + } + /** * Refresh assets across every data source after a network configuration * is added to or removed from NetworkController. Mirrors the diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index d964b6a744..0ec368de0a 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -232,6 +232,33 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + it('refreshActiveChains re-fetches supported networks and updates activeChains', async () => { + const { controller, apiClient, activeChainsUpdateHandler } = + await setupController({ supportedChains: [1] }); + + activeChainsUpdateHandler.mockClear(); + apiClient.accounts.fetchV2SupportedNetworks.mockClear(); + apiClient.accounts.fetchV2SupportedNetworks.mockResolvedValue({ + fullSupport: [1, 137], + partialSupport: [], + }); + + await controller.refreshActiveChains(); + + expect(apiClient.accounts.fetchV2SupportedNetworks).toHaveBeenCalledTimes(1); + expect(activeChainsUpdateHandler).toHaveBeenCalledWith( + 'AccountsApiDataSource', + [CHAIN_MAINNET, CHAIN_POLYGON], + [CHAIN_MAINNET], + ); + expect(controller.getActiveChainsSync()).toStrictEqual([ + CHAIN_MAINNET, + CHAIN_POLYGON, + ]); + + controller.destroy(); + }); + it('exposes assetsMiddleware and getActiveChains on instance', async () => { const { controller } = await setupController(); diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 23d07389fe..b72dc7d1c4 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -264,6 +264,15 @@ export class AccountsApiDataSource extends AbstractDataSource< } } + /** + * Re-fetch supported networks from the Accounts API and update `activeChains` + * when the list changed. Used when the selected EVM network switches so + * chain claiming is not stuck on an empty init-time list. + */ + refreshActiveChains(): Promise { + return this.#refreshActiveChains(); + } + async #fetchActiveChains(): Promise { const response = await this.#apiClient.accounts.fetchV2SupportedNetworks(); diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts index d63fa16fcd..e730cff8d1 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts @@ -358,6 +358,15 @@ export class BackendWebsocketDataSource extends AbstractDataSource< } } + /** + * Re-fetch supported networks and refresh `activeChains` when connected. + * When disconnected, only `#supportedChains` is updated so reconnect can + * reclaim chains. Called on EVM network switch from AssetsController. + */ + refreshActiveChains(): Promise { + return this.#refreshActiveChains(); + } + async #fetchActiveChains(): Promise { const response = await this.#apiClient.accounts.fetchV2SupportedNetworks(); return response.fullSupport.map(toChainId); diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index b35c3d4a06..9eb7133c41 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -740,6 +740,14 @@ export class RpcDataSource extends AbstractDataSource< } } + /** + * Re-read NetworkController state and refresh Rpc `activeChains` (e.g. when + * network availability metadata changes after an EVM network switch). + */ + refreshActiveChainsFromNetworkState(): void { + this.#initializeFromNetworkController(); + } + #updateFromNetworkState(networkState: NetworkState): void { const { networkConfigurationsByChainId, networksMetadata } = networkState; From 4c62dd9cc1f074eded57f5c1d0c30ac95f748bd9 Mon Sep 17 00:00:00 2001 From: salimtb Date: Sat, 27 Jun 2026 01:37:41 +0200 Subject: [PATCH 21/24] fix: fix and clean --- packages/assets-controller/src/AssetsController.test.ts | 5 ++++- packages/assets-controller/src/AssetsController.ts | 4 +--- .../src/data-sources/AccountsApiDataSource.test.ts | 6 ++++-- .../src/data-sources/AccountsApiDataSource.ts | 2 ++ .../src/data-sources/BackendWebsocketDataSource.ts | 2 ++ 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index dca9bc1689..9feef3e5cf 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -2447,7 +2447,10 @@ describe('AssetsController', () => { ); ( messenger as { - registerActionHandler: (a: string, h: (id: string) => unknown) => void; + registerActionHandler: ( + a: string, + h: (id: string) => unknown, + ) => void; } ).registerActionHandler( 'NetworkController:getNetworkClientById', diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 72b42af62d..406a4f8797 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -3410,9 +3410,7 @@ export class AssetsController extends BaseController< * * @param networkState - NetworkController state after the switch. */ - async #handleNetworkDidChange( - networkState: NetworkState, - ): Promise { + async #handleNetworkDidChange(networkState: NetworkState): Promise { if (!this.#uiOpen || !this.#keyringUnlocked || !this.#isEnabled()) { return; } diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index 0ec368de0a..645b0a44de 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -245,13 +245,15 @@ describe('AccountsApiDataSource', () => { await controller.refreshActiveChains(); - expect(apiClient.accounts.fetchV2SupportedNetworks).toHaveBeenCalledTimes(1); + expect(apiClient.accounts.fetchV2SupportedNetworks).toHaveBeenCalledTimes( + 1, + ); expect(activeChainsUpdateHandler).toHaveBeenCalledWith( 'AccountsApiDataSource', [CHAIN_MAINNET, CHAIN_POLYGON], [CHAIN_MAINNET], ); - expect(controller.getActiveChainsSync()).toStrictEqual([ + expect(await controller.getActiveChains()).toStrictEqual([ CHAIN_MAINNET, CHAIN_POLYGON, ]); diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index b72dc7d1c4..11ff6aa1a1 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -268,6 +268,8 @@ export class AccountsApiDataSource extends AbstractDataSource< * Re-fetch supported networks from the Accounts API and update `activeChains` * when the list changed. Used when the selected EVM network switches so * chain claiming is not stuck on an empty init-time list. + * + * @returns Resolves when supported networks have been re-fetched. */ refreshActiveChains(): Promise { return this.#refreshActiveChains(); diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts index e730cff8d1..b0349b297b 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts @@ -362,6 +362,8 @@ export class BackendWebsocketDataSource extends AbstractDataSource< * Re-fetch supported networks and refresh `activeChains` when connected. * When disconnected, only `#supportedChains` is updated so reconnect can * reclaim chains. Called on EVM network switch from AssetsController. + * + * @returns Resolves when supported networks have been re-fetched. */ refreshActiveChains(): Promise { return this.#refreshActiveChains(); From 53326764b0396e6a4045ac342a6850a1cbbf927c Mon Sep 17 00:00:00 2001 From: salimtb Date: Sat, 27 Jun 2026 01:47:29 +0200 Subject: [PATCH 22/24] fix: reduce complexity --- packages/assets-controller/CHANGELOG.md | 1 - .../src/AssetsController.test.ts | 77 +---------- .../assets-controller/src/AssetsController.ts | 120 +++++------------- .../src/data-sources/AccountsApiDataSource.ts | 2 +- .../src/data-sources/RpcDataSource.test.ts | 6 +- .../src/data-sources/RpcDataSource.ts | 8 +- .../src/data-sources/SnapDataSource.test.ts | 4 +- .../src/data-sources/SnapDataSource.ts | 6 +- .../src/middlewares/ParallelMiddleware.ts | 6 - packages/assets-controller/src/types.ts | 12 +- ...ocessAccountActivityBalanceUpdates.test.ts | 2 +- .../processAccountActivityBalanceUpdates.ts | 4 +- 12 files changed, 60 insertions(+), 188 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 90dd013439..1cc82a7a69 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -18,7 +18,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AccountsApiDataSource` bypasses the TanStack Query balance cache when `forceUpdate` is true so forced refreshes return up-to-date balances instead of 60-second cached values ([#9265](https://github.com/MetaMask/core/pull/9265)) - `BackendWebsocketDataSource` re-subscribes when subscribed accounts change (case-insensitive EVM address matching), serializes subscribe/unsubscribe to prevent races on account switch, and registers channel callbacks as a fallback when server `subscriptionId` values do not match ([#9265](https://github.com/MetaMask/core/pull/9265)) - Remove the 120-second websocket balance freshness guard that blocked force-refresh and polling updates from correcting stale websocket balances ([#9273](https://github.com/MetaMask/core/pull/9273)) -- Add `update` balance update mode so fetch and force-refresh pipelines patch balances without removing tokens omitted from partial API snapshots; `getAssets({ forceUpdate: true })`, `AccountsApiDataSource`, and `SnapDataSource` use this mode ([#9273](https://github.com/MetaMask/core/pull/9273)) - `BackendWebsocketDataSource` registers subscription handlers before the subscribe handshake so in-flight account-activity notifications are not dropped, cleans up subscription state on subscribe failure, and resolves balance updates from stored subscription state when notifications arrive with stale subscription IDs ([#9273](https://github.com/MetaMask/core/pull/9273)) - `AssetsController` refreshes data-source `activeChains`, re-subscribes, and force-fetches balances for the newly selected EVM chain when the user switches networks via `NetworkController:networkDidChange` diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 9feef3e5cf..0a1a98685e 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1758,7 +1758,7 @@ describe('AssetsController', () => { }); }); - it('overlays balances without removing tokens when update mode is used', async () => { + it('overlays balances without removing tokens when merge mode is used', async () => { const initialState: Partial = { assetsBalance: { [MOCK_ACCOUNT_ID]: { @@ -1766,33 +1766,17 @@ describe('AssetsController', () => { [MOCK_NATIVE_ASSET_ID]: { amount: '0.000390285791392' }, }, }, - assetsInfo: { - [MOCK_ASSET_ID]: { - type: 'erc20', - symbol: 'USDC', - name: 'USD Coin', - decimals: 6, - }, - }, }; await withController({ state: initialState }, async ({ controller }) => { await controller.handleAssetsUpdate( { - updateMode: 'update', + updateMode: 'merge', assetsBalance: { [MOCK_ACCOUNT_ID]: { [MOCK_NATIVE_ASSET_ID]: { amount: '0.000389261286724' }, }, }, - assetsInfo: { - [MOCK_ASSET_ID]: { - type: 'erc20', - symbol: 'REPLACED', - name: 'Replaced', - decimals: 18, - }, - }, }, 'TestSource', ); @@ -1805,17 +1789,16 @@ describe('AssetsController', () => { MOCK_NATIVE_ASSET_ID ], ).toStrictEqual({ amount: '0.000389261286724' }); - expect(controller.state.assetsInfo[MOCK_ASSET_ID]?.symbol).toBe('USDC'); }); }); - it('seeds missing metadata in update mode for RPC-only chains', async () => { + it('seeds missing metadata in merge mode for RPC-only chains', async () => { const avaxNative = 'eip155:43114/slip44:9005' as Caip19AssetId; await withController({ state: {} }, async ({ controller }) => { await controller.handleAssetsUpdate( { - updateMode: 'update', + updateMode: 'merge', assetsBalance: { [MOCK_ACCOUNT_ID]: { [avaxNative]: { amount: '1.5' }, @@ -1840,7 +1823,7 @@ describe('AssetsController', () => { }); }); - it('updates balance amounts present in update mode response', async () => { + it('updates balance amounts present in merge mode response', async () => { const initialState: Partial = { assetsBalance: { [MOCK_ACCOUNT_ID]: { @@ -1852,7 +1835,7 @@ describe('AssetsController', () => { await withController({ state: initialState }, async ({ controller }) => { await controller.handleAssetsUpdate( { - updateMode: 'update', + updateMode: 'merge', assetsBalance: { [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '2' }, @@ -1907,54 +1890,6 @@ describe('AssetsController', () => { ); }); - it('zeros stale native in update mode when the chain is covered but native is omitted', async () => { - const initialState: Partial = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_NATIVE_ASSET_ID]: { amount: '1.5' }, - [MOCK_ASSET_ID]: { amount: '10' }, - }, - }, - assetsInfo: { - [MOCK_NATIVE_ASSET_ID]: { - type: 'native', - symbol: 'ETH', - name: 'Ether', - decimals: 18, - }, - [MOCK_ASSET_ID]: { - type: 'erc20', - symbol: 'USDC', - name: 'USD Coin', - decimals: 6, - }, - }, - }; - - await withController({ state: initialState }, async ({ controller }) => { - await controller.handleAssetsUpdate( - { - updateMode: 'update', - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_ID]: { amount: '0' }, - }, - }, - }, - 'AccountsApiDataSource', - ); - - expect( - controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], - ).toStrictEqual({ amount: '0' }); - expect( - controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ - MOCK_NATIVE_ASSET_ID - ], - ).toStrictEqual({ amount: '0' }); - }); - }); - it('updates state with price data', async () => { await withController(async ({ controller }) => { await controller.handleAssetsUpdate( diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 406a4f8797..5e2f8e7cd0 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1546,7 +1546,7 @@ export class AssetsController extends BaseController< // creation, RPC is slow on many chains). Results are committed to state // immediately so the UI can display balances without waiting for them. // - // Fast/slow pipelines use 'update' so partial API snapshots cannot wipe + // Fast/slow pipelines use merge so partial API snapshots cannot wipe // tokens missing from the response (e.g. USDC when only native balance // is returned). Balances present in the response are still refreshed. const fastSources = this.#isBasicFunctionality() @@ -1571,7 +1571,7 @@ export class AssetsController extends BaseController< fastSources, request, ); - await this.#updateState({ ...response, updateMode: 'update' }); + await this.#updateState({ ...response, updateMode: 'merge' }); // Background pipeline: snap and RPC run in parallel after the fast path // commits to state. Their balances are merged together before detection. @@ -1605,7 +1605,7 @@ export class AssetsController extends BaseController< slowRequest, ) .then(({ response: slowResponse }) => - this.#updateState({ ...slowResponse, updateMode: 'update' }), + this.#updateState({ ...slowResponse, updateMode: 'merge' }), ) .catch((error) => log('Background pipeline failed', { error })); } @@ -2330,51 +2330,34 @@ export class AssetsController extends BaseController< const prices = state.assetsPrice as Record; if (normalizedResponse.assetsInfo) { - if (mode === 'update') { - // Balance-only refresh: patch amounts without overwriting metadata, - // but seed entries that are missing so RPC-only chains (e.g. - // Avalanche) can render native balances on first fetch. - for (const [key, value] of Object.entries( - normalizedResponse.assetsInfo, - )) { - if (metadata[key]) { - continue; - } - metadata[key] = value; + for (const [key, value] of Object.entries( + normalizedResponse.assetsInfo, + )) { + if ( + !isEqual(previousState.assetsInfo[key as Caip19AssetId], value) + ) { changedMetadata.push(key); } - } else { - for (const [key, value] of Object.entries( - normalizedResponse.assetsInfo, - )) { - if ( - !isEqual(previousState.assetsInfo[key as Caip19AssetId], value) - ) { - changedMetadata.push(key); - } - const existing = metadata[key] as - | FungibleAssetMetadata - | undefined; - const incoming = value as FungibleAssetMetadata; - - // When the API returns no symbol or name for this token, it has no - // real knowledge of it (e.g. a user-deployed token not indexed by - // the API). Preserve richer metadata already in state (e.g. from - // pendingMetadata set by addCustomAsset) so that the correct - // decimals/symbol/name/image are not overwritten with empty values. - if (existing && !incoming.symbol && !incoming.name) { - metadata[key] = { - ...existing, - ...incoming, - symbol: existing.symbol, - name: existing.name, - decimals: existing.decimals ?? incoming.decimals, - image: existing.image ?? incoming.image, - }; - } else { - metadata[key] = value; - } + const existing = metadata[key] as FungibleAssetMetadata | undefined; + const incoming = value as FungibleAssetMetadata; + + // When the API returns no symbol or name for this token, it has no + // real knowledge of it (e.g. a user-deployed token not indexed by + // the API). Preserve richer metadata already in state (e.g. from + // pendingMetadata set by addCustomAsset) so that the correct + // decimals/symbol/name/image are not overwritten with empty values. + if (existing && !incoming.symbol && !incoming.name) { + metadata[key] = { + ...existing, + ...incoming, + symbol: existing.symbol, + name: existing.name, + decimals: existing.decimals ?? incoming.decimals, + image: existing.image ?? incoming.image, + }; + } else { + metadata[key] = value; } } } @@ -2416,10 +2399,9 @@ export class AssetsController extends BaseController< // balances for chains not in the response are preserved from // previous state so unsupported chains (e.g. Ink on AccountsAPI) // are never inadvertently reset to zero. - // Merge / update: response overlays previous balances (update never - // removes tokens missing from a partial snapshot). + // Merge: response overlays previous balances. const effective: Record = - mode === 'merge' || mode === 'update' + mode === 'merge' ? { ...previousBalances, ...accountBalances } : ((): Record => { // Determine which chain namespaces this response covers. @@ -2456,14 +2438,6 @@ export class AssetsController extends BaseController< return next; })(); - // Chains present in this partial response (e.g. Accounts API returned - // ERC-20 zeros but omitted native after a max send). - const coveredChainsInResponse = new Set( - Object.keys(accountBalances).map( - (assetId) => assetId.split('/')[0], - ), - ); - // Ensure native tokens have an entry (0 if missing) for chains this account supports const account = this.#getSelectedAccounts().find( (a) => a.id === accountId, @@ -2472,23 +2446,10 @@ export class AssetsController extends BaseController< ? this.#getNativeAssetIdsForAccount(account) : this.#getNativeAssetIdsForEnabledChains(); for (const nativeAssetId of nativeAssetIdsForAccount) { - const nativeChain = nativeAssetId.split('/')[0]; if ( !Object.prototype.hasOwnProperty.call(effective, nativeAssetId) ) { effective[nativeAssetId] = { amount: '0' } as AssetBalance; - } else if ( - mode === 'update' && - coveredChainsInResponse.has(nativeChain) && - !Object.prototype.hasOwnProperty.call( - accountBalances, - nativeAssetId, - ) - ) { - // Update-mode overlay keeps previous native when the response - // omits it. If the API returned any balance on this chain, - // treat omitted native as zero instead of a stale pre-tx amount. - effective[nativeAssetId] = { amount: '0' } as AssetBalance; } } @@ -2529,24 +2490,11 @@ export class AssetsController extends BaseController< } } - // Update prices in state. Update mode only seeds missing entries so - // balance-only refreshes do not clobber existing price data. if (normalizedResponse.assetsPrice) { - if (mode === 'update') { - for (const [key, value] of Object.entries( - normalizedResponse.assetsPrice, - )) { - if (prices[key]) { - continue; - } - prices[key] = value; - } - } else { - for (const [key, value] of Object.entries( - normalizedResponse.assetsPrice, - )) { - prices[key] = value; - } + for (const [key, value] of Object.entries( + normalizedResponse.assetsPrice, + )) { + prices[key] = value; } } }); diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 11ff6aa1a1..74f5d94be1 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -362,7 +362,7 @@ export class AccountsApiDataSource extends AbstractDataSource< ); response.assetsBalance = assetsBalance; - response.updateMode = 'update'; + response.updateMode = 'merge'; } catch (error) { log('Fetch FAILED', { error, chains: chainsToFetch }); diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index 1c60892bf8..c2ff9044d1 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -1894,7 +1894,7 @@ describe('RpcDataSource', () => { }); describe('transaction events', () => { - it('refreshes balance with update mode when transaction confirmed', async () => { + it('refreshes balance with merge mode when transaction confirmed', async () => { const onAssetsUpdate = jest.fn().mockResolvedValue(undefined); const fetchSpy = jest .spyOn(RpcDataSource.prototype, 'fetch') @@ -1904,7 +1904,7 @@ describe('RpcDataSource', () => { 'eip155:1/slip44:60': { amount: '2' }, }, }, - updateMode: 'update', + updateMode: 'merge', }); try { @@ -1924,7 +1924,7 @@ describe('RpcDataSource', () => { expect(fetchSpy).toHaveBeenCalled(); expect(onAssetsUpdate).toHaveBeenCalledWith( - expect.objectContaining({ updateMode: 'update' }), + expect.objectContaining({ updateMode: 'merge' }), expect.objectContaining({ dataTypes: ['balance'] }), ); }); diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index 9eb7133c41..d28af6e3fd 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -523,7 +523,7 @@ export class RpcDataSource extends AbstractDataSource< [result.accountId]: newBalances, }, assetsInfo, - updateMode: 'update', + updateMode: 'merge', }; const request: DataRequest = { @@ -603,7 +603,7 @@ export class RpcDataSource extends AbstractDataSource< assetsBalance: { [result.accountId]: newBalances, }, - updateMode: 'update', + updateMode: 'merge', }; const chainIdDecimal = parseInt(result.chainId, 16); @@ -709,7 +709,7 @@ export class RpcDataSource extends AbstractDataSource< const responseWithMode: DataResponse = { ...response, - updateMode: response.updateMode ?? 'update', + updateMode: response.updateMode ?? 'merge', }; await subscription.onAssetsUpdate(responseWithMode, request); @@ -1172,7 +1172,7 @@ export class RpcDataSource extends AbstractDataSource< response.assetsInfo = assetsInfo; } - response.updateMode = 'update'; + response.updateMode = 'merge'; return response; } diff --git a/packages/assets-controller/src/data-sources/SnapDataSource.test.ts b/packages/assets-controller/src/data-sources/SnapDataSource.test.ts index 412b0bb79e..8518b3d694 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.test.ts @@ -435,7 +435,7 @@ describe('SnapDataSource', () => { expect(response).toStrictEqual({ assetsBalance: {}, assetsInfo: {}, - updateMode: 'update', + updateMode: 'merge', }); cleanup(); @@ -470,7 +470,7 @@ describe('SnapDataSource', () => { expect(response).toStrictEqual({ assetsBalance: {}, assetsInfo: {}, - updateMode: 'update', + updateMode: 'merge', }); cleanup(); diff --git a/packages/assets-controller/src/data-sources/SnapDataSource.ts b/packages/assets-controller/src/data-sources/SnapDataSource.ts index 75ea392531..17da7b0b5e 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.ts @@ -304,7 +304,7 @@ export class SnapDataSource extends AbstractDataSource< // Only report if we have snap-related updates if (assetsBalance) { - const response: DataResponse = { assetsBalance, updateMode: 'update' }; + const response: DataResponse = { assetsBalance, updateMode: 'merge' }; for (const subscription of this.activeSubscriptions.values()) { subscription.onAssetsUpdate(response)?.catch(console.error); } @@ -445,13 +445,13 @@ export class SnapDataSource extends AbstractDataSource< return {}; } if (!request?.accountsWithSupportedChains?.length) { - return { assetsBalance: {}, assetsInfo: {}, updateMode: 'update' }; + return { assetsBalance: {}, assetsInfo: {}, updateMode: 'merge' }; } const results: DataResponse = { assetsBalance: {}, assetsInfo: {}, - updateMode: 'update', + updateMode: 'merge', }; // Fetch balances for each account using its snap ID from metadata diff --git a/packages/assets-controller/src/middlewares/ParallelMiddleware.ts b/packages/assets-controller/src/middlewares/ParallelMiddleware.ts index bc285346dd..94e32e9002 100644 --- a/packages/assets-controller/src/middlewares/ParallelMiddleware.ts +++ b/packages/assets-controller/src/middlewares/ParallelMiddleware.ts @@ -66,12 +66,6 @@ export function mergeDataResponses(responses: DataResponse[]): DataResponse { merged.updateMode !== 'full' ) { merged.updateMode = 'merge'; - } else if ( - response.updateMode === 'update' && - merged.updateMode !== 'full' && - merged.updateMode !== 'merge' - ) { - merged.updateMode = 'update'; } } merged.updateMode ??= 'merge'; diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index f7ce1b91b7..5ee679bc07 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -379,14 +379,10 @@ export type DataResponse = { * - **full**: Response is the full set for the scope. Assets in state but not in the * response are cleared (except custom assets). Use for initial fetch or full refresh. * - **merge**: Only assets present in the response are updated; nothing is removed. - * Metadata and prices from the response are applied. Use for event-driven updates. - * - **update**: Balance-only overlay — incoming balance amounts are patched in place; - * existing balances, metadata, and prices are never removed or overwritten. - * Missing metadata and prices from the response are seeded so RPC-only chains - * can render on first fetch. Use for force refresh when the API may return a - * partial chain snapshot. - */ -export type AssetsUpdateMode = 'full' | 'merge' | 'update'; + * Metadata and prices from the response are applied. Use for event-driven updates + * and partial balance refreshes. + */ +export type AssetsUpdateMode = 'full' | 'merge'; // ============================================================================ // DATA SOURCE <-> CONTROLLER (DIRECT CALLS, NO MESSENGER PER SOURCE) diff --git a/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.test.ts b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.test.ts index b97a65a72f..6f4f09e58c 100644 --- a/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.test.ts +++ b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.test.ts @@ -26,7 +26,7 @@ describe('processAccountActivityBalanceUpdates', () => { () => 'native', ); - expect(response.updateMode).toBe('update'); + expect(response.updateMode).toBe('merge'); expect(response.assetsBalance?.[accountId]?.[assetId]).toStrictEqual({ amount: '0.00000114526056', }); diff --git a/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts index 83f19946ee..16c3844ce3 100644 --- a/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts +++ b/packages/assets-controller/src/utils/processAccountActivityBalanceUpdates.ts @@ -15,7 +15,7 @@ import type { * @param updates - Balance updates from account-activity websocket payload. * @param accountId - Internal account UUID. * @param getAssetType - Resolver for asset metadata type. - * @returns DataResponse with update mode when balances are present. + * @returns DataResponse with merge mode when balances are present. */ export function processAccountActivityBalanceUpdates( updates: BalanceUpdate[], @@ -68,7 +68,7 @@ export function processAccountActivityBalanceUpdates( }; } - const response: DataResponse = { updateMode: 'update' }; + const response: DataResponse = { updateMode: 'merge' }; if (Object.keys(assetsBalance[accountId]).length > 0) { response.assetsBalance = assetsBalance; response.assetsInfo = assetsMetadata; From 5a868956829643d9c12df4d7dcb94d6107a08703 Mon Sep 17 00:00:00 2001 From: salimtb Date: Sat, 27 Jun 2026 22:03:01 +0200 Subject: [PATCH 23/24] fix: fix prices --- packages/assets-controller/CHANGELOG.md | 1 + .../src/AssetsController.test.ts | 1 + .../assets-controller/src/AssetsController.ts | 104 ++++++++++++++++-- .../src/data-sources/PriceDataSource.test.ts | 5 +- .../src/data-sources/PriceDataSource.ts | 39 +++++-- .../middlewares/DetectionMiddleware.test.ts | 71 +++++++++++- .../src/middlewares/DetectionMiddleware.ts | 27 +++++ packages/assets-controller/src/types.ts | 12 +- .../src/utils/dedupingBatchFetcher.ts | 12 ++ 9 files changed, 246 insertions(+), 26 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 1cc82a7a69..806e0b2e3d 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove the 120-second websocket balance freshness guard that blocked force-refresh and polling updates from correcting stale websocket balances ([#9273](https://github.com/MetaMask/core/pull/9273)) - `BackendWebsocketDataSource` registers subscription handlers before the subscribe handshake so in-flight account-activity notifications are not dropped, cleans up subscription state on subscribe failure, and resolves balance updates from stored subscription state when notifications arrive with stale subscription IDs ([#9273](https://github.com/MetaMask/core/pull/9273)) - `AssetsController` refreshes data-source `activeChains`, re-subscribes, and force-fetches balances for the newly selected EVM chain when the user switches networks via `NetworkController:networkDidChange` +- `AssetsController` bypasses the price deduper cache and fetches spot prices for assets on a newly selected or added EVM network only when those assets have no entry in `assetsPrice` yet; `DetectionMiddleware` queues newly detected assets for the same pipeline price fetch so RPC-detected tokens are priced without waiting for the poll interval ## [9.1.0] diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 0a1a98685e..9ef3ea0b27 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -2456,6 +2456,7 @@ describe('AssetsController', () => { expect.objectContaining({ chainIds: ['eip155:1'], forceUpdate: true, + dataTypes: ['balance', 'metadata', 'price'], }), ); } finally { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 5e2f8e7cd0..d23e73321e 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1063,7 +1063,11 @@ export class AssetsController extends BaseController< 'NetworkController:networkAdded', (networkConfiguration) => { this.#handleNetworkAdded(networkConfiguration.chainId); - this.#refreshAssetsAfterNetworkChange(); + this.#refreshAssetsAfterNetworkAdded(networkConfiguration.chainId).catch( + (error) => { + log('Failed to refresh assets after network added', { error }); + }, + ); }, ); @@ -1981,6 +1985,60 @@ export class AssetsController extends BaseController< }); } + /** + * Fetch spot prices (bypassing deduper cache) for held assets that have no + * entry in `assetsPrice` yet. Used when switching or adding a network. + * + * @param accounts - Accounts whose held assets should be priced. + * @param chainIds - Chains to scope the check and fetch. + */ + #fetchMissingPricesWithoutCache( + accounts: InternalAccount[], + chainIds: ChainId[], + ): void { + if (!this.#isBasicFunctionality() || accounts.length === 0) { + return; + } + + const accountIds = new Set(accounts.map((account) => account.id)); + const chainFilter = new Set(chainIds); + const assetsForPriceUpdate: Caip19AssetId[] = []; + const prices = this.state.assetsPrice as Record; + + for (const [accountId, accountBalances] of Object.entries( + this.state.assetsBalance, + )) { + if (!accountIds.has(accountId)) { + continue; + } + for (const assetId of Object.keys(accountBalances)) { + if (!chainFilter.has(assetId.split('/')[0] as ChainId)) { + continue; + } + const normalizedAssetId = normalizeAssetId(assetId as Caip19AssetId); + if (prices[normalizedAssetId] ?? prices[assetId]) { + continue; + } + assetsForPriceUpdate.push(normalizedAssetId); + } + } + + if (assetsForPriceUpdate.length === 0) { + return; + } + + this.#priceDataSource.invalidatePriceCache(); + + this.getAssets(accounts, { + forceUpdate: true, + dataTypes: ['price'], + chainIds, + assetsForPriceUpdate, + }).catch((error) => { + log('Failed to fetch missing prices', { error }); + }); + } + // ============================================================================ // SUBSCRIPTIONS // ============================================================================ @@ -3388,30 +3446,60 @@ export class AssetsController extends BaseController< await this.getAssets(accounts, { chainIds: [selectedChainId], forceUpdate: true, + dataTypes: ['balance', 'metadata', 'price'], }); this.#ensureNativeBalancesDefaultZero(); + this.#fetchMissingPricesWithoutCache(accounts, [selectedChainId]); } finally { releaseLock(); } } /** - * Refresh assets across every data source after a network configuration - * is added to or removed from NetworkController. Mirrors the - * `forceUpdate` path used elsewhere (e.g. unapproved tx, account-tree - * change), so balances/prices/metadata stay consistent for the user's - * currently-enabled chains without us having to maintain bespoke - * per-event state surgery. + * Refresh balances after a network configuration is removed. */ #refreshAssetsAfterNetworkChange(): void { - this.getAssets(this.#getSelectedAccounts(), { + const accounts = this.#getSelectedAccounts(); + if (accounts.length === 0) { + return; + } + + this.getAssets(accounts, { forceUpdate: true, + dataTypes: ['balance', 'metadata'], }).catch((error) => { log('Failed to refresh assets after network change', { error }); }); } + /** + * Refresh balances and fetch missing prices after a network is added. + * + * @param hexChainId - Hex chain id of the newly-added network. + */ + async #refreshAssetsAfterNetworkAdded(hexChainId: Hex): Promise { + const accounts = this.#getSelectedAccounts(); + if (accounts.length === 0) { + return; + } + + let caipChainId: ChainId; + try { + caipChainId = `eip155:${parseInt(hexChainId, 16)}` as ChainId; + } catch { + return; + } + + await this.getAssets(accounts, { + chainIds: [caipChainId], + forceUpdate: true, + dataTypes: ['balance', 'metadata', 'price'], + }); + this.#ensureNativeBalancesDefaultZero(); + this.#fetchMissingPricesWithoutCache(accounts, [caipChainId]); + } + /** * Handle assets updated from a data source. * Called via the onAssetsUpdate callback passed in SubscriptionRequest when the controller subscribes to a data source. diff --git a/packages/assets-controller/src/data-sources/PriceDataSource.test.ts b/packages/assets-controller/src/data-sources/PriceDataSource.test.ts index 10d9c97e50..d0842543e3 100644 --- a/packages/assets-controller/src/data-sources/PriceDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/PriceDataSource.test.ts @@ -8,6 +8,7 @@ import type { Caip19AssetId, AssetsControllerStateInternal, } from '../types'; +import { normalizeAssetId } from '../utils'; import type { PriceDataSourceOptions } from './PriceDataSource'; import { PriceDataSource } from './PriceDataSource'; @@ -74,7 +75,7 @@ function createMiddlewareContext(overrides?: Partial): Context { return { request: createDataRequest(), response: {}, - getAssetsState: jest.fn(), + getAssetsState: jest.fn().mockReturnValue({ assetsPrice: {} }), ...overrides, }; } @@ -756,7 +757,7 @@ describe('PriceDataSource', () => { await controller.assetsMiddleware(context, next); expect(apiClient.prices.fetchV3SpotPrices).toHaveBeenCalledWith( - [MOCK_TOKEN_ASSET], + [normalizeAssetId(MOCK_TOKEN_ASSET)], { currency: 'usd', includeMarketData: true }, ); expect(context.response.assetsPrice?.[MOCK_TOKEN_ASSET]).toStrictEqual({ diff --git a/packages/assets-controller/src/data-sources/PriceDataSource.ts b/packages/assets-controller/src/data-sources/PriceDataSource.ts index ac68008c68..4b0a45c3bd 100644 --- a/packages/assets-controller/src/data-sources/PriceDataSource.ts +++ b/packages/assets-controller/src/data-sources/PriceDataSource.ts @@ -15,7 +15,7 @@ import type { Middleware, AssetsControllerStateInternal, } from '../types'; -import { fetchWithTimeout } from '../utils'; +import { fetchWithTimeout, normalizeAssetId } from '../utils'; import { DedupingBatchFetcher } from '../utils/dedupingBatchFetcher'; import type { SubscriptionRequest } from './AbstractDataSource'; import { reduceInBatchesSerially } from './evm-rpc-services'; @@ -210,25 +210,38 @@ export class PriceDataSource { // Extract response from context const { response, request } = ctx; - // Only fetch prices for detected assets (assets without metadata) - // The subscription handles fetching prices for all existing assets - if (!response.detectedAssets && !request.assetsForPriceUpdate?.length) { - return next(ctx); - } + const statePrices = (ctx.getAssetsState()?.assetsPrice ?? {}) as Record< + string, + FungibleAssetPrice + >; const assetIds = new Set(); + + for (const assetId of request.assetsForPriceUpdate ?? []) { + assetIds.add(assetId); + } + + // Detected assets only need a price fetch when state has none yet. + // Explicit assetsForPriceUpdate (e.g. currency change) are always fetched. for (const detectedAccountAssets of Object.values( response.detectedAssets ?? {}, )) { for (const assetId of detectedAccountAssets) { - assetIds.add(assetId); + const normalizedAssetId = normalizeAssetId(assetId); + const alreadyQueued = request.assetsForPriceUpdate?.some( + (queuedId) => + queuedId === assetId || queuedId === normalizedAssetId, + ); + if ( + statePrices[assetId] === undefined && + statePrices[normalizedAssetId] === undefined && + !alreadyQueued + ) { + assetIds.add(normalizedAssetId); + } } } - for (const assetId of request.assetsForPriceUpdate ?? []) { - assetIds.add(assetId); - } - if (assetIds.size === 0) { return next(ctx); } @@ -240,6 +253,10 @@ export class PriceDataSource { return next(ctx); } + if (request.forceUpdate) { + this.#deduper.invalidateKeys(priceableAssetIds); + } + try { const spotPrices = await this.#fetchSpotPrices(priceableAssetIds); response.assetsPrice = { diff --git a/packages/assets-controller/src/middlewares/DetectionMiddleware.test.ts b/packages/assets-controller/src/middlewares/DetectionMiddleware.test.ts index 5838adcd73..4d471c0bb0 100644 --- a/packages/assets-controller/src/middlewares/DetectionMiddleware.test.ts +++ b/packages/assets-controller/src/middlewares/DetectionMiddleware.test.ts @@ -7,6 +7,7 @@ import type { Caip19AssetId, AssetsControllerStateInternal, } from '../types'; +import { normalizeAssetId } from '../utils'; import { DetectionMiddleware } from './DetectionMiddleware'; const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; @@ -56,26 +57,35 @@ function createDataRequest( function createAssetsState( metadataAssets: Caip19AssetId[] = [], + assetsPrice: Caip19AssetId[] = [], ): AssetsControllerStateInternal { const assetsInfo: Record = {}; for (const assetId of metadataAssets) { assetsInfo[assetId] = { name: `Asset ${assetId}` }; } + const priceState: Record = {}; + for (const assetId of assetsPrice) { + priceState[assetId] = { price: 1 }; + } return { assetsInfo, assetsBalance: {}, customAssets: {}, + assetsPrice: priceState, } as AssetsControllerStateInternal; } function createMiddlewareContext( overrides?: Partial, stateMetadata: Caip19AssetId[] = [], + stateAssetsPrice: Caip19AssetId[] = [], ): Context { return { request: createDataRequest(), response: {}, - getAssetsState: jest.fn().mockReturnValue(createAssetsState(stateMetadata)), + getAssetsState: jest + .fn() + .mockReturnValue(createAssetsState(stateMetadata, stateAssetsPrice)), ...overrides, }; } @@ -148,6 +158,10 @@ describe('DetectionMiddleware', () => { expect(context.response.detectedAssets).toStrictEqual({ [MOCK_ACCOUNT_ID]: [MOCK_ASSET_1, MOCK_ASSET_2], }); + expect(context.request.assetsForPriceUpdate).toStrictEqual([ + normalizeAssetId(MOCK_ASSET_1), + normalizeAssetId(MOCK_ASSET_2), + ]); expect(next).toHaveBeenCalledWith(context); }); @@ -340,6 +354,61 @@ describe('DetectionMiddleware', () => { expect(next).toHaveBeenCalledWith(context); }); + it('queues assetsForPriceUpdate for detected assets missing a price', async () => { + const { middleware } = setupController(); + const context = createMiddlewareContext( + { + response: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_1]: { amount: '1000' }, + [MOCK_ASSET_2]: { amount: '2000' }, + }, + }, + }, + }, + [], + [MOCK_ASSET_1], + ); + const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); + + await middleware.assetsMiddleware(context, next); + + expect(context.response.detectedAssets).toStrictEqual({ + [MOCK_ACCOUNT_ID]: [MOCK_ASSET_1, MOCK_ASSET_2], + }); + expect(context.request.assetsForPriceUpdate).toStrictEqual([ + normalizeAssetId(MOCK_ASSET_2), + ]); + expect(next).toHaveBeenCalledWith(context); + }); + + it('does not queue assetsForPriceUpdate when all detected assets have prices', async () => { + const { middleware } = setupController(); + const context = createMiddlewareContext( + { + response: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_1]: { amount: '1000' }, + }, + }, + }, + }, + [], + [MOCK_ASSET_1], + ); + const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); + + await middleware.assetsMiddleware(context, next); + + expect(context.response.detectedAssets).toStrictEqual({ + [MOCK_ACCOUNT_ID]: [MOCK_ASSET_1], + }); + expect(context.request.assetsForPriceUpdate).toBeUndefined(); + expect(next).toHaveBeenCalledWith(context); + }); + it('retrieves middleware from instance', async () => { const { middleware } = setupController(); const middlewareFn = middleware.assetsMiddleware; diff --git a/packages/assets-controller/src/middlewares/DetectionMiddleware.ts b/packages/assets-controller/src/middlewares/DetectionMiddleware.ts index 9ca75db6ab..2fe4b1dcf8 100644 --- a/packages/assets-controller/src/middlewares/DetectionMiddleware.ts +++ b/packages/assets-controller/src/middlewares/DetectionMiddleware.ts @@ -1,6 +1,7 @@ import { projectLogger, createModuleLogger } from '../logger'; import { forDataTypes } from '../types'; import type { AccountId, Caip19AssetId, Middleware } from '../types'; +import { normalizeAssetId } from '../utils'; // ============================================================================ // CONSTANTS @@ -49,6 +50,9 @@ export class DetectionMiddleware { * state.assetsBalance and state.assetsInfo (brand-new assets only) * 2. Always includes each account's custom assets from state * 3. Fills response.detectedAssets with the resulting asset IDs per account + * 4. Queues detected assets that lack a price in state on + * request.assetsForPriceUpdate so PriceDataSource fetches them in the same + * pipeline pass (including the background RPC detection path) * * @returns The middleware function for the assets pipeline. */ @@ -62,6 +66,7 @@ export class DetectionMiddleware { customAssets: stateCustomAssets, assetsBalance: stateAssetsBalance, assetsInfo: stateAssetsInfo, + assetsPrice: stateAssetsPrice, } = state; const detectedAssets: Record = {}; @@ -133,6 +138,28 @@ export class DetectionMiddleware { if (Object.keys(detectedAssets).length > 0) { response.detectedAssets = detectedAssets; + + const prices = stateAssetsPrice as Record; + const missingPriceAssets = new Set(); + + for (const accountAssetIds of Object.values(detectedAssets)) { + for (const assetId of accountAssetIds) { + const normalizedAssetId = normalizeAssetId(assetId); + if ( + prices[normalizedAssetId] === undefined && + prices[assetId] === undefined + ) { + missingPriceAssets.add(normalizedAssetId); + } + } + } + + if (missingPriceAssets.size > 0) { + request.assetsForPriceUpdate = [ + ...(request.assetsForPriceUpdate ?? []), + ...missingPriceAssets, + ]; + } } return next(ctx); diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 5ee679bc07..f7ce1b91b7 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -379,10 +379,14 @@ export type DataResponse = { * - **full**: Response is the full set for the scope. Assets in state but not in the * response are cleared (except custom assets). Use for initial fetch or full refresh. * - **merge**: Only assets present in the response are updated; nothing is removed. - * Metadata and prices from the response are applied. Use for event-driven updates - * and partial balance refreshes. - */ -export type AssetsUpdateMode = 'full' | 'merge'; + * Metadata and prices from the response are applied. Use for event-driven updates. + * - **update**: Balance-only overlay — incoming balance amounts are patched in place; + * existing balances, metadata, and prices are never removed or overwritten. + * Missing metadata and prices from the response are seeded so RPC-only chains + * can render on first fetch. Use for force refresh when the API may return a + * partial chain snapshot. + */ +export type AssetsUpdateMode = 'full' | 'merge' | 'update'; // ============================================================================ // DATA SOURCE <-> CONTROLLER (DIRECT CALLS, NO MESSENGER PER SOURCE) diff --git a/packages/assets-controller/src/utils/dedupingBatchFetcher.ts b/packages/assets-controller/src/utils/dedupingBatchFetcher.ts index 6b9330b0b2..39b42f2072 100644 --- a/packages/assets-controller/src/utils/dedupingBatchFetcher.ts +++ b/packages/assets-controller/src/utils/dedupingBatchFetcher.ts @@ -106,6 +106,18 @@ export class DedupingBatchFetcher { this.#fetchedAt.clear(); } + /** + * Clear freshness for specific keys only, forcing the next fetch to + * re-request those keys regardless of TTL. Does not affect inflight fetches. + * + * @param keys - Keys to mark stale. + */ + invalidateKeys(keys: Key[]): void { + for (const key of keys) { + this.#fetchedAt.delete(key); + } + } + /** Clear all freshness and inflight state. */ destroy(): void { this.#fetchedAt.clear(); From 18dd0df9c8bd0d2c960c67fd7c79c8c19f639be7 Mon Sep 17 00:00:00 2001 From: salimtb Date: Sat, 27 Jun 2026 22:27:48 +0200 Subject: [PATCH 24/24] fix: fix linter --- packages/assets-controller/src/AssetsController.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index d23e73321e..cae6baffc3 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1063,11 +1063,11 @@ export class AssetsController extends BaseController< 'NetworkController:networkAdded', (networkConfiguration) => { this.#handleNetworkAdded(networkConfiguration.chainId); - this.#refreshAssetsAfterNetworkAdded(networkConfiguration.chainId).catch( - (error) => { - log('Failed to refresh assets after network added', { error }); - }, - ); + this.#refreshAssetsAfterNetworkAdded( + networkConfiguration.chainId, + ).catch((error) => { + log('Failed to refresh assets after network added', { error }); + }); }, );