From 20944d202e3d86bd4e48c50df84af7feacd6e276 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 2 Sep 2026 18:17:38 +0200 Subject: [PATCH 1/3] fix: run RPC fallback on Accounts API poll updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale-balance re-read only ran in the forced getAssets fast pipeline, so while the wallet sat open, 30s Accounts API polls kept committing responses that omit unindexed tokens with a plain merge — the stale amount survived until the next forced refresh (unlock, transaction, manual refresh). handleAssetsUpdate now inserts RpcFallbackMiddleware into the enrichment pipeline for AccountsApiDataSource updates, after graduation and before detection, mirroring the fast pipeline. WebSocket updates are excluded (incremental single-asset pushes — absence is not staleness) and RPC/Snap updates are excluded so RPC never re-triggers itself. --- packages/assets-controller/CHANGELOG.md | 1 + .../src/AssetsController.test.ts | 71 +++++++++++++++++++ .../assets-controller/src/AssetsController.ts | 2 + 3 files changed, 74 insertions(+) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 37d4f67fbd..e656def9ff 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. - Balances from chains the RPC read itself failed on are discarded instead of merged, so a transient RPC failure can no longer overwrite a correct upstream balance with the failure stub's native `0` (or, previously, falsely clear the chain's error as "recovered"). The chain's error is kept only when it was already errored upstream. - `RpcDataSource.assetsMiddleware` now propagates per-chain fetch errors onto the pipeline response (previously it only used them internally), which is what lets `RpcFallbackMiddleware` identify the failed chains. + - The fallback also runs on Accounts API poll updates (`handleAssetsUpdate`), not only in the forced `getAssets` fast pipeline, so a stale amount no longer survives between forced refreshes while the wallet sits open. 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. ## [14.0.3] diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 4917ed22ae..535aa28f08 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 70187a8a0a..7ec0003791 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -3901,6 +3901,7 @@ export class AssetsController extends BaseController< sourceId === 'AccountActivityDataSource' && this.#isBasicFunctionality(); + const shouldRunRpcFallback = sourceId === 'AccountsApiDataSource'; const enrichmentSources: AssetsDataSource[] = [ ...(shouldGraduateCustomAssets ? [this.#customAssetGraduationMiddleware] @@ -3914,6 +3915,7 @@ export class AssetsController extends BaseController< }, ] : []), + ...(shouldRunRpcFallback ? [this.#rpcFallbackMiddleware] : []), this.#detectionMiddleware, ]; if (this.#isBasicFunctionality()) { From 9ff47f3e8649d7542baac22b9e6a1871d8cc6fbf Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 2 Sep 2026 18:22:27 +0200 Subject: [PATCH 2/3] fix: fix changelog --- packages/assets-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index e656def9ff..6bb8d288e3 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. - Balances from chains the RPC read itself failed on are discarded instead of merged, so a transient RPC failure can no longer overwrite a correct upstream balance with the failure stub's native `0` (or, previously, falsely clear the chain's error as "recovered"). The chain's error is kept only when it was already errored upstream. - `RpcDataSource.assetsMiddleware` now propagates per-chain fetch errors onto the pipeline response (previously it only used them internally), which is what lets `RpcFallbackMiddleware` identify the failed chains. - - The fallback also runs on Accounts API poll updates (`handleAssetsUpdate`), not only in the forced `getAssets` fast pipeline, so a stale amount no longer survives between forced refreshes while the wallet sits open. 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. + - The fallback also runs on Accounts API poll updates (`handleAssetsUpdate`), not only in the forced `getAssets` fast pipeline, so a stale amount no longer survives between forced refreshes while the wallet sits open. 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 ([#10078](https://github.com/MetaMask/core/pull/10078)) ## [14.0.3] From 195fd2c6cf4be3ab1915f599e2acd32dd2ed951c Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 2 Sep 2026 18:27:10 +0200 Subject: [PATCH 3/3] fix: give #10078 its own changelog entry Co-authored-by: Cursor --- packages/assets-controller/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 6bb8d288e3..d5c7a85fe8 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -18,11 +18,12 @@ 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 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. - Balances from chains the RPC read itself failed on are discarded instead of merged, so a transient RPC failure can no longer overwrite a correct upstream balance with the failure stub's native `0` (or, previously, falsely clear the chain's error as "recovered"). The chain's error is kept only when it was already errored upstream. - `RpcDataSource.assetsMiddleware` now propagates per-chain fetch errors onto the pipeline response (previously it only used them internally), which is what lets `RpcFallbackMiddleware` identify the failed chains. - - The fallback also runs on Accounts API poll updates (`handleAssetsUpdate`), not only in the forced `getAssets` fast pipeline, so a stale amount no longer survives between forced refreshes while the wallet sits open. 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 ([#10078](https://github.com/MetaMask/core/pull/10078)) ## [14.0.3]