From 880285ece8e6dfd08270be04fb7eb0d43639ab08 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 2 Sep 2026 02:09:41 +0200 Subject: [PATCH 1/3] feat(assets-controller): bypass Accounts API server cache after transaction confirmation The Accounts API keeps a 60s server-side cache keyed on the full URL, and WebSocket events do not invalidate it, so a refresh right after a transaction confirms can be answered with the pre-transaction snapshot. forceUpdate only skips the client-side query cache. getAssets now accepts a bypassCache option; when set, AccountsApiDataSource asks the core-backend client to append a random cacheBuster query param (the mechanism sanctioned by the API team so these requests stay traceable in logs) and skip the client-side cache. The transaction-confirmed refresh passes it; the unapproved-transaction refresh intentionally does not, to keep cache misses rare. --- packages/assets-controller/CHANGELOG.md | 9 ++ .../src/AssetsController.test.ts | 1 + .../assets-controller/src/AssetsController.ts | 25 ++++- .../AccountsApiDataSource.test.ts | 30 ++++++ .../src/data-sources/AccountsApiDataSource.ts | 22 ++++- packages/assets-controller/src/types.ts | 8 ++ packages/core-backend/CHANGELOG.md | 5 + .../src/api/accounts/client.test.ts | 91 +++++++++++++++++++ .../core-backend/src/api/accounts/client.ts | 23 ++++- packages/core-backend/src/api/shared-types.ts | 9 ++ 10 files changed, 214 insertions(+), 9 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 45acf6cd067..b856cff93b6 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,10 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `bypassCache` option to `getAssets` + - When true (only meaningful together with `forceUpdate`), the Accounts API request also bypasses the API's server-side 60s cache via a random `cacheBuster` query param, instead of only the client-side query cache. + ### Changed - Bump `@metamask/transaction-controller` from `^69.6.1` to `^69.7.0` ([#10046](https://github.com/MetaMask/core/pull/10046)) +### Fixed + +- Fix stale balances shown right after a transaction confirms: the post-confirmation refresh now calls `getAssets` with `bypassCache: 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 + ## [14.0.3] ### Changed diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 59e80e44428..8c25ca5b114 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -2846,6 +2846,7 @@ describe('AssetsController', () => { { chainIds: ['eip155:42161'], forceUpdate: true, + bypassCache: true, }, ); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 8754c27d3d0..a23acc2e4f1 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1210,10 +1210,15 @@ export class AssetsController extends BaseController< // Skipped for chains covered by AccountActivity (real-time WS updates). // RpcDataSource also listens for transactionConfirmed, but only refreshes // chains it owns via an active subscription. + // bypassCache defeats the Accounts API's 60s server-side cache, which + // WebSocket events do not invalidate — without it the refresh can be + // answered with the pre-transaction snapshot. this.messenger.subscribe( 'TransactionController:transactionConfirmed', (transactionMeta: TransactionMeta) => { - this.#refreshAssetsForTransaction(transactionMeta); + this.#refreshAssetsForTransaction(transactionMeta, { + bypassCache: true, + }); }, ); // Start tracking only after the account tree is fully built. Unlock can @@ -1234,8 +1239,15 @@ export class AssetsController extends BaseController< * chain is already covered by AccountActivity (real-time WebSocket balances). * * @param transactionMeta - The transaction that triggered the refresh. + * @param options - Refresh options. + * @param options.bypassCache - Also bypass server-side HTTP caches. Used on + * transaction confirmation, where a cached Accounts API response would still + * hold the pre-transaction balance. */ - #refreshAssetsForTransaction(transactionMeta: TransactionMeta): void { + #refreshAssetsForTransaction( + transactionMeta: TransactionMeta, + options?: { bypassCache?: boolean }, + ): void { const hexChainId = transactionMeta.chainId; if (!hexChainId) { return; @@ -1268,6 +1280,7 @@ export class AssetsController extends BaseController< this.getAssets([matchedAccount], { chainIds: [caipChainId], forceUpdate: true, + ...(options?.bypassCache ? { bypassCache: true } : {}), }).catch((error) => { log('Failed to refresh assets after transaction event', { error }); }); @@ -1667,6 +1680,13 @@ export class AssetsController extends BaseController< chainIds?: ChainId[]; assetTypes?: AssetType[]; forceUpdate?: boolean; + /** + * Also bypass server-side HTTP caches (e.g. the Accounts API's 60s + * cache, via a random `cacheBuster` query param). Only meaningful + * together with `forceUpdate`. Use sparingly — e.g. right after a + * transaction confirms, when the API's cached snapshot is known stale. + */ + bypassCache?: boolean; dataTypes?: DataType[]; assetsForPriceUpdate?: Caip19AssetId[]; /** When set to `'merge'`, fetch result is merged with existing state instead of replacing. Use for partial fetches (e.g. newly added chains). */ @@ -1700,6 +1720,7 @@ export class AssetsController extends BaseController< dataTypes, customAssets: customAssets.length > 0 ? customAssets : undefined, forceUpdate: true, + bypassCache: options?.bypassCache, assetsForPriceUpdate: options?.assetsForPriceUpdate, }); diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index 2f67b8aa34a..ddd336f857d 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -537,6 +537,36 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + it('fetch requests a full cache bypass when request.bypassCache is true', async () => { + const { controller, apiClient } = await setupController(); + + await controller.fetch( + createDataRequest({ forceUpdate: true, bypassCache: true }), + ); + + expect(apiClient.accounts.fetchV5MultiAccountBalances).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + undefined, + { staleTime: 0, gcTime: 0, bypassCache: true }, + ); + + controller.destroy(); + }); + + it('fetch bypasses caches when bypassCache is set without forceUpdate', async () => { + const { controller, apiClient } = await setupController(); + + await controller.fetch(createDataRequest({ bypassCache: true })); + + expect(apiClient.accounts.fetchV5MultiAccountBalances).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + undefined, + { staleTime: 0, gcTime: 0, bypassCache: true }, + ); + + controller.destroy(); + }); + it('fetch processes balance response', async () => { const balances = [ createMockBalanceItem( diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 0fb4771710e..c9191ca255d 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -424,9 +424,17 @@ export class AccountsApiDataSource extends AbstractDataSource< return response; } - const fetchOptions = request.forceUpdate - ? { staleTime: 0, gcTime: 0 } - : undefined; + const fetchOptions = + request.forceUpdate || request.bypassCache + ? { + staleTime: 0, + gcTime: 0, + // Also defeats the API's server-side cache (via a random + // cacheBuster query param) so a post-transaction refresh cannot + // be answered with a pre-transaction snapshot. + ...(request.bypassCache ? { bypassCache: true } : {}), + } + : undefined; // Feature-flagged: v6 endpoint with a fallback to legacy v5. The flag is // read here (not cached) so a runtime toggle can revert v6 -> v5. @@ -485,7 +493,9 @@ export class AccountsApiDataSource extends AbstractDataSource< */ async #fetchV5Balances( accountIds: string[], - fetchOptions: { staleTime: number; gcTime: number } | undefined, + fetchOptions: + | { staleTime: number; gcTime: number; bypassCache?: boolean } + | undefined, request: DataRequest, ): Promise<{ unprocessedNetworks: string[]; @@ -522,7 +532,9 @@ export class AccountsApiDataSource extends AbstractDataSource< */ async #fetchV6Balances( accountIds: string[], - fetchOptions: { staleTime: number; gcTime: number } | undefined, + fetchOptions: + | { staleTime: number; gcTime: number; bypassCache?: boolean } + | undefined, request: DataRequest, ): Promise<{ unprocessedNetworks: string[]; diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 2b2b940a713..e65699772c9 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -347,6 +347,14 @@ export type DataRequest = { customAssetsOnly?: boolean; /** Force fresh fetch, bypass cache */ forceUpdate?: boolean; + /** + * Bypass server-side HTTP caches in addition to the client-side ones that + * `forceUpdate` already skips. Data sources backed by cached HTTP APIs + * (e.g. the Accounts API with its 60s cache) append a random `cacheBuster` + * query param so the server re-reads instead of replaying a stale snapshot. + * Use sparingly — e.g. right after a transaction confirms. + */ + bypassCache?: boolean; /** Hint for polling interval (ms) - used by data sources that implement polling */ updateInterval?: number; /** Specific CAIP-19 asset IDs for price update */ diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 639ebb9299d..3ccdd2b7caa 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `bypassCache` option to `FetchOptions` + - When true, the v5/v6 multi-account balances requests skip the client-side query cache (stale time defaults to 0) and append a random `cacheBuster` query param so the Accounts API's server-side cache (keyed on the full URL) misses. Intended for hard refreshes only, e.g. right after a transaction confirms. + ### Changed - Bump `@metamask/remote-feature-flag-controller` from `^6.0.0` to `^6.1.0` ([#9980](https://github.com/MetaMask/core/pull/9980)) diff --git a/packages/core-backend/src/api/accounts/client.test.ts b/packages/core-backend/src/api/accounts/client.test.ts index 606eff39a3d..1051cf16db1 100644 --- a/packages/core-backend/src/api/accounts/client.test.ts +++ b/packages/core-backend/src/api/accounts/client.test.ts @@ -179,6 +179,97 @@ describe('AccountsApiClient', () => { ); }); + it('appends a fresh random cacheBuster param on each v5 fetch when bypassCache is true', async () => { + const mockResponse: V5BalancesResponse = { + count: 0, + unprocessedNetworks: [], + balances: [], + }; + mockFetch.mockResolvedValue(createMockResponse(mockResponse)); + + await client.accounts.fetchV5MultiAccountBalances( + ['eip155:1:0x123'], + undefined, + { bypassCache: true }, + ); + await client.accounts.fetchV5MultiAccountBalances( + ['eip155:1:0x123'], + undefined, + { bypassCache: true }, + ); + + // Two network calls prove the client-side query cache was bypassed too. + expect(mockFetch).toHaveBeenCalledTimes(2); + const firstBuster = new URL( + mockFetch.mock.calls[0]?.[0] as string, + ).searchParams.get('cacheBuster'); + const secondBuster = new URL( + mockFetch.mock.calls[1]?.[0] as string, + ).searchParams.get('cacheBuster'); + expect(firstBuster).toMatch(/^[a-z0-9]{8}$/u); + expect(secondBuster).toMatch(/^[a-z0-9]{8}$/u); + expect(firstBuster).not.toBe(secondBuster); + }); + + it('does not append a cacheBuster param to v5 fetches without bypassCache', async () => { + const mockResponse: V5BalancesResponse = { + count: 0, + unprocessedNetworks: [], + balances: [], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await client.accounts.fetchV5MultiAccountBalances(['eip155:1:0x123']); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).not.toContain('cacheBuster'); + }); + + it('appends a fresh random cacheBuster param on each v6 fetch when bypassCache is true', async () => { + const mockResponse: V6BalancesResponse = { + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + balances: [], + }; + mockFetch.mockResolvedValue(createMockResponse(mockResponse)); + + await client.accounts.fetchV6MultiAccountBalances( + ['eip155:1:0x123'], + undefined, + { bypassCache: true }, + ); + await client.accounts.fetchV6MultiAccountBalances( + ['eip155:1:0x123'], + undefined, + { bypassCache: true }, + ); + + expect(mockFetch).toHaveBeenCalledTimes(2); + const firstBuster = new URL( + mockFetch.mock.calls[0]?.[0] as string, + ).searchParams.get('cacheBuster'); + const secondBuster = new URL( + mockFetch.mock.calls[1]?.[0] as string, + ).searchParams.get('cacheBuster'); + expect(firstBuster).toMatch(/^[a-z0-9]{8}$/u); + expect(secondBuster).toMatch(/^[a-z0-9]{8}$/u); + expect(firstBuster).not.toBe(secondBuster); + }); + + it('does not append a cacheBuster param to v6 fetches without bypassCache', async () => { + const mockResponse: V6BalancesResponse = { + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + balances: [], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + await client.accounts.fetchV6MultiAccountBalances(['eip155:1:0x123']); + + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).not.toContain('cacheBuster'); + }); + it('fetches v2 balances with additional options', async () => { const mockResponse: V2BalancesResponse = { count: 1, diff --git a/packages/core-backend/src/api/accounts/client.ts b/packages/core-backend/src/api/accounts/client.ts index 75a60dd892a..f23611244a2 100644 --- a/packages/core-backend/src/api/accounts/client.ts +++ b/packages/core-backend/src/api/accounts/client.ts @@ -42,6 +42,17 @@ import type { V2TokensResponse, } from './types.js'; +/** + * Generate a short random value for the `cacheBuster` query param. The + * Accounts API's server-side cache keys on the full URL including query + * params, so a unique value forces a cache miss ("hard refresh"). + * + * @returns An 8-character alphanumeric string. + */ +function generateCacheBuster(): string { + return Math.random().toString(36).slice(2, 10).padEnd(8, '0'); +} + /** * Accounts API Client. * Provides methods for interacting with the Accounts API. @@ -454,12 +465,16 @@ export class AccountsApiClient extends BaseApiClient { networks: queryOptions?.networks, filterMMListTokens: queryOptions?.filterMMListTokens, includeStakedAssets: queryOptions?.includeStakedAssets, + cacheBuster: options?.bypassCache + ? generateCacheBuster() + : undefined, }, }, ); }, ...getQueryOptionsOverrides(options), - staleTime: options?.staleTime ?? STALE_TIMES.BALANCES, + staleTime: + options?.staleTime ?? (options?.bypassCache ? 0 : STALE_TIMES.BALANCES), gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, }; } @@ -585,12 +600,16 @@ export class AccountsApiClient extends BaseApiClient { vsCurrency: queryOptions?.vsCurrency, includeAssetIds: queryOptions?.includeAssetIds, excludeAssetIds: queryOptions?.excludeAssetIds, + cacheBuster: options?.bypassCache + ? generateCacheBuster() + : undefined, }, }, ); }, ...getQueryOptionsOverrides(options), - staleTime: options?.staleTime ?? STALE_TIMES.BALANCES, + staleTime: + options?.staleTime ?? (options?.bypassCache ? 0 : STALE_TIMES.BALANCES), gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, }; } diff --git a/packages/core-backend/src/api/shared-types.ts b/packages/core-backend/src/api/shared-types.ts index 72ea886b1a5..b50504e86cf 100644 --- a/packages/core-backend/src/api/shared-types.ts +++ b/packages/core-backend/src/api/shared-types.ts @@ -152,6 +152,14 @@ export type FetchOptions = { staleTime?: number; /** Custom GC time (ms). */ gcTime?: number; + /** + * When true, bypass caching for this request: the client-side query cache is + * skipped (stale time defaults to 0) and, on endpoints that support it, a + * random `cacheBuster` query param is appended so server-side HTTP caches + * (keyed on the full URL) miss. Use sparingly — only when a hard refresh is + * required, e.g. right after a transaction confirms. + */ + bypassCache?: boolean; } & Partial< Omit< FetchQueryOptions, @@ -180,6 +188,7 @@ export function getQueryOptionsOverrides( const { queryKey: _qk, queryFn: _qf, + bypassCache: _bc, ...rest } = options as FetchOptions & { queryKey?: unknown; From d09139b6da4022c624ef82fdc65478cf5af13caa Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 2 Sep 2026 02:19:24 +0200 Subject: [PATCH 2/3] docs: link changelog entries to PR #10068 Co-Authored-By: Claude Fable 5 --- packages/assets-controller/CHANGELOG.md | 4 ++-- packages/core-backend/CHANGELOG.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index b856cff93b6..87eb1d91db9 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `bypassCache` option to `getAssets` +- Add `bypassCache` option to `getAssets` ([#10068](https://github.com/MetaMask/core/pull/10068)) - When true (only meaningful together with `forceUpdate`), the Accounts API request also bypasses the API's server-side 60s cache via a random `cacheBuster` query param, instead of only the client-side query cache. ### Changed @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fix stale balances shown right after a transaction confirms: the post-confirmation refresh now calls `getAssets` with `bypassCache: 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 +- Fix stale balances shown right after a transaction confirms: the post-confirmation refresh now calls `getAssets` with `bypassCache: 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)) ## [14.0.3] diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 3ccdd2b7caa..945d481f551 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `bypassCache` option to `FetchOptions` +- Add `bypassCache` option to `FetchOptions` ([#10068](https://github.com/MetaMask/core/pull/10068)) - When true, the v5/v6 multi-account balances requests skip the client-side query cache (stale time defaults to 0) and append a random `cacheBuster` query param so the Accounts API's server-side cache (keyed on the full URL) misses. Intended for hard refreshes only, e.g. right after a transaction confirms. ### Changed From cfda0d6db8f8815ca52bae95d96a64dda4f2cf83 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 2 Sep 2026 16:19:52 +0200 Subject: [PATCH 3/3] fix: fix PR comments --- packages/assets-controller/CHANGELOG.md | 6 ++-- .../src/AssetsController.test.ts | 3 +- .../assets-controller/src/AssetsController.ts | 28 ++++++------------- .../AccountsApiDataSource.test.ts | 12 ++++---- .../src/data-sources/AccountsApiDataSource.ts | 10 +++---- packages/assets-controller/src/types.ts | 4 +-- packages/core-backend/CHANGELOG.md | 4 +-- .../src/api/accounts/client.test.ts | 28 +++++++++---------- .../core-backend/src/api/accounts/client.ts | 18 ++++++------ packages/core-backend/src/api/shared-types.ts | 6 ++-- 10 files changed, 56 insertions(+), 63 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index ca04bb533df..69d98505c89 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `bypassCache` option to `getAssets` ([#10068](https://github.com/MetaMask/core/pull/10068)) - - When true (only meaningful together with `forceUpdate`), the Accounts API request also bypasses the API's server-side 60s cache via a random `cacheBuster` query param, instead of only the client-side query cache. +- Add `bypassServerCache` option to `getAssets` ([#10068](https://github.com/MetaMask/core/pull/10068)) + - When true (only meaningful together with `forceUpdate`), the Accounts API request also bypasses the API's server-side 60s cache via a random `bypassServerCache` query param, instead of only the client-side query cache. ### Changed @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fix stale balances shown right after a transaction confirms: the post-confirmation refresh now calls `getAssets` with `bypassCache: 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 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. - 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. diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 52073d3be04..4c8a7e4c8fc 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -2872,7 +2872,7 @@ describe('AssetsController', () => { { chainIds: ['eip155:42161'], forceUpdate: true, - bypassCache: true, + bypassServerCache: true, }, ); @@ -2898,6 +2898,7 @@ describe('AssetsController', () => { { chainIds: ['eip155:42161'], forceUpdate: true, + bypassServerCache: true, }, ); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index db33d614019..46ae9495ff5 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1221,15 +1221,10 @@ export class AssetsController extends BaseController< // Skipped for chains covered by AccountActivity (real-time WS updates). // RpcDataSource also listens for transactionConfirmed, but only refreshes // chains it owns via an active subscription. - // bypassCache defeats the Accounts API's 60s server-side cache, which - // WebSocket events do not invalidate — without it the refresh can be - // answered with the pre-transaction snapshot. this.messenger.subscribe( 'TransactionController:transactionConfirmed', (transactionMeta: TransactionMeta) => { - this.#refreshAssetsForTransaction(transactionMeta, { - bypassCache: true, - }); + this.#refreshAssetsForTransaction(transactionMeta); }, ); } @@ -1237,17 +1232,12 @@ export class AssetsController extends BaseController< /** * Force-refresh assets for the account/chain of a transaction, unless the * chain is already covered by AccountActivity (real-time WebSocket balances). + * Always bypasses the Accounts API's server-side cache so a refresh cannot + * be answered with a stale pre-transaction snapshot. * * @param transactionMeta - The transaction that triggered the refresh. - * @param options - Refresh options. - * @param options.bypassCache - Also bypass server-side HTTP caches. Used on - * transaction confirmation, where a cached Accounts API response would still - * hold the pre-transaction balance. - */ - #refreshAssetsForTransaction( - transactionMeta: TransactionMeta, - options?: { bypassCache?: boolean }, - ): void { + */ + #refreshAssetsForTransaction(transactionMeta: TransactionMeta): void { const hexChainId = transactionMeta.chainId; if (!hexChainId) { return; @@ -1280,7 +1270,7 @@ export class AssetsController extends BaseController< this.getAssets([matchedAccount], { chainIds: [caipChainId], forceUpdate: true, - ...(options?.bypassCache ? { bypassCache: true } : {}), + bypassServerCache: true, }).catch((error) => { log('Failed to refresh assets after transaction event', { error }); }); @@ -1606,11 +1596,11 @@ export class AssetsController extends BaseController< forceUpdate?: boolean; /** * Also bypass server-side HTTP caches (e.g. the Accounts API's 60s - * cache, via a random `cacheBuster` query param). Only meaningful + * cache, via a random `bypassServerCache` query param). Only meaningful * together with `forceUpdate`. Use sparingly — e.g. right after a * transaction confirms, when the API's cached snapshot is known stale. */ - bypassCache?: boolean; + bypassServerCache?: boolean; dataTypes?: DataType[]; assetsForPriceUpdate?: Caip19AssetId[]; /** When set to `'merge'`, fetch result is merged with existing state instead of replacing. Use for partial fetches (e.g. newly added chains). */ @@ -1644,7 +1634,7 @@ export class AssetsController extends BaseController< dataTypes, customAssets: customAssets.length > 0 ? customAssets : undefined, forceUpdate: true, - bypassCache: options?.bypassCache, + bypassServerCache: options?.bypassServerCache, assetsForPriceUpdate: options?.assetsForPriceUpdate, }); diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index ddd336f857d..eb4f7e08110 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -537,31 +537,31 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); - it('fetch requests a full cache bypass when request.bypassCache is true', async () => { + it('fetch requests a full cache bypass when request.bypassServerCache is true', async () => { const { controller, apiClient } = await setupController(); await controller.fetch( - createDataRequest({ forceUpdate: true, bypassCache: true }), + createDataRequest({ forceUpdate: true, bypassServerCache: true }), ); expect(apiClient.accounts.fetchV5MultiAccountBalances).toHaveBeenCalledWith( [`eip155:1:${MOCK_ADDRESS}`], undefined, - { staleTime: 0, gcTime: 0, bypassCache: true }, + { staleTime: 0, gcTime: 0, bypassServerCache: true }, ); controller.destroy(); }); - it('fetch bypasses caches when bypassCache is set without forceUpdate', async () => { + it('fetch bypasses caches when bypassServerCache is set without forceUpdate', async () => { const { controller, apiClient } = await setupController(); - await controller.fetch(createDataRequest({ bypassCache: true })); + await controller.fetch(createDataRequest({ bypassServerCache: true })); expect(apiClient.accounts.fetchV5MultiAccountBalances).toHaveBeenCalledWith( [`eip155:1:${MOCK_ADDRESS}`], undefined, - { staleTime: 0, gcTime: 0, bypassCache: true }, + { staleTime: 0, gcTime: 0, bypassServerCache: true }, ); controller.destroy(); diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index c9191ca255d..9ca74bcf9f1 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -425,14 +425,14 @@ export class AccountsApiDataSource extends AbstractDataSource< } const fetchOptions = - request.forceUpdate || request.bypassCache + request.forceUpdate || request.bypassServerCache ? { staleTime: 0, gcTime: 0, // Also defeats the API's server-side cache (via a random - // cacheBuster query param) so a post-transaction refresh cannot + // bypassServerCache query param) so a post-transaction refresh cannot // be answered with a pre-transaction snapshot. - ...(request.bypassCache ? { bypassCache: true } : {}), + ...(request.bypassServerCache ? { bypassServerCache: true } : {}), } : undefined; @@ -494,7 +494,7 @@ export class AccountsApiDataSource extends AbstractDataSource< async #fetchV5Balances( accountIds: string[], fetchOptions: - | { staleTime: number; gcTime: number; bypassCache?: boolean } + | { staleTime: number; gcTime: number; bypassServerCache?: boolean } | undefined, request: DataRequest, ): Promise<{ @@ -533,7 +533,7 @@ export class AccountsApiDataSource extends AbstractDataSource< async #fetchV6Balances( accountIds: string[], fetchOptions: - | { staleTime: number; gcTime: number; bypassCache?: boolean } + | { staleTime: number; gcTime: number; bypassServerCache?: boolean } | undefined, request: DataRequest, ): Promise<{ diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index e65699772c9..501220b6e5f 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -350,11 +350,11 @@ export type DataRequest = { /** * Bypass server-side HTTP caches in addition to the client-side ones that * `forceUpdate` already skips. Data sources backed by cached HTTP APIs - * (e.g. the Accounts API with its 60s cache) append a random `cacheBuster` + * (e.g. the Accounts API with its 60s cache) append a random `bypassServerCache` * query param so the server re-reads instead of replaying a stale snapshot. * Use sparingly — e.g. right after a transaction confirms. */ - bypassCache?: boolean; + bypassServerCache?: boolean; /** Hint for polling interval (ms) - used by data sources that implement polling */ updateInterval?: number; /** Specific CAIP-19 asset IDs for price update */ diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 945d481f551..055d948c56f 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `bypassCache` option to `FetchOptions` ([#10068](https://github.com/MetaMask/core/pull/10068)) - - When true, the v5/v6 multi-account balances requests skip the client-side query cache (stale time defaults to 0) and append a random `cacheBuster` query param so the Accounts API's server-side cache (keyed on the full URL) misses. Intended for hard refreshes only, e.g. right after a transaction confirms. +- Add `bypassServerCache` option to `FetchOptions` ([#10068](https://github.com/MetaMask/core/pull/10068)) + - When true, the v5/v6 multi-account balances requests skip the client-side query cache (stale time defaults to 0) and append a random `bypassServerCache` query param so the Accounts API's server-side cache (keyed on the full URL) misses. Intended for hard refreshes only, e.g. right after a transaction confirms. ### Changed diff --git a/packages/core-backend/src/api/accounts/client.test.ts b/packages/core-backend/src/api/accounts/client.test.ts index 1051cf16db1..3963817a7a4 100644 --- a/packages/core-backend/src/api/accounts/client.test.ts +++ b/packages/core-backend/src/api/accounts/client.test.ts @@ -179,7 +179,7 @@ describe('AccountsApiClient', () => { ); }); - it('appends a fresh random cacheBuster param on each v5 fetch when bypassCache is true', async () => { + it('appends a fresh random bypassServerCache param on each v5 fetch when bypassServerCache is true', async () => { const mockResponse: V5BalancesResponse = { count: 0, unprocessedNetworks: [], @@ -190,28 +190,28 @@ describe('AccountsApiClient', () => { await client.accounts.fetchV5MultiAccountBalances( ['eip155:1:0x123'], undefined, - { bypassCache: true }, + { bypassServerCache: true }, ); await client.accounts.fetchV5MultiAccountBalances( ['eip155:1:0x123'], undefined, - { bypassCache: true }, + { bypassServerCache: true }, ); // Two network calls prove the client-side query cache was bypassed too. expect(mockFetch).toHaveBeenCalledTimes(2); const firstBuster = new URL( mockFetch.mock.calls[0]?.[0] as string, - ).searchParams.get('cacheBuster'); + ).searchParams.get('bypassServerCache'); const secondBuster = new URL( mockFetch.mock.calls[1]?.[0] as string, - ).searchParams.get('cacheBuster'); + ).searchParams.get('bypassServerCache'); expect(firstBuster).toMatch(/^[a-z0-9]{8}$/u); expect(secondBuster).toMatch(/^[a-z0-9]{8}$/u); expect(firstBuster).not.toBe(secondBuster); }); - it('does not append a cacheBuster param to v5 fetches without bypassCache', async () => { + it('does not append a bypassServerCache param to v5 fetches without bypassServerCache', async () => { const mockResponse: V5BalancesResponse = { count: 0, unprocessedNetworks: [], @@ -222,10 +222,10 @@ describe('AccountsApiClient', () => { await client.accounts.fetchV5MultiAccountBalances(['eip155:1:0x123']); const calledUrl = mockFetch.mock.calls[0]?.[0] as string; - expect(calledUrl).not.toContain('cacheBuster'); + expect(calledUrl).not.toContain('bypassServerCache'); }); - it('appends a fresh random cacheBuster param on each v6 fetch when bypassCache is true', async () => { + it('appends a fresh random bypassServerCache param on each v6 fetch when bypassServerCache is true', async () => { const mockResponse: V6BalancesResponse = { unprocessedNetworks: [], unprocessedIncludeAssetIds: [], @@ -236,27 +236,27 @@ describe('AccountsApiClient', () => { await client.accounts.fetchV6MultiAccountBalances( ['eip155:1:0x123'], undefined, - { bypassCache: true }, + { bypassServerCache: true }, ); await client.accounts.fetchV6MultiAccountBalances( ['eip155:1:0x123'], undefined, - { bypassCache: true }, + { bypassServerCache: true }, ); expect(mockFetch).toHaveBeenCalledTimes(2); const firstBuster = new URL( mockFetch.mock.calls[0]?.[0] as string, - ).searchParams.get('cacheBuster'); + ).searchParams.get('bypassServerCache'); const secondBuster = new URL( mockFetch.mock.calls[1]?.[0] as string, - ).searchParams.get('cacheBuster'); + ).searchParams.get('bypassServerCache'); expect(firstBuster).toMatch(/^[a-z0-9]{8}$/u); expect(secondBuster).toMatch(/^[a-z0-9]{8}$/u); expect(firstBuster).not.toBe(secondBuster); }); - it('does not append a cacheBuster param to v6 fetches without bypassCache', async () => { + it('does not append a bypassServerCache param to v6 fetches without bypassServerCache', async () => { const mockResponse: V6BalancesResponse = { unprocessedNetworks: [], unprocessedIncludeAssetIds: [], @@ -267,7 +267,7 @@ describe('AccountsApiClient', () => { await client.accounts.fetchV6MultiAccountBalances(['eip155:1:0x123']); const calledUrl = mockFetch.mock.calls[0]?.[0] as string; - expect(calledUrl).not.toContain('cacheBuster'); + expect(calledUrl).not.toContain('bypassServerCache'); }); it('fetches v2 balances with additional options', async () => { diff --git a/packages/core-backend/src/api/accounts/client.ts b/packages/core-backend/src/api/accounts/client.ts index f23611244a2..549e6524583 100644 --- a/packages/core-backend/src/api/accounts/client.ts +++ b/packages/core-backend/src/api/accounts/client.ts @@ -43,13 +43,13 @@ import type { } from './types.js'; /** - * Generate a short random value for the `cacheBuster` query param. The + * Generate a short random value for the `bypassServerCache` query param. The * Accounts API's server-side cache keys on the full URL including query * params, so a unique value forces a cache miss ("hard refresh"). * * @returns An 8-character alphanumeric string. */ -function generateCacheBuster(): string { +function generateBypassServerCache(): string { return Math.random().toString(36).slice(2, 10).padEnd(8, '0'); } @@ -465,8 +465,8 @@ export class AccountsApiClient extends BaseApiClient { networks: queryOptions?.networks, filterMMListTokens: queryOptions?.filterMMListTokens, includeStakedAssets: queryOptions?.includeStakedAssets, - cacheBuster: options?.bypassCache - ? generateCacheBuster() + bypassServerCache: options?.bypassServerCache + ? generateBypassServerCache() : undefined, }, }, @@ -474,7 +474,8 @@ export class AccountsApiClient extends BaseApiClient { }, ...getQueryOptionsOverrides(options), staleTime: - options?.staleTime ?? (options?.bypassCache ? 0 : STALE_TIMES.BALANCES), + options?.staleTime ?? + (options?.bypassServerCache ? 0 : STALE_TIMES.BALANCES), gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, }; } @@ -600,8 +601,8 @@ export class AccountsApiClient extends BaseApiClient { vsCurrency: queryOptions?.vsCurrency, includeAssetIds: queryOptions?.includeAssetIds, excludeAssetIds: queryOptions?.excludeAssetIds, - cacheBuster: options?.bypassCache - ? generateCacheBuster() + bypassServerCache: options?.bypassServerCache + ? generateBypassServerCache() : undefined, }, }, @@ -609,7 +610,8 @@ export class AccountsApiClient extends BaseApiClient { }, ...getQueryOptionsOverrides(options), staleTime: - options?.staleTime ?? (options?.bypassCache ? 0 : STALE_TIMES.BALANCES), + options?.staleTime ?? + (options?.bypassServerCache ? 0 : STALE_TIMES.BALANCES), gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, }; } diff --git a/packages/core-backend/src/api/shared-types.ts b/packages/core-backend/src/api/shared-types.ts index b50504e86cf..a585123bb5f 100644 --- a/packages/core-backend/src/api/shared-types.ts +++ b/packages/core-backend/src/api/shared-types.ts @@ -155,11 +155,11 @@ export type FetchOptions = { /** * When true, bypass caching for this request: the client-side query cache is * skipped (stale time defaults to 0) and, on endpoints that support it, a - * random `cacheBuster` query param is appended so server-side HTTP caches + * random `bypassServerCache` query param is appended so server-side HTTP caches * (keyed on the full URL) miss. Use sparingly — only when a hard refresh is * required, e.g. right after a transaction confirms. */ - bypassCache?: boolean; + bypassServerCache?: boolean; } & Partial< Omit< FetchQueryOptions, @@ -188,7 +188,7 @@ export function getQueryOptionsOverrides( const { queryKey: _qk, queryFn: _qf, - bypassCache: _bc, + bypassServerCache: _bc, ...rest } = options as FetchOptions & { queryKey?: unknown;