diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index b6ac2dd66d..8c69404c1d 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Run `RpcFallbackMiddleware` on Accounts API poll updates (`handleAssetsUpdate`), not only in the forced `getAssets` fast pipeline, so a stale amount for tokens omitted from poll responses no longer survives between forced refreshes while the wallet sits open ([#10078](https://github.com/MetaMask/core/pull/10078)) + - WebSocket, RPC, and Snap updates are excluded: WebSocket pushes are incremental single-asset updates where absence is not staleness, and RPC/Snap updates must not re-trigger RPC - Fix stale balances shown right after a transaction confirms: the post-confirmation refresh now calls `getAssets` with `bypassServerCache: true`, since WebSocket events do not invalidate the Accounts API's server-side cache and a plain refetch within its 60s window returns the pre-transaction snapshot ([#10068](https://github.com/MetaMask/core/pull/10068)) - Fix stale balances surviving in state when the Accounts API returns no entry for an asset it does not index (or reports an untrusted `0`), which the `merge` update kept as the previous amount ([#10061](https://github.com/MetaMask/core/pull/10061)) - `RpcFallbackMiddleware` now re-reads EVM assets tracked in state (`assetsBalance` or `customAssets`) whose balance is empty in the current response, passing them to `RpcDataSource` as `customAssets`, in addition to its existing retry of chains in `response.errors`. Staking vault assets and assets on chains outside the request or the account's supported set are excluded. diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 4c8a7e4c8f..4efe9e6257 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -23,6 +23,7 @@ import type { import type { AccountsApiDataSourceConfig } from './data-sources/AccountsApiDataSource.js'; import type { PriceDataSourceConfig } from './data-sources/PriceDataSource.js'; import { PriceDataSource } from './data-sources/PriceDataSource.js'; +import { RpcDataSource } from './data-sources/RpcDataSource.js'; import { TokenDataSource } from './data-sources/TokenDataSource.js'; import { buildDefaultAssetsInfo } from './defaults.js'; import type { Assets3346MigrationState } from './migrations/healAssetsInfoMetadata.js'; @@ -1763,6 +1764,76 @@ describe('AssetsController', () => { }); describe('handleAssetsUpdate', () => { + it('re-reads tracked assets an Accounts API poll left empty via the RPC fallback', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '1000' } }, + }, + }; + const rpcMiddleware = jest.fn( + async (ctx: unknown, next: (ctx: unknown) => Promise) => + next(ctx), + ); + const rpcMiddlewareGetter = jest + .spyOn(RpcDataSource.prototype, 'assetsMiddleware', 'get') + .mockReturnValue(rpcMiddleware as never); + + await withController({ state: initialState }, async ({ controller }) => { + const pollRequest: DataRequest = { + accountsWithSupportedChains: [ + { + account: createMockInternalAccount(), + supportedChains: ['eip155:1' as ChainId], + }, + ], + chainIds: ['eip155:1' as ChainId], + dataTypes: ['balance'], + }; + + // The poll response omits MOCK_ASSET_ID even though state tracks it — + // the fallback must hand it to RPC for an on-chain re-read. + await controller.handleAssetsUpdate( + { + updateMode: 'merge', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, + }, + }, + }, + 'AccountsApiDataSource', + pollRequest, + ); + + expect(rpcMiddleware).toHaveBeenCalledTimes(1); + const [rpcCtx] = rpcMiddleware.mock.calls[0] as [ + { request: DataRequest }, + ]; + expect(rpcCtx.request.chainIds).toStrictEqual(['eip155:1']); + expect(rpcCtx.request.customAssets).toContain(MOCK_ASSET_ID); + + // Same update from the WebSocket source must NOT trigger the + // fallback: its pushes are incremental single-asset updates, so an + // absent asset is not stale there. + rpcMiddleware.mockClear(); + await controller.handleAssetsUpdate( + { + updateMode: 'merge', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, + }, + }, + }, + 'AccountActivityDataSource', + pollRequest, + ); + expect(rpcMiddleware).not.toHaveBeenCalled(); + }); + + rpcMiddlewareGetter.mockRestore(); + }); + it('does not fail when parent trace rejects after enrichment completes', async () => { const traceMock = jest .fn() diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 46ae9495ff..a277f7ab3d 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -3912,6 +3912,7 @@ export class AssetsController extends BaseController< sourceId === 'AccountActivityDataSource' && this.#isBasicFunctionality(); + const shouldRunRpcFallback = sourceId === 'AccountsApiDataSource'; const enrichmentSources: AssetsDataSource[] = [ ...(shouldGraduateCustomAssets ? [this.#customAssetGraduationMiddleware] @@ -3925,6 +3926,7 @@ export class AssetsController extends BaseController< }, ] : []), + ...(shouldRunRpcFallback ? [this.#rpcFallbackMiddleware] : []), this.#detectionMiddleware, ]; if (this.#isBasicFunctionality()) {