Skip to content
5 changes: 0 additions & 5 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ 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))
- `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))

## [9.1.0]

Expand Down
102 changes: 68 additions & 34 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1720,113 +1720,147 @@ describe('AssetsController', () => {
});
});

it('does not let subscription polling overwrite a recent websocket balance update', async () => {
it('replaces state when full update has authoritative data', async () => {
const initialState: Partial<AssetsControllerState> = {
assetsBalance: {
[MOCK_ACCOUNT_ID]: {
[MOCK_ASSET_ID]: { amount: '8.185173' },
[MOCK_ASSET_ID]: { amount: '1' },
[MOCK_NATIVE_ASSET_ID]: { amount: '0.5' },
},
},
};

await withController({ state: initialState }, async ({ controller }) => {
await controller.handleAssetsUpdate(
{
updateMode: 'full',
assetsBalance: {
[MOCK_ACCOUNT_ID]: {
[MOCK_ASSET_ID]: { amount: '7.185173' },
},
},
},
'BackendWebsocketDataSource',
);

await controller.handleAssetsUpdate(
{
assetsBalance: {
[MOCK_ACCOUNT_ID]: {
[MOCK_ASSET_ID]: { amount: '8.185173' },
[MOCK_NATIVE_ASSET_ID]: { amount: '2' },
},
},
},
'AccountsApiDataSource',
'TestSource',
);

// Full update is authoritative — the ERC20 that wasn't in the response is removed
expect(
controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID],
).toStrictEqual({ amount: '7.185173' });
).toBeUndefined();
expect(
controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[
MOCK_NATIVE_ASSET_ID
],
).toStrictEqual({ amount: '2' });
});
});

it('applies getAssets forceUpdate over a recent websocket balance update', async () => {
it('overlays balances without removing tokens when update mode is used', async () => {
const initialState: Partial<AssetsControllerState> = {
assetsBalance: {
[MOCK_ACCOUNT_ID]: {
[MOCK_ASSET_ID]: { amount: '8.185173' },
[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_ASSET_ID]: { amount: '7.185173' },
[MOCK_NATIVE_ASSET_ID]: { amount: '0.000389261286724' },
},
},
assetsInfo: {
[MOCK_ASSET_ID]: {
type: 'erc20',
symbol: 'REPLACED',
name: 'Replaced',
decimals: 18,
},
},
},
'BackendWebsocketDataSource',
'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('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]: {
[MOCK_ASSET_ID]: { amount: '8.185173' },
[avaxNative]: { amount: '1.5' },
},
},
assetsInfo: {
[avaxNative]: {
type: 'native',
symbol: 'AVAX',
name: 'Avalanche',
decimals: 18,
},
},
},
'getAssets:forceUpdate',
'RpcDataSource',
);

expect(controller.state.assetsInfo[avaxNative]?.symbol).toBe('AVAX');
expect(
controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID],
).toStrictEqual({ amount: '8.185173' });
controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[avaxNative],
).toStrictEqual({ amount: '1.5' });
});
});

it('replaces state when full update has authoritative data', async () => {
it('updates balance amounts present in update mode response', async () => {
const initialState: Partial<AssetsControllerState> = {
assetsBalance: {
[MOCK_ACCOUNT_ID]: {
[MOCK_ASSET_ID]: { amount: '1' },
[MOCK_NATIVE_ASSET_ID]: { amount: '0.5' },
},
},
};

await withController({ state: initialState }, async ({ controller }) => {
await controller.handleAssetsUpdate(
{
updateMode: 'full',
updateMode: 'update',
assetsBalance: {
[MOCK_ACCOUNT_ID]: {
[MOCK_NATIVE_ASSET_ID]: { amount: '2' },
[MOCK_ASSET_ID]: { amount: '2' },
},
},
},
'TestSource',
);

// Full update is authoritative — the ERC20 that wasn't in the response is removed
expect(
controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID],
).toBeUndefined();
expect(
controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[
MOCK_NATIVE_ASSET_ID
],
).toStrictEqual({ amount: '2' });
});
});
Expand Down
Loading
Loading