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)) diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts index 02684639727..aaad33d43a0 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,122 @@ 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('skips balance update when asset.decimals is missing', 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, + }); + + // 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: { + 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); + + // No valid updates → response has only updateMode, no assetsBalance + expect(assetsUpdateHandler).toHaveBeenCalledWith({ updateMode: 'merge' }); + + 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..0cad9d7c95b 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,23 @@ 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') + // 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 humanReadableAmount = new BigNumberJS(rawBalanceStr) + .dividedBy(new BigNumberJS(10).pow(asset.decimals)) + .toString(); + assetsBalance[accountId][assetId] = { - amount: balanceAmount, + amount: humanReadableAmount, }; assetsMetadata[assetId] = {