diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 37d4f67fbd0..b6ac2dd66df 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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 - **BREAKING:** `AssetsControllerMessenger` now requires `AccountTreeController:isInitialized`, `ClientController:getState`, and `KeyringController:isUnlocked` so lifecycle checks read controller state on demand instead of mirroring it from events ([#10059](https://github.com/MetaMask/core/pull/10059)) @@ -18,6 +23,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 `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 4917ed22ae3..4c8a7e4c8fc 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -2872,6 +2872,7 @@ describe('AssetsController', () => { { chainIds: ['eip155:42161'], forceUpdate: true, + bypassServerCache: true, }, ); @@ -2897,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 70187a8a0a7..46ae9495ff5 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1232,6 +1232,8 @@ 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. */ @@ -1268,6 +1270,7 @@ export class AssetsController extends BaseController< this.getAssets([matchedAccount], { chainIds: [caipChainId], forceUpdate: true, + bypassServerCache: true, }).catch((error) => { log('Failed to refresh assets after transaction event', { error }); }); @@ -1591,6 +1594,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 `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. + */ + 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). */ @@ -1624,6 +1634,7 @@ export class AssetsController extends BaseController< dataTypes, customAssets: customAssets.length > 0 ? customAssets : undefined, forceUpdate: true, + 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 2f67b8aa34a..eb4f7e08110 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.bypassServerCache is true', async () => { + const { controller, apiClient } = await setupController(); + + await controller.fetch( + createDataRequest({ forceUpdate: true, bypassServerCache: true }), + ); + + expect(apiClient.accounts.fetchV5MultiAccountBalances).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + undefined, + { staleTime: 0, gcTime: 0, bypassServerCache: true }, + ); + + controller.destroy(); + }); + + it('fetch bypasses caches when bypassServerCache is set without forceUpdate', async () => { + const { controller, apiClient } = await setupController(); + + await controller.fetch(createDataRequest({ bypassServerCache: true })); + + expect(apiClient.accounts.fetchV5MultiAccountBalances).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + undefined, + { staleTime: 0, gcTime: 0, bypassServerCache: 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..9ca74bcf9f1 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.bypassServerCache + ? { + staleTime: 0, + gcTime: 0, + // Also defeats the API's server-side cache (via a random + // bypassServerCache query param) so a post-transaction refresh cannot + // be answered with a pre-transaction snapshot. + ...(request.bypassServerCache ? { bypassServerCache: 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; bypassServerCache?: 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; bypassServerCache?: 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..501220b6e5f 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 `bypassServerCache` + * query param so the server re-reads instead of replaying a stale snapshot. + * Use sparingly — e.g. right after a transaction confirms. + */ + 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 daa70cf4309..daee102f61b 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 `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 - 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..3963817a7a4 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 bypassServerCache param on each v5 fetch when bypassServerCache is true', async () => { + const mockResponse: V5BalancesResponse = { + count: 0, + unprocessedNetworks: [], + balances: [], + }; + mockFetch.mockResolvedValue(createMockResponse(mockResponse)); + + await client.accounts.fetchV5MultiAccountBalances( + ['eip155:1:0x123'], + undefined, + { bypassServerCache: true }, + ); + await client.accounts.fetchV5MultiAccountBalances( + ['eip155:1:0x123'], + undefined, + { 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('bypassServerCache'); + const secondBuster = new URL( + mockFetch.mock.calls[1]?.[0] as string, + ).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 bypassServerCache param to v5 fetches without bypassServerCache', 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('bypassServerCache'); + }); + + it('appends a fresh random bypassServerCache param on each v6 fetch when bypassServerCache is true', async () => { + const mockResponse: V6BalancesResponse = { + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + balances: [], + }; + mockFetch.mockResolvedValue(createMockResponse(mockResponse)); + + await client.accounts.fetchV6MultiAccountBalances( + ['eip155:1:0x123'], + undefined, + { bypassServerCache: true }, + ); + await client.accounts.fetchV6MultiAccountBalances( + ['eip155:1:0x123'], + undefined, + { bypassServerCache: true }, + ); + + expect(mockFetch).toHaveBeenCalledTimes(2); + const firstBuster = new URL( + mockFetch.mock.calls[0]?.[0] as string, + ).searchParams.get('bypassServerCache'); + const secondBuster = new URL( + mockFetch.mock.calls[1]?.[0] as string, + ).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 bypassServerCache param to v6 fetches without bypassServerCache', 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('bypassServerCache'); + }); + 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..549e6524583 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 `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 generateBypassServerCache(): 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,17 @@ export class AccountsApiClient extends BaseApiClient { networks: queryOptions?.networks, filterMMListTokens: queryOptions?.filterMMListTokens, includeStakedAssets: queryOptions?.includeStakedAssets, + bypassServerCache: options?.bypassServerCache + ? generateBypassServerCache() + : undefined, }, }, ); }, ...getQueryOptionsOverrides(options), - staleTime: options?.staleTime ?? STALE_TIMES.BALANCES, + staleTime: + options?.staleTime ?? + (options?.bypassServerCache ? 0 : STALE_TIMES.BALANCES), gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, }; } @@ -585,12 +601,17 @@ export class AccountsApiClient extends BaseApiClient { vsCurrency: queryOptions?.vsCurrency, includeAssetIds: queryOptions?.includeAssetIds, excludeAssetIds: queryOptions?.excludeAssetIds, + bypassServerCache: options?.bypassServerCache + ? generateBypassServerCache() + : undefined, }, }, ); }, ...getQueryOptionsOverrides(options), - staleTime: options?.staleTime ?? STALE_TIMES.BALANCES, + staleTime: + 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 72ea886b1a5..a585123bb5f 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 `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. + */ + bypassServerCache?: boolean; } & Partial< Omit< FetchQueryOptions, @@ -180,6 +188,7 @@ export function getQueryOptionsOverrides( const { queryKey: _qk, queryFn: _qf, + bypassServerCache: _bc, ...rest } = options as FetchOptions & { queryKey?: unknown;