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 });