Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2872,6 +2872,7 @@ describe('AssetsController', () => {
{
chainIds: ['eip155:42161'],
forceUpdate: true,
bypassServerCache: true,
},
);

Expand All @@ -2897,6 +2898,7 @@ describe('AssetsController', () => {
{
chainIds: ['eip155:42161'],
forceUpdate: true,
bypassServerCache: true,
},
);

Expand Down
11 changes: 11 additions & 0 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 });
});
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -1624,6 +1634,7 @@ export class AssetsController extends BaseController<
dataTypes,
customAssets: customAssets.length > 0 ? customAssets : undefined,
forceUpdate: true,
bypassServerCache: options?.bypassServerCache,
assetsForPriceUpdate: options?.assetsForPriceUpdate,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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[];
Expand Down
8 changes: 8 additions & 0 deletions packages/assets-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
5 changes: 5 additions & 0 deletions packages/core-backend/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
91 changes: 91 additions & 0 deletions packages/core-backend/src/api/accounts/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 23 additions & 2 deletions packages/core-backend/src/api/accounts/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -454,12 +465,17 @@ export class AccountsApiClient extends BaseApiClient {
networks: queryOptions?.networks,
filterMMListTokens: queryOptions?.filterMMListTokens,
includeStakedAssets: queryOptions?.includeStakedAssets,
bypassServerCache: options?.bypassServerCache
? generateBypassServerCache()
: undefined,
Comment thread
salimtb marked this conversation as resolved.
},
},
);
},
...getQueryOptionsOverrides(options),
staleTime: options?.staleTime ?? STALE_TIMES.BALANCES,
staleTime:
options?.staleTime ??
(options?.bypassServerCache ? 0 : STALE_TIMES.BALANCES),
gcTime: options?.gcTime ?? GC_TIMES.DEFAULT,
};
}
Expand Down Expand Up @@ -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,
};
}
Expand Down
9 changes: 9 additions & 0 deletions packages/core-backend/src/api/shared-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown, Error, unknown>,
Expand Down Expand Up @@ -180,6 +188,7 @@ export function getQueryOptionsOverrides(
const {
queryKey: _qk,
queryFn: _qf,
bypassServerCache: _bc,
...rest
} = options as FetchOptions & {
queryKey?: unknown;
Expand Down