diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 4f7be51d2c4..a448b73835a 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -816,15 +816,6 @@ "packages/assets-controller/src/data-sources/SnapDataSource.test.ts": { "no-useless-return": { "count": 1 - }, - "typescript/no-unsafe-assignment": { - "count": 6 - }, - "typescript/no-unsafe-member-access": { - "count": 1 - }, - "typescript/no-unsafe-return": { - "count": 1 } }, "packages/assets-controller/src/data-sources/SnapDataSource.ts": { @@ -2553,7 +2544,7 @@ "count": 1 }, "typescript/no-unsafe-assignment": { - "count": 9 + "count": 11 }, "typescript/no-unsafe-member-access": { "count": 2 diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index cb3eae4f399..3e7cd45beeb 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 +- Temporarily extend `SnapDataSource` v6 balance fetches, in a deletable block, to also request every token already stored in balance state for the requested chains, so a rollback to Snaps still refreshes tokens that Accounts API and the websocket already support. Remove that block once balance reads fully move to Accounts API ([#10819](https://github.com/MetaMask/core/pull/10819)) - Bump `@metamask/keyring-api` from `^24.1.0` to `^24.3.0` ([#10792](https://github.com/MetaMask/core/pull/10792), [#10844](https://github.com/MetaMask/core/pull/10844)) - Bump `@metamask/keyring-internal-api` from `^12.1.0` to `^12.1.2` ([#10792](https://github.com/MetaMask/core/pull/10792), [#10844](https://github.com/MetaMask/core/pull/10844)) - Bump `@metamask/keyring-snap-client` from `^10.0.0` to `^10.0.1` ([#10792](https://github.com/MetaMask/core/pull/10792)) diff --git a/packages/assets-controller/docs/decisions/2026-09-23-accounts-api-v6-integration.md b/packages/assets-controller/docs/decisions/2026-09-23-accounts-api-v6-integration.md index 43823477bbf..80f01959199 100644 --- a/packages/assets-controller/docs/decisions/2026-09-23-accounts-api-v6-integration.md +++ b/packages/assets-controller/docs/decisions/2026-09-23-accounts-api-v6-integration.md @@ -142,8 +142,13 @@ The requests differ as well: in full from state. RPC always stamps `full`: resolved tokens get a fresh amount, unread tokens keep the amount already in state, and a chain that resolved nothing is listed in `errors` instead of writing an empty slice. - Snap `#fetchV6` requests visible assets with listed holdings, stamps `full`, - and fills omitted visible assets from current state (`0` if none). An empty + Snap `#fetchV6` requests listed holdings and visible assets, stamps `full`, + and fills omitted visible assets from current state (`0` if none). A separate + temporary block also requests every token already in balance state for those + chains. A token the snap omits stays out of the snapshot. Accounts API and + the websocket already cover more tokens than the snap lists, so a chain that + rolls back to the snap still refreshes them. Delete that block once balance + reads fully move to Accounts API. An empty `getAccountBalances` map (or a thrown fetch) contributes nothing, so last- known amounts are kept. Background Snap and RPC both stamp `full`. `mergeDataResponses` promotes `full` if any source did. @@ -184,7 +189,7 @@ flowchart TB V6A["Subscribe each source with accounts + assigned chains only"] V6WS["Account Activity · event deltas · merge"] V6API["Accounts API · visibility from state · full"] - V6SNAP["Snap · visible assets from state · full snapshot
balance events stay merge"] + V6SNAP["Snap · visible assets · full snapshot
temporary balance-state tokens
balance events stay merge"] V6RPC["RPC · visible assets from state · full"] V6A --> V6WS V6A --> V6API diff --git a/packages/assets-controller/src/data-sources/SnapDataSource.test.ts b/packages/assets-controller/src/data-sources/SnapDataSource.test.ts index 1f33b4588df..2914716ee59 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.test.ts @@ -53,6 +53,8 @@ const MOCK_SOL_ASSET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as Caip19AssetId; const MOCK_SOL_PIN = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:So11111111111111111111111111111111111111112' as Caip19AssetId; +const MOCK_SOL_TRACKED = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Caip19AssetId; const MOCK_STELLAR_ASSET = 'stellar:pubnet/slip44:148' as Caip19AssetId; const MOCK_STELLAR_PIN = 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as Caip19AssetId; @@ -61,11 +63,29 @@ const MOCK_BTC_ASSET = const MOCK_TRON_ASSET = 'tron:728126428/slip44:195' as Caip19AssetId; const CHAIN_MAINNET = 'eip155:1' as ChainId; +type SnapHandleRequestArgument = { + snapId?: string; + origin?: string; + handler?: string; + request?: { + method?: string; + params?: { + id?: string; + assets?: string[]; + }; + }; +}; + +type SnapHandleRequestMock = jest.Mock< + Promise, + [SnapHandleRequestArgument] +>; + type SetupResult = { controller: SnapDataSource; messenger: RootMessenger; mockGetRunnableSnaps: jest.Mock; - mockHandleRequest: jest.Mock; + mockHandleRequest: SnapHandleRequestMock; mockGetPermissions: jest.Mock; assetsUpdateHandler: jest.Mock; activeChainsUpdateHandler: jest.Mock; @@ -162,40 +182,61 @@ function createMockPermissions( } /** - * Creates a mock handler for SnapController:handleRequest + * Assert one `SnapController:handleRequest` call included these fields. + * + * @param mockHandleRequest - Mock registered for SnapController:handleRequest. + * @param expected - Fields the matching call must include. + */ +function expectSnapHandleRequest( + mockHandleRequest: SnapHandleRequestMock, + expected: { + snapId?: string; + origin?: string; + handler?: string; + request: { + method: string; + params: { id: string; assets?: string[] }; + }; + }, +): void { + const matchingCall = mockHandleRequest.mock.calls.find( + ([argument]) => argument.request?.method === expected.request.method, + ); + + expect(matchingCall?.[0]).toMatchObject(expected); +} + +/** + * Creates a mock handler for SnapController:handleRequest. * - * @param accountAssets - Assets to return for keyring_listAccountAssets - * @param balances - Balances to return for keyring_getAccountBalances - * @returns Mock handler function + * @param accountAssets - Assets to return for keyring_listAccountAssets. + * @param balances - Balances to return for keyring_getAccountBalances. + * @returns Mock handler function. */ function createMockHandleRequest( accountAssets: string[] = [], balances: Record = {}, -): jest.Mock { - return jest.fn( - (params: { - request?: { method?: string; params?: { assets?: string[] } }; - }) => { - const { request } = params; - if (request?.method === 'keyring_listAccountAssets') { - return Promise.resolve(accountAssets); - } - if (request?.method === 'keyring_getAccountBalances') { - const requested = request.params?.assets; - if (!requested) { - return Promise.resolve(balances); - } - return Promise.resolve( - Object.fromEntries( - requested - .filter((assetId) => balances[assetId]) - .map((assetId) => [assetId, balances[assetId]]), - ), - ); +): SnapHandleRequestMock { + return jest.fn((params: SnapHandleRequestArgument) => { + const { request } = params; + if (request?.method === 'keyring_listAccountAssets') { + return Promise.resolve(accountAssets); + } + if (request?.method === 'keyring_getAccountBalances') { + const requested = request.params?.assets; + if (!requested) { + return Promise.resolve(balances); } - return Promise.resolve(null); - }, - ); + return Promise.resolve( + Object.fromEntries( + requested + .filter((assetId) => balances[assetId]) + .map((assetId) => [assetId, balances[assetId]]), + ), + ); + } + return Promise.resolve(null); + }); } function setupController( @@ -536,30 +577,24 @@ describe('SnapDataSource', () => { const response = await controller.fetch(createDataRequest()); - // Verify keyring_listAccountAssets was called - expect(mockHandleRequest).toHaveBeenCalledWith( - expect.objectContaining({ - snapId: SOLANA_SNAP_ID, - origin: 'metamask', - handler: 'onKeyringRequest', - request: expect.objectContaining({ - method: 'keyring_listAccountAssets', - params: { id: 'mock-account-id' }, - }), - }), - ); - // Verify keyring_getAccountBalances was called - expect(mockHandleRequest).toHaveBeenCalledWith( - expect.objectContaining({ - snapId: SOLANA_SNAP_ID, - origin: 'metamask', - handler: 'onKeyringRequest', - request: expect.objectContaining({ - method: 'keyring_getAccountBalances', - params: { id: 'mock-account-id', assets: [MOCK_SOL_ASSET] }, - }), - }), - ); + expectSnapHandleRequest(mockHandleRequest, { + snapId: SOLANA_SNAP_ID, + origin: 'metamask', + handler: 'onKeyringRequest', + request: { + method: 'keyring_listAccountAssets', + params: { id: 'mock-account-id' }, + }, + }); + expectSnapHandleRequest(mockHandleRequest, { + snapId: SOLANA_SNAP_ID, + origin: 'metamask', + handler: 'onKeyringRequest', + request: { + method: 'keyring_getAccountBalances', + params: { id: 'mock-account-id', assets: [MOCK_SOL_ASSET] }, + }, + }); expect( response.assetsBalance?.['mock-account-id']?.[MOCK_SOL_ASSET], ).toStrictEqual({ amount: '1000000000' }); @@ -567,9 +602,9 @@ describe('SnapDataSource', () => { cleanup(); }); - it('requests expected visible assets and zero-fills any the snap omitted', async () => { + it('requests the union of snap holdings, visible assets, and balance-state tokens', async () => { const getAssetVisibility = jest.fn().mockReturnValue({ - visibleAssetIds: [MOCK_SOL_ASSET, MOCK_SOL_PIN], + visibleAssetIds: [MOCK_SOL_PIN], hiddenAssetIds: [], }); const { controller, mockHandleRequest, cleanup } = setupController({ @@ -579,9 +614,20 @@ describe('SnapDataSource', () => { accountAssets: [MOCK_SOL_ASSET], balances: { [MOCK_SOL_ASSET]: { amount: '1000000000', unit: 'SOL' }, + [MOCK_SOL_PIN]: { amount: '7', unit: 'WSOL' }, + [MOCK_SOL_TRACKED]: { amount: '3', unit: 'USDC' }, }, isBalanceV6Enabled: () => true, getAssetVisibility, + getAssetsState: () => ({ + ...getDefaultAssetsControllerState(), + assetsBalance: { + 'mock-account-id': { + [MOCK_SOL_TRACKED]: { amount: '1' }, + [MOCK_BTC_ASSET]: { amount: '5' }, + }, + }, + }), }); await new Promise(process.nextTick); @@ -591,22 +637,21 @@ describe('SnapDataSource', () => { ['mock-account-id'], [SOLANA_MAINNET], ); - expect(mockHandleRequest).toHaveBeenCalledWith( - expect.objectContaining({ - request: expect.objectContaining({ - method: 'keyring_getAccountBalances', - params: { - id: 'mock-account-id', - assets: [MOCK_SOL_ASSET, MOCK_SOL_PIN], - }, - }), - }), - ); + expectSnapHandleRequest(mockHandleRequest, { + request: { + method: 'keyring_getAccountBalances', + params: { + id: 'mock-account-id', + assets: [MOCK_SOL_ASSET, MOCK_SOL_PIN, MOCK_SOL_TRACKED], + }, + }, + }); expect(response).toStrictEqual({ assetsBalance: { 'mock-account-id': { [MOCK_SOL_ASSET]: { amount: '1000000000' }, - [MOCK_SOL_PIN]: { amount: '0' }, + [MOCK_SOL_PIN]: { amount: '7' }, + [MOCK_SOL_TRACKED]: { amount: '3' }, }, }, assetsInfo: {}, @@ -616,6 +661,42 @@ describe('SnapDataSource', () => { cleanup(); }); + it('requests expected visible assets and zero-fills any the snap omitted', async () => { + const { controller, mockHandleRequest, cleanup } = setupController({ + installedSnaps: { + [SOLANA_SNAP_ID]: { version: '1.0.0', chainIds: [SOLANA_MAINNET] }, + }, + accountAssets: [MOCK_SOL_ASSET], + balances: { + [MOCK_SOL_ASSET]: { amount: '1000000000', unit: 'SOL' }, + }, + isBalanceV6Enabled: () => true, + getAssetVisibility: () => ({ + visibleAssetIds: [MOCK_SOL_ASSET, MOCK_SOL_PIN], + hiddenAssetIds: [], + }), + }); + await new Promise(process.nextTick); + + const response = await controller.fetch(createDataRequest()); + + expectSnapHandleRequest(mockHandleRequest, { + request: { + method: 'keyring_getAccountBalances', + params: { + id: 'mock-account-id', + assets: [MOCK_SOL_ASSET, MOCK_SOL_PIN], + }, + }, + }); + expect(response.assetsBalance?.['mock-account-id']).toStrictEqual({ + [MOCK_SOL_ASSET]: { amount: '1000000000' }, + [MOCK_SOL_PIN]: { amount: '0' }, + }); + + cleanup(); + }); + it('seeds omitted Stellar visible tokens with empty trustline metadata', async () => { const { controller, cleanup } = setupController({ installedSnaps: { @@ -711,7 +792,7 @@ describe('SnapDataSource', () => { }), }); mockHandleRequest.mockImplementation( - (params: { request?: { method: string } }) => { + (params: SnapHandleRequestArgument) => { if (params.request?.method === 'keyring_listAccountAssets') { return Promise.resolve([MOCK_SOL_ASSET]); } @@ -734,8 +815,8 @@ describe('SnapDataSource', () => { cleanup(); }); - it('fills an omitted visible asset from current state when the snap left it out', async () => { - const { controller, cleanup } = setupController({ + it('leaves out a balance-state token the snap omitted from its response', async () => { + const { controller, mockHandleRequest, cleanup } = setupController({ installedSnaps: { [SOLANA_SNAP_ID]: { version: '1.0.0', chainIds: [SOLANA_MAINNET] }, }, @@ -745,7 +826,7 @@ describe('SnapDataSource', () => { }, isBalanceV6Enabled: () => true, getAssetVisibility: () => ({ - visibleAssetIds: [MOCK_SOL_ASSET, MOCK_SOL_PIN], + visibleAssetIds: [], hiddenAssetIds: [], }), getAssetsState: () => ({ @@ -761,15 +842,23 @@ describe('SnapDataSource', () => { const response = await controller.fetch(createDataRequest()); + expectSnapHandleRequest(mockHandleRequest, { + request: { + method: 'keyring_getAccountBalances', + params: { + id: 'mock-account-id', + assets: [MOCK_SOL_ASSET, MOCK_SOL_PIN], + }, + }, + }); expect(response.assetsBalance?.['mock-account-id']).toStrictEqual({ [MOCK_SOL_ASSET]: { amount: '1000000000' }, - [MOCK_SOL_PIN]: { amount: '42' }, }); cleanup(); }); - it('uses the snap balance for a visible asset omitted from listAccountAssets', async () => { + it('uses the snap balance for a balance-state token omitted from listAccountAssets', async () => { const { controller, cleanup } = setupController({ installedSnaps: { [SOLANA_SNAP_ID]: { version: '1.0.0', chainIds: [SOLANA_MAINNET] }, @@ -781,9 +870,17 @@ describe('SnapDataSource', () => { }, isBalanceV6Enabled: () => true, getAssetVisibility: () => ({ - visibleAssetIds: [MOCK_SOL_ASSET, MOCK_SOL_PIN], + visibleAssetIds: [], hiddenAssetIds: [], }), + getAssetsState: () => ({ + ...getDefaultAssetsControllerState(), + assetsBalance: { + 'mock-account-id': { + [MOCK_SOL_PIN]: { amount: '1' }, + }, + }, + }), }); await new Promise(process.nextTick); @@ -812,22 +909,28 @@ describe('SnapDataSource', () => { visibleAssetIds: [MOCK_SOL_ASSET], hiddenAssetIds: [MOCK_SOL_PIN], }), + getAssetsState: () => ({ + ...getDefaultAssetsControllerState(), + assetsBalance: { + 'mock-account-id': { + [MOCK_SOL_PIN]: { amount: '42' }, + }, + }, + }), }); await new Promise(process.nextTick); const response = await controller.fetch(createDataRequest()); - expect(mockHandleRequest).toHaveBeenCalledWith( - expect.objectContaining({ - request: expect.objectContaining({ - method: 'keyring_getAccountBalances', - params: { - id: 'mock-account-id', - assets: [MOCK_SOL_ASSET], - }, - }), - }), - ); + expectSnapHandleRequest(mockHandleRequest, { + request: { + method: 'keyring_getAccountBalances', + params: { + id: 'mock-account-id', + assets: [MOCK_SOL_ASSET], + }, + }, + }); expect(response.assetsBalance?.['mock-account-id']).toStrictEqual({ [MOCK_SOL_ASSET]: { amount: '1000000000' }, }); @@ -1276,16 +1379,16 @@ describe('SnapDataSource', () => { }); await new Promise(process.nextTick); - const next = jest.fn().mockImplementation(async (context) => context); + const next = jest.fn( + async (middlewareContext: Context) => middlewareContext, + ); const context = createMiddlewareContext(); await controller.assetsMiddleware(context, next); - expect(next).toHaveBeenCalledWith( - expect.objectContaining({ - response: expect.objectContaining({ updateMode: 'full' }), - }), - ); + expect(next.mock.calls[0]?.[0]).toMatchObject({ + response: { updateMode: 'full' }, + }); cleanup(); }); @@ -1302,7 +1405,9 @@ describe('SnapDataSource', () => { }); await new Promise(process.nextTick); - const next = jest.fn().mockResolvedValue(undefined); + const next = jest.fn((middlewareContext: Context) => + Promise.resolve(middlewareContext), + ); const context = createMiddlewareContext({ request: createDataRequest({ chainIds: [SOLANA_MAINNET, CHAIN_MAINNET], @@ -1311,13 +1416,9 @@ describe('SnapDataSource', () => { await controller.assetsMiddleware(context, next); - expect(next).toHaveBeenCalledWith( - expect.objectContaining({ - request: expect.objectContaining({ - chainIds: [CHAIN_MAINNET], - }), - }), - ); + expect(next.mock.calls[0]?.[0]).toMatchObject({ + request: { chainIds: [CHAIN_MAINNET] }, + }); cleanup(); }); diff --git a/packages/assets-controller/src/data-sources/SnapDataSource.ts b/packages/assets-controller/src/data-sources/SnapDataSource.ts index a90c7f2b2d7..1d62da6d57e 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.ts @@ -605,6 +605,13 @@ export class SnapDataSource extends AbstractDataSource< supportedChainIds, hiddenAssetIds, ); + // TEMPORARY: delete this call with `#includeSnapRollbackBalanceStateTokens` after Snaps Migration. + this.#includeSnapRollbackBalanceStateTokens( + accountId, + assetsToFetch, + supportedChainIds, + hiddenAssetIds, + ); // Step 2: Get balances for those specific assets. An empty map is a // failed fetch (or nothing to ask for), not a zero snapshot. Leave @@ -734,6 +741,80 @@ export class SnapDataSource extends AbstractDataSource< return Array.from(assetsToFetch.values()); } + // ============================================================================ + // TEMPORARY: balance-state tokens while a chain can roll back to Snaps + // + // Delete this whole section, and the call marked TEMPORARY in `#fetchV6`, + // once balance reads fully move to Accounts API. + // + // The v6 request is the union of the snap's listed holdings, visible assets, + // and every token already in balance state for the chain. Accounts API and + // the websocket already return more tokens than the snap lists, so a rollback + // to the snap still refreshes that wider set. This section adds the + // balance-state part. Hidden assets stay out. Visible assets stay in the + // request even when they have no balance entry yet. + // ============================================================================ + + /** + * Append balance-state tokens for these chains onto the v6 request. + * Tokens already selected, hidden, or on another chain are left out. + * + * @param accountId - Account whose balances are being fetched. + * @param assetsToFetch - Asset IDs already chosen, mutated in place. + * @param supportedChainIds - Chains this snap was asked about. + * @param hiddenAssetIds - Asset IDs the user hid in this scope. + */ + #includeSnapRollbackBalanceStateTokens( + accountId: string, + assetsToFetch: CaipAssetType[], + supportedChainIds: ChainId[], + hiddenAssetIds: Caip19AssetId[], + ): void { + const supportedChains = new Set(supportedChainIds); + const hidden = new Set( + hiddenAssetIds.map((assetId) => assetId.toLowerCase()), + ); + const present = new Set( + assetsToFetch.map((assetId) => assetId.toLowerCase()), + ); + const balanceAssetIds = Object.keys( + this.#getAssetsState().assetsBalance[accountId] ?? {}, + ); + + for (const assetId of balanceAssetIds) { + const normalizedAssetId = assetId.toLowerCase(); + if (present.has(normalizedAssetId) || hidden.has(normalizedAssetId)) { + continue; + } + + if (!this.#isSnapRollbackAssetOnChains(assetId, supportedChains)) { + continue; + } + + present.add(normalizedAssetId); + assetsToFetch.push(assetId as CaipAssetType); + } + } + + /** + * Whether an asset ID belongs to one of the chains in this request. + * Unparseable IDs are treated as out of scope. + * + * @param assetId - CAIP-19 asset ID. + * @param supportedChains - Chains this snap was asked about. + * @returns True when the asset's chain is in scope. + */ + #isSnapRollbackAssetOnChains( + assetId: string, + supportedChains: Set, + ): boolean { + try { + return supportedChains.has(extractChainFromAssetId(assetId)); + } catch { + return false; + } + } + // ============================================================================ // MIDDLEWARE // ============================================================================ diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index da6f8ddafbf..b4903874f0f 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Publish `AccountActivityService:statusChanged` with status `down` for every tracked chain when enabled chain prefixes change, matching WebSocket disconnect, so those chains are released for snaps feature flag changes ([#10819](https://github.com/MetaMask/core/pull/10819)) + ## [12.2.0] ### Added diff --git a/packages/core-backend/src/ws/AccountActivityService.test.ts b/packages/core-backend/src/ws/AccountActivityService.test.ts index 1b7ec81e55b..9721617df6d 100644 --- a/packages/core-backend/src/ws/AccountActivityService.test.ts +++ b/packages/core-backend/src/ws/AccountActivityService.test.ts @@ -1346,6 +1346,50 @@ describe('AccountActivityService', () => { ); }); + it('switches account channels without publishing tracked chains down', async () => { + jest.useFakeTimers(); + jest.spyOn(Math, 'random').mockReturnValue(0); + try { + await withService(async ({ messenger, rootMessenger, mocks }) => { + const statusChangedEventListener = jest.fn(); + messenger.subscribe( + 'AccountActivityService:statusChanged', + statusChangedEventListener, + ); + const systemCallback = getSystemNotificationCallback(mocks); + + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { chainIds: ['eip155:1'], status: 'up' }, + timestamp: 1000, + }); + jest.advanceTimersByTime(1000); + statusChangedEventListener.mockClear(); + + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + createMockInternalAccount({ address: '0x123abc' }), + ]); + mocks.findSubscriptionsByChannelPrefix.mockReturnValue([ + { unsubscribe: jest.fn() }, + ]); + + rootMessenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + '', + '', + ); + await completeAsyncOperations(); + + expect(mocks.findSubscriptionsByChannelPrefix).toHaveBeenCalled(); + expect(statusChangedEventListener).not.toHaveBeenCalled(); + }); + } finally { + jest.restoreAllMocks(); + jest.useRealTimers(); + } + }); + it('should resubscribe to selected account when WebSocket connects', async () => { await withService( { accountAddress: '0x123abc' }, @@ -1471,6 +1515,122 @@ describe('AccountActivityService', () => { }); }); + it('publishes every tracked chain as down when a migration flag change unsubscribes account activity', async () => { + jest.useFakeTimers(); + jest.spyOn(Math, 'random').mockReturnValue(0); + try { + await withService(async ({ messenger, rootMessenger, mocks }) => { + const statusChangedEventListener = jest.fn(); + messenger.subscribe( + 'AccountActivityService:statusChanged', + statusChangedEventListener, + ); + const systemCallback = getSystemNotificationCallback(mocks); + const solanaChainId = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + const tronChainId = 'tron:mainnet'; + + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { + chainIds: ['eip155:1', solanaChainId, tronChainId], + status: 'up', + }, + timestamp: 1000, + }); + jest.advanceTimersByTime(1000); + statusChangedEventListener.mockClear(); + + mocks.getFeatureFlagState.mockReturnValue(featureFlagState(0)); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + createMockInternalAccount({ address: '0xevmaccount' }), + ]); + mocks.findSubscriptionsByChannelPrefix.mockReturnValue([ + { unsubscribe: jest.fn() }, + ]); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + featureFlagState(0), + [], + ); + await completeAsyncOperations(); + + expect(statusChangedEventListener).toHaveBeenCalledTimes(1); + expect(statusChangedEventListener).toHaveBeenCalledWith({ + chainIds: ['eip155:1', solanaChainId, tronChainId], + status: 'down', + timestamp: expect.any(Number), + }); + expect(mocks.findSubscriptionsByChannelPrefix).toHaveBeenCalled(); + + // The backend decides which chains are up again after resubscribe. + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { + chainIds: ['eip155:1', solanaChainId], + status: 'up', + }, + timestamp: 2000, + }); + jest.advanceTimersByTime(1000); + expect(statusChangedEventListener).toHaveBeenCalledTimes(2); + expect(statusChangedEventListener).toHaveBeenLastCalledWith({ + chainIds: ['eip155:1', solanaChainId], + status: 'up', + }); + }); + } finally { + jest.restoreAllMocks(); + jest.useRealTimers(); + } + }); + + it('flushes every pending chain as down when a migration flag change unsubscribes account activity', async () => { + jest.useFakeTimers(); + jest.spyOn(Math, 'random').mockReturnValue(0); + try { + await withService(async ({ messenger, rootMessenger, mocks }) => { + const statusChangedEventListener = jest.fn(); + messenger.subscribe( + 'AccountActivityService:statusChanged', + statusChangedEventListener, + ); + const systemCallback = getSystemNotificationCallback(mocks); + const solanaChainId = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + + systemCallback({ + event: 'system-notification', + channel: 'system-notifications.v1.account-activity.v1', + data: { chainIds: ['eip155:1', solanaChainId], status: 'up' }, + timestamp: 1000, + }); + + mocks.getFeatureFlagState.mockReturnValue(featureFlagState(0)); + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + featureFlagState(0), + [], + ); + await completeAsyncOperations(); + + expect(statusChangedEventListener).toHaveBeenCalledTimes(1); + expect(statusChangedEventListener).toHaveBeenCalledWith({ + chainIds: ['eip155:1', solanaChainId], + status: 'down', + timestamp: expect.any(Number), + }); + + jest.advanceTimersByTime(1000); + expect(statusChangedEventListener).toHaveBeenCalledTimes(1); + }); + } finally { + jest.restoreAllMocks(); + jest.useRealTimers(); + } + }); + it('does not resubscribe on flag change when the websocket is not connected', async () => { await withService(async ({ mocks, rootMessenger }) => { mocks.getConnectionInfo.mockReturnValue({ diff --git a/packages/core-backend/src/ws/AccountActivityService.ts b/packages/core-backend/src/ws/AccountActivityService.ts index 76c39dd7148..1cc44c5b903 100644 --- a/packages/core-backend/src/ws/AccountActivityService.ts +++ b/packages/core-backend/src/ws/AccountActivityService.ts @@ -422,7 +422,8 @@ export class AccountActivityService { ); try { - // First, unsubscribe from all current account activity subscriptions to avoid multiple subscriptions + // Drop the previous account's channels without releasing tracked chains. + // Chain status is unchanged on an account switch. await this.#unsubscribeFromAllAccountActivity(); // Subscribe to the new selected accounts in CAIP-10 format @@ -536,34 +537,52 @@ export class AccountActivityService { // The system notification will automatically provide the list of chains that are up await this.#subscribeToSelectedAccount(); } else if (state === WebSocketState.DISCONNECTED) { - if (this.#statusChangeDebouncer.timer) { - clearTimeout(this.#statusChangeDebouncer.timer); - this.#statusChangeDebouncer.timer = null; - } + this.#publishTrackedChainsDown( + 'WebSocket disconnection - Published tracked chains as down', + ); + } + } - const chainsToMarkDown = new Set(this.#chainsUp); - for (const chainId of this.#statusChangeDebouncer.pendingChanges.keys()) { - chainsToMarkDown.add(chainId); - } - this.#statusChangeDebouncer.pendingChanges.clear(); - - if (chainsToMarkDown.size > 0) { - const chainIds = Array.from(chainsToMarkDown); - this.#messenger.publish(`AccountActivityService:statusChanged`, { - chainIds, - status: 'down', - timestamp: Date.now(), - }); + /** + * Publish every tracked chain, including any status still buffered, as + * `down`, then forget them. + * + * Used on WebSocket disconnect and when enabled chain prefixes change. + * Account switches leave tracked chains alone so AssetsController does not + * rebuild active-chain subscriptions. The next system notification restores + * chains that are up after resubscribe or reconnect. + * + * @param reason - Log message describing why the chains were released. + */ + #publishTrackedChainsDown(reason: string): void { + if (this.#statusChangeDebouncer.timer) { + clearTimeout(this.#statusChangeDebouncer.timer); + this.#statusChangeDebouncer.timer = null; + } - log('WebSocket disconnection - Published tracked chains as down', { - count: chainIds.length, - chains: chainIds, - }); + const chainsToMarkDown = new Set(this.#chainsUp); + for (const chainId of this.#statusChangeDebouncer.pendingChanges.keys()) { + chainsToMarkDown.add(chainId); + } + this.#statusChangeDebouncer.pendingChanges.clear(); - // Clear the tracking set since all chains are now down - this.#chainsUp.clear(); - } + if (chainsToMarkDown.size === 0) { + return; } + + const chainIds = Array.from(chainsToMarkDown); + this.#messenger.publish(`AccountActivityService:statusChanged`, { + chainIds, + status: 'down', + timestamp: Date.now(), + }); + + log(reason, { + count: chainIds.length, + chains: chainIds, + }); + + this.#chainsUp.clear(); } // ============================================================================= @@ -584,8 +603,9 @@ export class AccountActivityService { } /** - * Unsubscribe from all account activity subscriptions for this service - * Finds all channels matching the service's namespace and unsubscribes from them + * Unsubscribe from all account activity subscriptions for this service. + * Finds all channels matching the service's namespace and unsubscribes from + * them. */ async #unsubscribeFromAllAccountActivity(): Promise { const accountActivitySubscriptions = this.#messenger.call( @@ -593,7 +613,6 @@ export class AccountActivityService { this.#options.subscriptionNamespace, ); - // Unsubscribe from all matching subscriptions for (const subscription of accountActivitySubscriptions) { await subscription.unsubscribe(); } @@ -660,8 +679,9 @@ export class AccountActivityService { /** * Handle remote feature flag changes: if the set of enabled chain prefixes - * changed while connected, resubscribe the selected account so new chains - * are picked up and disabled ones are dropped. + * changed while connected, release every tracked chain, unsubscribe, then + * resubscribe with the new prefixes. The next system notification restores + * chains that are up. */ async #handleFeatureFlagsStateChange(): Promise { try { @@ -674,6 +694,9 @@ export class AccountActivityService { } await this.#unsubscribeFromAllAccountActivity(); + this.#publishTrackedChainsDown( + 'Feature flag change - Published tracked chains as down', + ); await this.#subscribeToSelectedAccount(); } catch (error) { log('Feature flag change handling failed', { error });