From 87448f02bc7397b95f3017c39730d5e80baceb65 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 8 Jul 2026 18:07:50 +0200 Subject: [PATCH 1/2] feat(assets-controller): gate non-EVM account-activity websocket behind a namespace feature flag Add a generic `webSocketEnabledNamespaces` constructor option so non-EVM account-activity balances (starting with Solana, extensible to e.g. Tron) are served over WebSocket only when their CAIP namespace is enabled. EVM (`eip155`) is always served over WebSocket. - BackendWebsocketDataSource claims an enabled namespace's chains (from a namespace -> chains map, since the supported-networks API is EVM-only) while the WebSocket is connected, and subscribes their account-activity channels. Disabled namespaces are left unclaimed and unsubscribed so the SnapDataSource serves them. - When a chain (or a whole non-EVM namespace) is reported down via `system-notifications`, it is excluded from the claimable set and automatically falls back to the SnapDataSource until it recovers. - AccountsApiDataSource is documented as EVM-only (removing the stale "restore solana" TODO); non-EVM is served by WebSocket or Snap, keeping Snap as the sole non-EVM fallback. --- packages/assets-controller/CHANGELOG.md | 11 + .../assets-controller/src/AssetsController.ts | 16 ++ .../src/data-sources/AccountsApiDataSource.ts | 14 +- .../BackendWebsocketDataSource.test.ts | 148 ++++++++++++++ .../BackendWebsocketDataSource.ts | 188 ++++++++++++++---- 5 files changed, 336 insertions(+), 41 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 3bebea71c0..623aac3015 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,8 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `webSocketEnabledNamespaces` constructor option to gate non-EVM account-activity balances (e.g. Solana) behind a feature flag ([#0000](https://github.com/MetaMask/core/pull/0000)) + - The getter returns the non-EVM CAIP namespaces (e.g. `['solana']`, and later `['solana', 'bip122']` for Tron) served over WebSocket. EVM (`eip155`) is always served over WebSocket. + - For each enabled namespace (while the WebSocket is connected), `BackendWebsocketDataSource` claims that namespace's chains and streams real-time balances for them; disabled namespaces are left unclaimed and unsubscribed so the `SnapDataSource` serves them. + - When the WebSocket is down, those chains are released, so the `SnapDataSource` takes over as a fallback. + - When a chain (or a whole non-EVM namespace, e.g. Solana) is reported down via `system-notifications`, it is excluded from the WebSocket source's claimable chains and automatically falls back to the `SnapDataSource` until it recovers. + - Defaults to none (`() => []`; EVM only). + ### Changed +- `AccountsApiDataSource` now documents that it serves EVM chains only; non-EVM chains (e.g. Solana) are served by the WebSocket source when enabled, otherwise by the Snap source ([#0000](https://github.com/MetaMask/core/pull/0000)) + - Bump `@metamask/transaction-controller` from `^68.2.2` to `^68.3.0` ([#9421](https://github.com/MetaMask/core/pull/9421)) - Bump `@metamask/keyring-api` from `^23.3.0` to `^23.5.0` ([#9390](https://github.com/MetaMask/core/pull/9390)) - Bump `@metamask/keyring-snap-client` from `^9.0.2` to `^9.2.0` ([#9390](https://github.com/MetaMask/core/pull/9390)) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 3a6612af09..e9763b63de 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -404,6 +404,19 @@ export type AssetsControllerOptions = { subscribeToBasicFunctionalityChange?: ( onChange: (isBasic: boolean) => void, ) => void | (() => void); + /** + * Getter for the non-EVM CAIP namespaces (e.g. 'solana', and later 'bip122' + * for Tron) whose account activity should be served over WebSocket. EVM + * ('eip155') is always served over WebSocket. For each returned namespace + * (and while the WebSocket is connected), the BackendWebsocketDataSource + * claims that namespace's chains and streams real-time balances for them. + * Namespaces not returned are left unclaimed so the SnapDataSource serves + * them instead. When the WebSocket is down, those chains are released + * regardless, so the SnapDataSource takes over as a fallback. No value is + * stored; the getter is invoked when needed. Defaults to () => [] when not + * provided (EVM only; Snap serves non-EVM). + */ + webSocketEnabledNamespaces?: () => string[]; /** * API client for balance/price/metadata. The controller instantiates data sources * and uses them directly when this is provided. @@ -849,6 +862,7 @@ export class AssetsController extends BaseController< isEnabled = (): boolean => true, isBasicFunctionality, subscribeToBasicFunctionalityChange, + webSocketEnabledNamespaces, queryApiClient, rpcDataSourceConfig, trace, @@ -912,6 +926,8 @@ export class AssetsController extends BaseController< onActiveChainsUpdated: this.#onActiveChainsUpdated, getAssetType: (assetId: Caip19AssetId): 'native' | 'erc20' | 'spl' => this.#getAssetType(assetId), + getWebSocketEnabledNamespaces: + webSocketEnabledNamespaces ?? ((): string[] => []), }); this.#accountsApiDataSource = new AccountsApiDataSource({ queryApiClient, diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 74f5d94be1..4546ce0b83 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -278,13 +278,13 @@ export class AccountsApiDataSource extends AbstractDataSource< async #fetchActiveChains(): Promise { const response = await this.#apiClient.accounts.fetchV2SupportedNetworks(); - // Use fullSupport networks as active chains - return ( - response.fullSupport - .map(decimalToChainId) - // TODO Restore solana when there is a fix for how we handle non-evm chains here - .filter((chainId) => chainId.startsWith('eip155:')) - ); + // Use fullSupport networks as active chains. This source serves EVM chains + // only; non-EVM chains (e.g. Solana) are served by the WebSocket source when + // their namespace is enabled, otherwise by the Snap source, so they are + // intentionally excluded from this source's claimable chains. + return response.fullSupport + .map(decimalToChainId) + .filter((chainId) => chainId.startsWith('eip155:')); } // ============================================================================ diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts index b2d5af5958..7901e9620f 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts @@ -106,11 +106,13 @@ function setupController( options: { initialActiveChains?: ChainId[]; connectionState?: WebSocketState; + webSocketEnabledNamespaces?: string[]; } = {}, ): SetupResult { const { initialActiveChains = [], connectionState = WebSocketState.CONNECTED, + webSocketEnabledNamespaces = [], } = options; const rootMessenger = new Messenger({ @@ -207,6 +209,7 @@ function setupController( onActiveChainsUpdated: (dataSourceName, chains, previousChains): void => activeChainsUpdateHandler(dataSourceName, chains, previousChains), getAssetType: getAssetTypeFn, + getWebSocketEnabledNamespaces: (): string[] => webSocketEnabledNamespaces, state: { activeChains: initialActiveChains }, }); @@ -402,6 +405,7 @@ describe('BackendWebsocketDataSource', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as ChainId, ], connectionState: WebSocketState.CONNECTED, + webSocketEnabledNamespaces: ['solana'], }); await controller.subscribe({ @@ -446,6 +450,150 @@ describe('BackendWebsocketDataSource', () => { controller.destroy(); }); + describe('Solana account-activity feature flag', () => { + const SOLANA_MAINNET = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as ChainId; + const SOLANA_ADDRESS = '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU'; + + it('does not subscribe Solana channels when the flag is disabled', async () => { + const { controller, wsSubscribeMock } = setupController({ + initialActiveChains: [CHAIN_MAINNET, SOLANA_MAINNET], + connectionState: WebSocketState.CONNECTED, + webSocketEnabledNamespaces: [], + }); + + await controller.subscribe({ + subscriptionId: 'sub-1', + request: createDataRequest({ + chainIds: [CHAIN_MAINNET, SOLANA_MAINNET], + accountsWithSupportedChains: [ + { + account: createMockAccount(), + supportedChains: [CHAIN_MAINNET], + }, + { + account: createMockAccount({ + id: 'solana-account-id', + address: SOLANA_ADDRESS, + type: 'solana:data-account', + scopes: ['solana:0'], + }), + supportedChains: [SOLANA_MAINNET], + }, + ], + }), + isUpdate: false, + onAssetsUpdate: jest.fn(), + }); + + const subscribedChannels: string[] = wsSubscribeMock.mock.calls.flatMap( + ([arg]) => arg.channels as string[], + ); + expect(subscribedChannels).toContain( + `account-activity.v1.eip155:0:${MOCK_ADDRESS.toLowerCase()}`, + ); + expect( + subscribedChannels.some((channel) => + channel.startsWith('account-activity.v1.solana'), + ), + ).toBe(false); + + controller.destroy(); + }); + + it('claims Solana chains on reconnect when the flag is enabled', async () => { + const { controller, activeChainsUpdateHandler, triggerConnectionStateChange } = + setupController({ + initialActiveChains: [CHAIN_MAINNET], + connectionState: WebSocketState.DISCONNECTED, + webSocketEnabledNamespaces: ['solana'], + }); + + // Let #initializeActiveChains populate #supportedChains from the API. + await new Promise(process.nextTick); + + triggerConnectionStateChange(WebSocketState.CONNECTED); + await new Promise(process.nextTick); + + const claimedChains: ChainId[] = + activeChainsUpdateHandler.mock.calls.at(-1)?.[1] ?? []; + expect(claimedChains).toContain(SOLANA_MAINNET); + + controller.destroy(); + }); + + it('does not claim Solana chains on reconnect when the flag is disabled', async () => { + const { controller, activeChainsUpdateHandler, triggerConnectionStateChange } = + setupController({ + initialActiveChains: [CHAIN_MAINNET], + connectionState: WebSocketState.DISCONNECTED, + webSocketEnabledNamespaces: [], + }); + + await new Promise(process.nextTick); + + triggerConnectionStateChange(WebSocketState.CONNECTED); + await new Promise(process.nextTick); + + const claimedChains: ChainId[] = + activeChainsUpdateHandler.mock.calls.at(-1)?.[1] ?? []; + expect(claimedChains).not.toContain(SOLANA_MAINNET); + + controller.destroy(); + }); + + it('releases Solana to Snap when reported down via system-notifications, then reclaims when up', async () => { + const { + controller, + activeChainsUpdateHandler, + addChannelCallbackMock, + triggerConnectionStateChange, + } = setupController({ + initialActiveChains: [CHAIN_MAINNET], + connectionState: WebSocketState.DISCONNECTED, + webSocketEnabledNamespaces: ['solana'], + }); + + await new Promise(process.nextTick); + triggerConnectionStateChange(WebSocketState.CONNECTED); + await new Promise(process.nextTick); + + // Solana is claimed while healthy. + expect(activeChainsUpdateHandler.mock.calls.at(-1)?.[1]).toContain( + SOLANA_MAINNET, + ); + + const systemNotificationCallback = addChannelCallbackMock.mock.calls.find( + ([arg]) => + arg.channelName === 'system-notifications.v1.account-activity.v1', + )?.[0]?.callback as (notification: unknown) => void; + + // Solana reported down -> released so Snap can claim it. + systemNotificationCallback({ + event: 'notification', + channel: 'system-notifications.v1.account-activity.v1', + timestamp: 1, + data: { chainIds: [SOLANA_MAINNET], status: 'down' }, + }); + expect(activeChainsUpdateHandler.mock.calls.at(-1)?.[1]).not.toContain( + SOLANA_MAINNET, + ); + + // Solana back up -> reclaimed by the WebSocket source. + systemNotificationCallback({ + event: 'notification', + channel: 'system-notifications.v1.account-activity.v1', + timestamp: 2, + data: { chainIds: [SOLANA_MAINNET], status: 'up' }, + }); + expect(activeChainsUpdateHandler.mock.calls.at(-1)?.[1]).toContain( + SOLANA_MAINNET, + ); + + controller.destroy(); + }); + }); + it('subscribe update only changes chains if addresses unchanged', async () => { const { controller, wsSubscribeMock } = setupController({ initialActiveChains: [CHAIN_MAINNET, CHAIN_POLYGON], diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts index b0349b297b..a02f8d0fb7 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts @@ -8,6 +8,7 @@ import type { BalanceUpdate, } from '@metamask/core-backend'; import type { ApiPlatformClient } from '@metamask/core-backend'; +import { SolScope } from '@metamask/keyring-api'; import { isCaipChainId, KnownCaipNamespace, @@ -31,6 +32,20 @@ import type { const CONTROLLER_NAME = 'BackendWebsocketDataSource'; const CHANNEL_TYPE = 'account-activity.v1'; +/** System-notifications channel carrying per-chain up/down status. */ +const SYSTEM_NOTIFICATIONS_CHANNEL = `system-notifications.v1.${CHANNEL_TYPE}`; + +/** + * Non-EVM CAIP namespaces that can be served over WebSocket, mapped to the + * chains this data source claims for each when the namespace is enabled. The + * account-activity subscription uses the `:0:
` wildcard, so + * covering the primary chain per namespace is sufficient for chain-claiming. + * Add future namespaces (e.g. `bip122` for Tron) here. + */ +const NON_EVM_WEBSOCKET_CHAINS: Record = { + solana: [SolScope.Mainnet as ChainId], +}; + const log = createModuleLogger(projectLogger, CONTROLLER_NAME); // ============================================================================ @@ -72,6 +87,14 @@ export type BackendWebsocketDataSourceOptions = { ) => void; /** Returns the asset type ('native' | 'erc20' | 'spl') for a given CAIP-19 asset ID. */ getAssetType: (assetId: Caip19AssetId) => 'native' | 'erc20' | 'spl'; + /** + * Returns the non-EVM CAIP namespaces (e.g. 'solana', and later 'bip122' for + * Tron) whose account activity should be served over WebSocket. EVM + * ('eip155') is always served. Namespaces not returned here are not claimed + * and not subscribed, so the SnapDataSource serves them instead. Defaults to + * none (EVM only). + */ + getWebSocketEnabledNamespaces?: () => string[]; state?: Partial; }; @@ -80,34 +103,18 @@ export type BackendWebsocketDataSourceOptions = { // ============================================================================ /** - * Extract namespace from a CAIP-2 chain ID. - * E.g., "eip155:1" -> "eip155", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" -> "solana" + * Get unique namespaces for account-activity subscriptions. EVM ('eip155') is + * always included; the WebSocket-enabled non-EVM namespaces are added so their + * accounts are subscribed. Namespaces that are not enabled are omitted so no + * channels are subscribed for them (the SnapDataSource serves them instead). * - * @param chainId - The CAIP-2 chain ID to extract namespace from. - * @returns The namespace portion of the chain ID. + * @param enabledNamespaces - WebSocket-enabled non-EVM namespaces (e.g. ['solana']). + * @returns Array of unique namespaces (always at least eip155). */ -function extractNamespace(chainId: ChainId): string { - const [namespace] = chainId.split(':'); - return namespace; -} - -/** Namespaces we always subscribe to for account activity (EVM + Solana). */ -const ACCOUNT_ACTIVITY_NAMESPACES = ['eip155', 'solana'] as const; - -/** - * Get unique namespaces for account-activity subscriptions. - * Always includes eip155 and solana so we subscribe to both EVM and Solana account activity, - * plus any additional namespaces from the requested chain IDs. - * - * @param chainIds - Array of CAIP-2 chain IDs (from the subscription request). - * @returns Array of unique namespaces (at least eip155 and solana). - */ -function getNamespacesForAccountActivity(chainIds: ChainId[]): string[] { - const namespaces = new Set(ACCOUNT_ACTIVITY_NAMESPACES); - for (const chainId of chainIds) { - namespaces.add(extractNamespace(chainId)); - } - return Array.from(namespaces); +function getNamespacesForAccountActivity( + enabledNamespaces: string[], +): string[] { + return Array.from(new Set(['eip155', ...enabledNamespaces])); } /** @@ -263,6 +270,8 @@ export class BackendWebsocketDataSource extends AbstractDataSource< assetId: Caip19AssetId, ) => 'native' | 'erc20' | 'spl'; + readonly #getWebSocketEnabledNamespaces: () => string[]; + /** Chains refresh timer */ #chainsRefreshTimer: ReturnType | null = null; @@ -272,6 +281,13 @@ export class BackendWebsocketDataSource extends AbstractDataSource< /** Whether the WebSocket is currently connected. Chains are only claimed when true. */ #isConnected = false; + /** + * Chains reported as down via `system-notifications`. Down chains are excluded + * from the claimable set so the chain-claiming loop hands them to the + * SnapDataSource (fallback) until they come back up. + */ + readonly #downChains: Set = new Set(); + /** WebSocket subscriptions by our internal subscription ID */ readonly #wsSubscriptions: Map = new Map(); @@ -297,6 +313,8 @@ export class BackendWebsocketDataSource extends AbstractDataSource< this.#apiClient = options.queryApiClient; this.#onActiveChainsUpdated = options.onActiveChainsUpdated; this.#getAssetType = options.getAssetType; + this.#getWebSocketEnabledNamespaces = + options.getWebSocketEnabledNamespaces ?? ((): string[] => []); this.#subscribeToEvents(); this.#initializeActiveChains().catch(console.error); @@ -306,6 +324,37 @@ export class BackendWebsocketDataSource extends AbstractDataSource< // INITIALIZATION // ============================================================================ + /** + * Compute the chains this data source can claim. The supported-networks API + * only reports EVM chains, so non-EVM chains are added here for each enabled + * namespace (e.g. Solana). Namespaces that are not enabled are omitted so the + * SnapDataSource claims their chains instead. + * + * Chains (or whole non-EVM namespaces) currently reported as down via + * `system-notifications` are excluded, so the chain-claiming loop hands them to + * the SnapDataSource until they recover. + * + * @returns The claimable chain IDs (EVM + enabled, healthy non-EVM namespaces). + */ + #getClaimableChains(): ChainId[] { + const enabledNamespaces = new Set(this.#getWebSocketEnabledNamespaces()); + const downNamespaces = new Set( + [...this.#downChains].map((chainId) => chainId.split(':')[0]), + ); + const chains = [...this.#supportedChains]; + for (const [namespace, namespaceChains] of Object.entries( + NON_EVM_WEBSOCKET_CHAINS, + )) { + // Add an enabled non-EVM namespace's chains only while it is healthy; + // when it is down, leave it unclaimed so the SnapDataSource takes over. + if (enabledNamespaces.has(namespace) && !downNamespaces.has(namespace)) { + chains.push(...namespaceChains); + } + } + // Drop any specific chain reported down (e.g. a single EVM chain). + return chains.filter((chainId) => !this.#downChains.has(chainId)); + } + async #initializeActiveChains(): Promise { try { const chains = await this.#fetchActiveChains(); @@ -316,7 +365,7 @@ export class BackendWebsocketDataSource extends AbstractDataSource< // can pick them up via polling. They'll be claimed on reconnect. if (this.#isConnected) { const previous = [...this.state.activeChains]; - this.updateActiveChains(chains, (updatedChains) => + this.updateActiveChains(this.#getClaimableChains(), (updatedChains) => this.#onActiveChainsUpdated(this.getName(), updatedChains, previous), ); } @@ -339,17 +388,20 @@ export class BackendWebsocketDataSource extends AbstractDataSource< return; } + const claimableChains = this.#getClaimableChains(); const previousChains = new Set(this.state.activeChains); - const newChains = new Set(chains); + const newChains = new Set(claimableChains); - const added = chains.filter((chain) => !previousChains.has(chain)); + const added = claimableChains.filter( + (chain) => !previousChains.has(chain), + ); const removed = Array.from(previousChains).filter( (chain) => !newChains.has(chain), ); if (added.length > 0 || removed.length > 0) { const previous = [...this.state.activeChains]; - this.updateActiveChains(chains, (updatedChains) => + this.updateActiveChains(claimableChains, (updatedChains) => this.#onActiveChainsUpdated(this.getName(), updatedChains, previous), ); } @@ -398,6 +450,60 @@ export class BackendWebsocketDataSource extends AbstractDataSource< } }, ); + + // Listen for per-chain health via system-notifications so a chain reported + // down can be released to the SnapDataSource (fallback) without dropping the + // whole WebSocket connection. + try { + this.#messenger.call('BackendWebSocketService:addChannelCallback', { + channelName: SYSTEM_NOTIFICATIONS_CHANNEL, + callback: (notification: ServerNotificationMessage) => + this.#handleSystemNotification(notification), + }); + } catch { + // Channel callbacks are optional; chain health just won't be tracked. + } + } + + /** + * Handle a `system-notifications` chain status update. Adds/removes chains + * from the down set and, when a claimable chain's health changes, recomputes + * the active chains so the chain-claiming loop reassigns them (a down Solana + * falls back to the SnapDataSource; a recovered Solana is reclaimed here). + * + * @param notification - Server notification with `{ chainIds, status }` data. + */ + #handleSystemNotification(notification: ServerNotificationMessage): void { + const data = notification.data as { + chainIds?: string[]; + status?: 'up' | 'down'; + }; + + if (!Array.isArray(data.chainIds) || !data.status) { + return; + } + + let changed = false; + for (const chainId of data.chainIds) { + const id = chainId as ChainId; + if (data.status === 'down') { + if (!this.#downChains.has(id)) { + this.#downChains.add(id); + changed = true; + } + } else if (this.#downChains.delete(id)) { + changed = true; + } + } + + // Only reclaim/release while connected; on disconnect the whole chain set is + // released separately. + if (changed && this.#isConnected) { + const previous = [...this.state.activeChains]; + this.updateActiveChains(this.#getClaimableChains(), (updatedChains) => + this.#onActiveChainsUpdated(this.getName(), updatedChains, previous), + ); + } } /** @@ -469,9 +575,10 @@ export class BackendWebsocketDataSource extends AbstractDataSource< // outdated request data. this.#pendingSubscriptions.clear(); - if (this.#supportedChains.length > 0) { + const claimableChains = this.#getClaimableChains(); + if (claimableChains.length > 0) { const previous = [...this.state.activeChains]; - this.updateActiveChains(this.#supportedChains, (updatedChains) => + this.updateActiveChains(claimableChains, (updatedChains) => this.#onActiveChainsUpdated(this.getName(), updatedChains, previous), ); } @@ -574,8 +681,12 @@ export class BackendWebsocketDataSource extends AbstractDataSource< // Clean up existing subscription if any (inline teardown — subscribe holds the lock) await this.#teardownSubscription(subscriptionId); - // Always subscribe to eip155 and solana account activity, plus any namespaces from requested chains - const namespaces = getNamespacesForAccountActivity(chainsToSubscribe); + // Always subscribe to eip155 account activity, plus any non-EVM namespaces + // enabled for WebSocket (e.g. solana). Disabled namespaces are omitted so + // the SnapDataSource serves them instead. + const namespaces = getNamespacesForAccountActivity( + this.#getWebSocketEnabledNamespaces(), + ); // Build channel names: use namespace-appropriate address per account (eip155 = hex, solana = base58) const channels: string[] = []; @@ -811,6 +922,15 @@ export class BackendWebsocketDataSource extends AbstractDataSource< this.#chainsRefreshTimer = null; } + try { + this.#messenger.call( + 'BackendWebSocketService:removeChannelCallback', + SYSTEM_NOTIFICATIONS_CHANNEL, + ); + } catch { + // Best-effort cleanup. + } + const subscriptionIds = [ ...new Set([ ...this.#wsSubscriptions.keys(), From 186382c0a9c1bd12aed89444ab00cdb638d42163 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 8 Jul 2026 19:16:34 +0200 Subject: [PATCH 2/2] docs(assets-controller): add PR link to changelog entries Co-Authored-By: Claude Opus 4.8 --- packages/assets-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index e4170b582b..8898287d60 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `webSocketEnabledNamespaces` constructor option to gate non-EVM account-activity balances (e.g. Solana) behind a feature flag ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `webSocketEnabledNamespaces` constructor option to gate non-EVM account-activity balances (e.g. Solana) behind a feature flag ([#9430](https://github.com/MetaMask/core/pull/9430)) - The getter returns the non-EVM CAIP namespaces (e.g. `['solana']`, and later `['solana', 'bip122']` for Tron) served over WebSocket. EVM (`eip155`) is always served over WebSocket. - For each enabled namespace (while the WebSocket is connected), `BackendWebsocketDataSource` claims that namespace's chains and streams real-time balances for them; disabled namespaces are left unclaimed and unsubscribed so the `SnapDataSource` serves them. - When the WebSocket is down, those chains are released, so the `SnapDataSource` takes over as a fallback. @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `AccountsApiDataSource` now documents that it serves EVM chains only; non-EVM chains (e.g. Solana) are served by the WebSocket source when enabled, otherwise by the Snap source ([#0000](https://github.com/MetaMask/core/pull/0000)) +- `AccountsApiDataSource` now documents that it serves EVM chains only; non-EVM chains (e.g. Solana) are served by the WebSocket source when enabled, otherwise by the Snap source ([#9430](https://github.com/MetaMask/core/pull/9430)) - `MulticallClient` memoizes `balanceOf` and `getEthBalance` call encodings per account address when building multicall batches, reducing redundant ABI encoding for wallets with many tokens ([#9425](https://github.com/MetaMask/core/pull/9425)) - Bump `@metamask/transaction-controller` from `^68.2.2` to `^68.3.0` ([#9421](https://github.com/MetaMask/core/pull/9421)) - Bump `@metamask/keyring-api` from `^23.3.0` to `^23.5.0` ([#9390](https://github.com/MetaMask/core/pull/9390))