From 2e038821cc8565b6874ddec3e7b8286186132524 Mon Sep 17 00:00:00 2001 From: salimtb Date: Tue, 24 Feb 2026 15:02:56 +0100 Subject: [PATCH 1/3] fix: Fix WebSocket balance conversion and add unit tests --- .../BackendWebsocketDataSource.test.ts | 131 +++++++++++++++++- .../BackendWebsocketDataSource.ts | 13 +- 2 files changed, 139 insertions(+), 5 deletions(-) diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts index 02684639727..1b97738536b 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts @@ -593,11 +593,12 @@ describe('BackendWebsocketDataSource', () => { notificationCallback(notification); await new Promise(process.nextTick); + // Raw 10e18 wei (0x8ac7230489e80000) with 18 decimals → human-readable "10" expect(assetsUpdateHandler).toHaveBeenCalledWith( expect.objectContaining({ assetsBalance: expect.objectContaining({ 'mock-account-id': expect.objectContaining({ - 'eip155:8453/slip44:60': { amount: '10000000000000000000' }, + 'eip155:8453/slip44:60': { amount: '10' }, }), }), assetsInfo: expect.objectContaining({ @@ -659,12 +660,13 @@ describe('BackendWebsocketDataSource', () => { notificationCallback(notification); await new Promise(process.nextTick); + // Raw 1000000 (1 USDC) with 6 decimals → human-readable "1" expect(assetsUpdateHandler).toHaveBeenCalledWith( expect.objectContaining({ assetsBalance: expect.objectContaining({ 'mock-account-id': expect.objectContaining({ 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { - amount: '1000000', + amount: '1', }, }), }), @@ -682,6 +684,131 @@ describe('BackendWebsocketDataSource', () => { controller.destroy(); }); + it('converts raw WebSocket balance (hex) to human-readable using asset decimals', async () => { + const { controller, wsSubscribeMock, assetsUpdateHandler } = + setupController({ + initialActiveChains: [CHAIN_MAINNET], + connectionState: WebSocketState.CONNECTED, + }); + + let notificationCallback: ( + notification: ServerNotificationMessage, + ) => void = () => undefined; + + wsSubscribeMock.mockImplementation(({ callback }) => { + notificationCallback = callback; + return Promise.resolve(createMockWsSubscription()); + }); + + await controller.subscribe({ + subscriptionId: 'sub-1', + request: createDataRequest(), + isUpdate: false, + onAssetsUpdate: assetsUpdateHandler, + }); + + // 0x26f0e5 = 2552037 raw; USDC 6 decimals → 2.552037 + const notification = createMockNotification({ + channel: `account-activity.v1.eip155:0:${MOCK_ADDRESS.toLowerCase()}`, + data: { + address: MOCK_ADDRESS, + tx: { chain: CHAIN_MAINNET }, + updates: [ + { + asset: { + type: 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + unit: 'USDC', + decimals: 6, + }, + postBalance: { + amount: '0x26f0e5', + }, + }, + ], + }, + }); + + notificationCallback(notification); + await new Promise(process.nextTick); + + // assetId key is as in notification (mixed case) + expect(assetsUpdateHandler).toHaveBeenCalledWith( + expect.objectContaining({ + assetsBalance: expect.objectContaining({ + 'mock-account-id': expect.objectContaining({ + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': { + amount: '2.552037', + }, + }), + }), + }), + ); + + controller.destroy(); + }); + + it('uses 18 decimals when asset.decimals is missing (fallback)', async () => { + const { controller, wsSubscribeMock, assetsUpdateHandler } = + setupController({ + initialActiveChains: [CHAIN_MAINNET], + connectionState: WebSocketState.CONNECTED, + }); + + let notificationCallback: ( + notification: ServerNotificationMessage, + ) => void = () => undefined; + + wsSubscribeMock.mockImplementation(({ callback }) => { + notificationCallback = callback; + return Promise.resolve(createMockWsSubscription()); + }); + + await controller.subscribe({ + subscriptionId: 'sub-1', + request: createDataRequest(), + isUpdate: false, + onAssetsUpdate: assetsUpdateHandler, + }); + + // 10^18 raw, no decimals on asset → fallback 18 → human-readable "1" + const notification = createMockNotification({ + channel: `account-activity.v1.eip155:0:${MOCK_ADDRESS.toLowerCase()}`, + data: { + address: MOCK_ADDRESS, + tx: { chain: CHAIN_MAINNET }, + updates: [ + { + asset: { + type: 'eip155:1/erc20:0x0000000000000000000000000000000000000001', + unit: 'UNKNOWN', + decimals: undefined, + }, + postBalance: { + amount: '1000000000000000000', + }, + }, + ], + }, + }); + + notificationCallback(notification); + await new Promise(process.nextTick); + + expect(assetsUpdateHandler).toHaveBeenCalledWith( + expect.objectContaining({ + assetsBalance: expect.objectContaining({ + 'mock-account-id': expect.objectContaining({ + 'eip155:1/erc20:0x0000000000000000000000000000000000000001': { + amount: '1', + }, + }), + }), + }), + ); + + controller.destroy(); + }); + it('ignores notification with missing data', async () => { const { controller, wsSubscribeMock, assetsUpdateHandler } = setupController({ diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts index 9d6c617109d..d4281edd4fe 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts @@ -13,6 +13,7 @@ import { KnownCaipNamespace, toCaipChainId, } from '@metamask/utils'; +import BigNumberJS from 'bignumber.js'; import { AbstractDataSource } from './AbstractDataSource'; import type { @@ -607,13 +608,19 @@ export class BackendWebsocketDataSource extends AbstractDataSource< const isNative = asset.type.includes('/slip44:'); const tokenType = isNative ? 'native' : 'erc20'; - // Parse balance amount (already in hex format like "0xc350") - const balanceAmount = postBalance.amount.startsWith('0x') + // Parse raw balance (hex like "0x26f0e5" or decimal string) + const rawBalanceStr = postBalance.amount.startsWith('0x') ? BigInt(postBalance.amount).toString() : postBalance.amount; + // Convert to human-readable using asset decimals (match RpcDataSource / pipeline format) + const decimals = asset.decimals ?? 18; + const humanReadableAmount = new BigNumberJS(rawBalanceStr) + .dividedBy(new BigNumberJS(10).pow(decimals)) + .toString(); + assetsBalance[accountId][assetId] = { - amount: balanceAmount, + amount: humanReadableAmount, }; assetsMetadata[assetId] = { From aa7ff64ab06e70ee2289f806565620809c593473 Mon Sep 17 00:00:00 2001 From: salimtb Date: Tue, 24 Feb 2026 15:07:15 +0100 Subject: [PATCH 2/3] fix: add changelog --- packages/assets-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index bc0966972b5..2995daa7616 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Convert WebSocket balance updates in `BackendWebsocketDataSource` from raw smallest-units to human-readable amounts using asset decimals (same behavior as RPC/Accounts API), so `assetsBalance` remains consistent across data sources ([#8032](https://github.com/MetaMask/core/pull/8032)) - Include all assets from balance and each account's custom assets from state in `detectedAssets`, so prices and metadata are fetched for existing assets and custom tokens (previously only assets without metadata were included, so existing assets did not get prices) ([#8021](https://github.com/MetaMask/core/pull/8021)) - Request `includeAggregators: true` when fetching token metadata from the v3 assets API so aggregator data is returned and stored in `assetsInfo` ([#8021](https://github.com/MetaMask/core/pull/8021)) From ac51deb6b1d145e685fe59df483be3a0488e8cc9 Mon Sep 17 00:00:00 2001 From: salimtb Date: Tue, 24 Feb 2026 15:53:08 +0100 Subject: [PATCH 3/3] fix: fix PR comments --- .../BackendWebsocketDataSource.test.ts | 17 ++++------------- .../data-sources/BackendWebsocketDataSource.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts index 1b97738536b..aaad33d43a0 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts @@ -747,7 +747,7 @@ describe('BackendWebsocketDataSource', () => { controller.destroy(); }); - it('uses 18 decimals when asset.decimals is missing (fallback)', async () => { + it('skips balance update when asset.decimals is missing', async () => { const { controller, wsSubscribeMock, assetsUpdateHandler } = setupController({ initialActiveChains: [CHAIN_MAINNET], @@ -770,7 +770,7 @@ describe('BackendWebsocketDataSource', () => { onAssetsUpdate: assetsUpdateHandler, }); - // 10^18 raw, no decimals on asset → fallback 18 → human-readable "1" + // No decimals on asset → update is skipped (we assume decimals are always present) const notification = createMockNotification({ channel: `account-activity.v1.eip155:0:${MOCK_ADDRESS.toLowerCase()}`, data: { @@ -794,17 +794,8 @@ describe('BackendWebsocketDataSource', () => { notificationCallback(notification); await new Promise(process.nextTick); - expect(assetsUpdateHandler).toHaveBeenCalledWith( - expect.objectContaining({ - assetsBalance: expect.objectContaining({ - 'mock-account-id': expect.objectContaining({ - 'eip155:1/erc20:0x0000000000000000000000000000000000000001': { - amount: '1', - }, - }), - }), - }), - ); + // No valid updates → response has only updateMode, no assetsBalance + expect(assetsUpdateHandler).toHaveBeenCalledWith({ updateMode: 'merge' }); controller.destroy(); }); diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts index d4281edd4fe..0cad9d7c95b 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts @@ -608,15 +608,19 @@ export class BackendWebsocketDataSource extends AbstractDataSource< const isNative = asset.type.includes('/slip44:'); const tokenType = isNative ? 'native' : 'erc20'; + // We assume decimals are always present; skip malformed updates + if (asset.decimals === undefined) { + continue; + } + // Parse raw balance (hex like "0x26f0e5" or decimal string) const rawBalanceStr = postBalance.amount.startsWith('0x') ? BigInt(postBalance.amount).toString() : postBalance.amount; // Convert to human-readable using asset decimals (match RpcDataSource / pipeline format) - const decimals = asset.decimals ?? 18; const humanReadableAmount = new BigNumberJS(rawBalanceStr) - .dividedBy(new BigNumberJS(10).pow(decimals)) + .dividedBy(new BigNumberJS(10).pow(asset.decimals)) .toString(); assetsBalance[accountId][assetId] = {