From 91399f1689ce26e348df6b7b7c60eaa615baf13a Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Thu, 30 Jul 2026 14:19:27 +0100 Subject: [PATCH 01/21] feat: refetch defi positions if they are still loading --- packages/assets-controllers/CHANGELOG.md | 8 + ...sitionsControllerV2-method-action-types.ts | 19 +- .../DeFiPositionsControllerV2.test.ts | 283 +++++++++++++----- .../DeFiPositionsControllerV2.ts | 167 ++++++++--- 4 files changed, 352 insertions(+), 125 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 897c49b0964..a6d3210a71d 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected + account has `processingDefiPositions: true`, applying ready accounts + immediately, invalidating the balances cache between attempts, sharing one + in-flight promise across concurrent calls, and stopping on request failure or + the max attempt limit + ## [110.0.2] ### Changed diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts index 12c145da251..89ead7af83c 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts @@ -8,16 +8,19 @@ import type { DeFiPositionsControllerV2 } from './DeFiPositionsControllerV2.js'; /** * Fetches DeFi positions for the selected account group. Each account key in * a ready response replaces that account's state (other accounts stay). - * Accounts still indexing (`processingDefiPositions`) are skipped so prior - * state is kept for them. No-ops when disabled or when the group has no - * supported accounts. Caching / spam prevention is handled by the apiClient - * TanStack Query cache (keyed by accounts + query options including - * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache (e.g. - * pull-to-refresh). + * Accounts still indexing (`processingDefiPositions`) keep prior state; the + * method polls (invalidating the balances cache between attempts) until all + * selected accounts are ready, the attempt limit is reached, or a request + * fails. Concurrent calls share one in-flight promise. No-ops when disabled + * or when the group has no supported accounts. Caching / spam prevention is + * handled by the apiClient TanStack Query cache (keyed by accounts + query + * options including `vsCurrency`). Pass `{ forceRefresh: true }` to bypass + * the cache on the first attempt (e.g. pull-to-refresh). * * @param options - Optional fetch modifiers. - * @param options.forceRefresh - When true, bypass the apiClient cache and - * fetch immediately. + * @param options.forceRefresh - When true, bypass the apiClient cache on the + * first attempt and fetch immediately. + * @returns Resolves when the fetch (and any processing polls) finish. */ export type DeFiPositionsControllerV2FetchDeFiPositionsAction = { type: `DeFiPositionsControllerV2:fetchDeFiPositions`; diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index dfd6919ac9b..57d604fb567 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -23,6 +23,8 @@ import { createMockInternalAccount } from '../../../accounts-controller/tests/mo import { DEFI_SUPPORTED_NETWORKS } from './build-defi-balances-query.js'; import type { DeFiPositionsControllerV2Messenger } from './DeFiPositionsControllerV2.js'; import { + DEFAULT_PROCESSING_POLL_INTERVAL_MS, + DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS, DeFiPositionsControllerV2, getDefaultDeFiPositionsControllerV2State, } from './DeFiPositionsControllerV2.js'; @@ -125,6 +127,23 @@ function buildMockBalancesResponse( }; } +/** + * Builds a processing-only balances response for the EVM account. + * + * @returns A v6 balances response with `processingDefiPositions: true`. + */ +function buildProcessingBalancesResponse(): V6BalancesResponse { + return buildMockBalancesResponse({ + accounts: [ + { + accountId: `eip155:0:${EVM_ADDRESS}`, + processingDefiPositions: true, + balances: [], + }, + ], + }); +} + /** * Sets up the V2 controller with the given configuration. * @@ -163,6 +182,8 @@ function setupController({ RootMessenger >; mockFetchV6MultiAccountBalances: jest.Mock; + mockInvalidateQueries: jest.Mock; + mockGetV6MultiAccountBalancesQueryOptions: jest.Mock; } { const messenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, @@ -187,9 +208,19 @@ function setupController({ actions: ['AccountTreeController:getAccountsFromSelectedAccountGroup'], }); + const mockInvalidateQueries = jest.fn().mockResolvedValue(undefined); + const mockGetV6MultiAccountBalancesQueryOptions = jest + .fn() + .mockReturnValue({ queryKey: ['accounts', 'balances', 'v6', 'mock'] }); + const apiClient = { accounts: { fetchV6MultiAccountBalances: mockFetchV6MultiAccountBalances, + getV6MultiAccountBalancesQueryOptions: + mockGetV6MultiAccountBalancesQueryOptions, + queryClient: { + invalidateQueries: mockInvalidateQueries, + }, }, } as unknown as ApiPlatformClient; @@ -205,11 +236,14 @@ function setupController({ controller, controllerMessenger, mockFetchV6MultiAccountBalances, + mockInvalidateQueries, + mockGetV6MultiAccountBalancesQueryOptions, }; } describe('DeFiPositionsControllerV2', () => { afterEach(() => { + jest.useRealTimers(); jest.restoreAllMocks(); }); @@ -248,7 +282,11 @@ describe('DeFiPositionsControllerV2', () => { }); it('fetches positions and stores them keyed by internal account ID', async () => { - const { controller, mockFetchV6MultiAccountBalances } = setupController(); + const { + controller, + mockFetchV6MultiAccountBalances, + mockInvalidateQueries, + } = setupController(); await controller.fetchDeFiPositions(); @@ -266,6 +304,7 @@ describe('DeFiPositionsControllerV2', () => { }, {}, ); + expect(mockInvalidateQueries).not.toHaveBeenCalled(); expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( 1, @@ -378,89 +417,121 @@ describe('DeFiPositionsControllerV2', () => { }); }); - it('keeps prior state for accounts still indexing DeFi positions', async () => { - const { controller, mockFetchV6MultiAccountBalances } = setupController({ + it('polls until processing accounts become ready and keeps prior state meanwhile', async () => { + jest.useFakeTimers(); + + const { + controller, + mockFetchV6MultiAccountBalances, + mockInvalidateQueries, + } = setupController({ mockFetchV6MultiAccountBalances: jest .fn() .mockResolvedValueOnce(buildMockBalancesResponse()) - .mockResolvedValueOnce( - buildMockBalancesResponse({ - accounts: [ - { - accountId: `eip155:0:${EVM_ADDRESS}`, - processingDefiPositions: true, - balances: [], - }, - ], - }), - ), + .mockResolvedValueOnce(buildProcessingBalancesResponse()) + .mockResolvedValueOnce(buildMockBalancesResponse()), }); await controller.fetchDeFiPositions(); const cached = controller.state.allDeFiPositionsV2['evm-account-id']; expect(cached).toHaveLength(1); - await controller.fetchDeFiPositions({ forceRefresh: true }); - expect(controller.state.allDeFiPositionsV2['evm-account-id']).toBe(cached); + const secondFetch = controller.fetchDeFiPositions({ forceRefresh: true }); + await Promise.resolve(); expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toBe(cached); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + await secondFetch; + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(3); + expect(mockFetchV6MultiAccountBalances).toHaveBeenLastCalledWith( + expect.any(Array), + expect.objectContaining({ vsCurrency: 'usd' }), + { staleTime: 0 }, + ); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).not.toBe( + cached, + ); }); - it('updates ready accounts while skipping ones still indexing', async () => { + it('updates ready accounts immediately while polling ones still indexing', async () => { + jest.useFakeTimers(); + const solanaAccountId = `solana:${SolScope.Mainnet.split(':')[1]}:${SOLANA_ADDRESS}`; - const { controller } = setupController({ - mockGroupAccounts: GROUP_ACCOUNTS_WITH_SOLANA, - mockFetchV6MultiAccountBalances: jest - .fn() - .mockResolvedValueOnce( - buildMockBalancesResponse({ - accounts: [ - { - accountId: `eip155:0:${EVM_ADDRESS}`, - balances: buildMockBalancesResponse().accounts[0].balances, - }, - { - accountId: solanaAccountId, - balances: [ - { - category: 'defi', - assetId: `${SolScope.Mainnet}/token:${SOLANA_ADDRESS}`, - name: 'Wrapped SOL', - symbol: 'WSOL', - decimals: 9, - balance: '1', - price: '100', - metadata: { - protocolId: 'marinade', - productName: 'Marinade', - description: 'Marinade on solana', - protocolUrl: 'https://marinade.finance/', - protocolIconUrl: 'https://example.com/marinade.png', - positionType: 'stake', - poolAddress: 'pool', - groupId: 'group-marinade-1', + const { controller, mockInvalidateQueries, mockFetchV6MultiAccountBalances } = + setupController({ + mockGroupAccounts: GROUP_ACCOUNTS_WITH_SOLANA, + mockFetchV6MultiAccountBalances: jest + .fn() + .mockResolvedValueOnce( + buildMockBalancesResponse({ + accounts: [ + { + accountId: `eip155:0:${EVM_ADDRESS}`, + balances: buildMockBalancesResponse().accounts[0].balances, + }, + { + accountId: solanaAccountId, + balances: [ + { + category: 'defi', + assetId: `${SolScope.Mainnet}/token:${SOLANA_ADDRESS}`, + name: 'Wrapped SOL', + symbol: 'WSOL', + decimals: 9, + balance: '1', + price: '100', + metadata: { + protocolId: 'marinade', + productName: 'Marinade', + description: 'Marinade on solana', + protocolUrl: 'https://marinade.finance/', + protocolIconUrl: 'https://example.com/marinade.png', + positionType: 'stake', + poolAddress: 'pool', + groupId: 'group-marinade-1', + }, }, - }, - ], - }, - ], - }), - ) - .mockResolvedValueOnce( - buildMockBalancesResponse({ - accounts: [ - { - accountId: `eip155:0:${EVM_ADDRESS}`, - processingDefiPositions: true, - balances: [], - }, - { - accountId: solanaAccountId, - balances: [], - }, - ], - }), - ), - }); + ], + }, + ], + }), + ) + .mockResolvedValueOnce( + buildMockBalancesResponse({ + accounts: [ + { + accountId: `eip155:0:${EVM_ADDRESS}`, + processingDefiPositions: true, + balances: [], + }, + { + accountId: solanaAccountId, + balances: [], + }, + ], + }), + ) + .mockResolvedValueOnce( + buildMockBalancesResponse({ + accounts: [ + { + accountId: `eip155:0:${EVM_ADDRESS}`, + balances: buildMockBalancesResponse().accounts[0].balances, + }, + { + accountId: solanaAccountId, + balances: [], + }, + ], + }), + ), + }); await controller.fetchDeFiPositions(); const evmPositions = controller.state.allDeFiPositionsV2['evm-account-id']; @@ -469,15 +540,83 @@ describe('DeFiPositionsControllerV2', () => { controller.state.allDeFiPositionsV2['solana-account-id'], ).toHaveLength(1); - await controller.fetchDeFiPositions({ forceRefresh: true }); + const secondFetch = controller.fetchDeFiPositions({ forceRefresh: true }); + await Promise.resolve(); - // Still-indexing EVM account keeps prior positions; ready Solana clears. + // Ready Solana clears immediately; still-indexing EVM keeps prior positions. expect(controller.state.allDeFiPositionsV2['evm-account-id']).toBe( evmPositions, ); expect( controller.state.allDeFiPositionsV2['solana-account-id'], ).toStrictEqual([]); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + await secondFetch; + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(3); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + expect( + controller.state.allDeFiPositionsV2['solana-account-id'], + ).toStrictEqual([]); + }); + + it('stops polling after the max attempt limit while still processing', async () => { + jest.useFakeTimers(); + + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockResolvedValue(buildProcessingBalancesResponse()); + + const { controller, mockInvalidateQueries } = setupController({ + mockFetchV6MultiAccountBalances, + }); + + const fetchPromise = controller.fetchDeFiPositions(); + + for (let i = 0; i < DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS - 1; i++) { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + } + + await fetchPromise; + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes( + DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS, + ); + expect(mockInvalidateQueries).toHaveBeenCalledTimes( + DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS, + ); + expect(controller.state.allDeFiPositionsV2).toStrictEqual({}); + }); + + it('shares one in-flight promise across concurrent fetchDeFiPositions calls', async () => { + jest.useFakeTimers(); + + let resolveFetch!: (value: V6BalancesResponse) => void; + const mockFetchV6MultiAccountBalances = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const { controller } = setupController({ + mockFetchV6MultiAccountBalances, + }); + + const first = controller.fetchDeFiPositions(); + const second = controller.fetchDeFiPositions({ forceRefresh: true }); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); + + resolveFetch(buildMockBalancesResponse()); + await Promise.all([first, second]); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); }); it('merges fetched accounts into state without clearing other accounts', async () => { diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index e03457e8f98..074836a6081 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -17,6 +17,24 @@ const controllerName = 'DeFiPositionsControllerV2'; const MESSENGER_EXPOSED_METHODS = ['fetchDeFiPositions'] as const; +/** Delay between polls while Accounts API reports DeFi indexing in progress. */ +export const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; + +/** + * Maximum fetch attempts (including the first) while any account still has + * `processingDefiPositions: true`. After this, the call resolves and keeps + * prior state for accounts that never became ready. + */ +export const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; + +/** + * @param ms - Milliseconds to wait. + * @returns A promise that resolves after `ms`. + */ +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export type DeFiPositionsControllerV2State = { /** * DeFi positions keyed by internal MetaMask account ID (`InternalAccount.id`, @@ -98,6 +116,11 @@ export type DeFiPositionsControllerV2Messenger = Messenger< * Deduplication and freshness are handled by the shared TanStack Query cache on * {@link ApiPlatformClient} (balances default `staleTime` is 1 minute). Pass * `{ forceRefresh: true }` to bypass that cache for pull-to-refresh. + * + * When the API reports `processingDefiPositions` for any account, this + * controller polls until indexing finishes or the attempt limit is reached. + * Concurrent calls share one in-flight promise so the UI can treat the pending + * promise as a loading signal. */ export class DeFiPositionsControllerV2 extends BaseController< typeof controllerName, @@ -110,6 +133,8 @@ export class DeFiPositionsControllerV2 extends BaseController< readonly #getVsCurrency: () => string; + #inFlightFetch: Promise | null = null; + /** * @param options - Constructor options. * @param options.messenger - The controller messenger. @@ -154,16 +179,19 @@ export class DeFiPositionsControllerV2 extends BaseController< /** * Fetches DeFi positions for the selected account group. Each account key in * a ready response replaces that account's state (other accounts stay). - * Accounts still indexing (`processingDefiPositions`) are skipped so prior - * state is kept for them. No-ops when disabled or when the group has no - * supported accounts. Caching / spam prevention is handled by the apiClient - * TanStack Query cache (keyed by accounts + query options including - * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache (e.g. - * pull-to-refresh). + * Accounts still indexing (`processingDefiPositions`) keep prior state; the + * method polls (invalidating the balances cache between attempts) until all + * selected accounts are ready, the attempt limit is reached, or a request + * fails. Concurrent calls share one in-flight promise. No-ops when disabled + * or when the group has no supported accounts. Caching / spam prevention is + * handled by the apiClient TanStack Query cache (keyed by accounts + query + * options including `vsCurrency`). Pass `{ forceRefresh: true }` to bypass + * the cache on the first attempt (e.g. pull-to-refresh). * * @param options - Optional fetch modifiers. - * @param options.forceRefresh - When true, bypass the apiClient cache and - * fetch immediately. + * @param options.forceRefresh - When true, bypass the apiClient cache on the + * first attempt and fetch immediately. + * @returns Resolves when the fetch (and any processing polls) finish. */ async fetchDeFiPositions(options?: { forceRefresh?: boolean; @@ -172,6 +200,21 @@ export class DeFiPositionsControllerV2 extends BaseController< return; } + if (this.#inFlightFetch) { + await this.#inFlightFetch; + return; + } + + this.#inFlightFetch = this.#fetchDeFiPositions(options).finally(() => { + this.#inFlightFetch = null; + }); + + await this.#inFlightFetch; + } + + async #fetchDeFiPositions(options?: { + forceRefresh?: boolean; + }): Promise { const selectedAccounts = this.messenger.call( 'AccountTreeController:getAccountsFromSelectedAccountGroup', ); @@ -185,48 +228,82 @@ export class DeFiPositionsControllerV2 extends BaseController< const accountIds = [...internalAccountIdByCaip.keys()]; const vsCurrency = this.#getVsCurrency().toLowerCase(); + const queryOptions = { + networks, + includeDeFiBalances: true, + forceFetchDeFiPositions: true, + includePrices: true, + vsCurrency, + }; + + for ( + let attempt = 0; + attempt < DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS; + attempt++ + ) { + // First attempt respects forceRefresh; later polls always bypass cache + // so we do not spin on a stale processing snapshot. + const fetchOptions = { + ...(options?.forceRefresh || attempt > 0 ? { staleTime: 0 } : {}), + }; - try { - const response = - await this.#apiClient.accounts.fetchV6MultiAccountBalances( - accountIds, - { - networks, - includeDeFiBalances: true, - forceFetchDeFiPositions: true, - includePrices: true, - vsCurrency, - }, - { - // staleTime: 0 makes TanStack treat the cache as stale for this call. - ...(options?.forceRefresh ? { staleTime: 0 } : {}), - }, + try { + const response = + await this.#apiClient.accounts.fetchV6MultiAccountBalances( + accountIds, + queryOptions, + fetchOptions, + ); + + const processingAccounts = response.accounts.filter( + (account) => account.processingDefiPositions, + ); + const readyAccounts = response.accounts.filter( + (account) => !account.processingDefiPositions, ); - // Skip accounts still indexing — their balances are not a valid snapshot. - const readyAccounts = response.accounts.filter( - (account) => !account.processingDefiPositions, - ); - if (readyAccounts.length === 0) { - return; - } + if (readyAccounts.length > 0) { + const positionsByAccount = groupDeFiPositionsV6( + { ...response, accounts: readyAccounts }, + internalAccountIdByCaip, + ); - const positionsByAccount = groupDeFiPositionsV6( - { ...response, accounts: readyAccounts }, - internalAccountIdByCaip, - ); - - // Last valid response wins per ready account; processing / other accounts - // stay untouched. - this.update((state) => { - for (const [accountId, positions] of Object.entries( - positionsByAccount, - )) { - state.allDeFiPositionsV2[accountId] = positions; + // Last valid response wins per ready account; still-indexing accounts + // stay untouched. + this.update((state) => { + for (const [accountId, positions] of Object.entries( + positionsByAccount, + )) { + state.allDeFiPositionsV2[accountId] = positions; + } + }); } - }); - } catch (error) { - console.error('Failed to fetch DeFi positions', error); + + if (processingAccounts.length === 0) { + return; + } + + const { queryKey } = + this.#apiClient.accounts.getV6MultiAccountBalancesQueryOptions( + accountIds, + queryOptions, + fetchOptions, + ); + await this.#apiClient.accounts.queryClient.invalidateQueries({ + queryKey, + }); + + const isLastAttempt = + attempt >= DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS - 1; + if (isLastAttempt) { + return; + } + + await delay(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + } catch (error) { + console.error('Failed to fetch DeFi positions', error); + return; + } } } } From b73dfc89008b0a73e7f78c7f458800f5a094f69b Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Thu, 30 Jul 2026 14:46:33 +0100 Subject: [PATCH 02/21] linting --- .../DeFiPositionsControllerV2.test.ts | 139 +++++++++--------- 1 file changed, 71 insertions(+), 68 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index 57d604fb567..59828fa1149 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -463,75 +463,78 @@ describe('DeFiPositionsControllerV2', () => { jest.useFakeTimers(); const solanaAccountId = `solana:${SolScope.Mainnet.split(':')[1]}:${SOLANA_ADDRESS}`; - const { controller, mockInvalidateQueries, mockFetchV6MultiAccountBalances } = - setupController({ - mockGroupAccounts: GROUP_ACCOUNTS_WITH_SOLANA, - mockFetchV6MultiAccountBalances: jest - .fn() - .mockResolvedValueOnce( - buildMockBalancesResponse({ - accounts: [ - { - accountId: `eip155:0:${EVM_ADDRESS}`, - balances: buildMockBalancesResponse().accounts[0].balances, - }, - { - accountId: solanaAccountId, - balances: [ - { - category: 'defi', - assetId: `${SolScope.Mainnet}/token:${SOLANA_ADDRESS}`, - name: 'Wrapped SOL', - symbol: 'WSOL', - decimals: 9, - balance: '1', - price: '100', - metadata: { - protocolId: 'marinade', - productName: 'Marinade', - description: 'Marinade on solana', - protocolUrl: 'https://marinade.finance/', - protocolIconUrl: 'https://example.com/marinade.png', - positionType: 'stake', - poolAddress: 'pool', - groupId: 'group-marinade-1', - }, + const { + controller, + mockInvalidateQueries, + mockFetchV6MultiAccountBalances, + } = setupController({ + mockGroupAccounts: GROUP_ACCOUNTS_WITH_SOLANA, + mockFetchV6MultiAccountBalances: jest + .fn() + .mockResolvedValueOnce( + buildMockBalancesResponse({ + accounts: [ + { + accountId: `eip155:0:${EVM_ADDRESS}`, + balances: buildMockBalancesResponse().accounts[0].balances, + }, + { + accountId: solanaAccountId, + balances: [ + { + category: 'defi', + assetId: `${SolScope.Mainnet}/token:${SOLANA_ADDRESS}`, + name: 'Wrapped SOL', + symbol: 'WSOL', + decimals: 9, + balance: '1', + price: '100', + metadata: { + protocolId: 'marinade', + productName: 'Marinade', + description: 'Marinade on solana', + protocolUrl: 'https://marinade.finance/', + protocolIconUrl: 'https://example.com/marinade.png', + positionType: 'stake', + poolAddress: 'pool', + groupId: 'group-marinade-1', }, - ], - }, - ], - }), - ) - .mockResolvedValueOnce( - buildMockBalancesResponse({ - accounts: [ - { - accountId: `eip155:0:${EVM_ADDRESS}`, - processingDefiPositions: true, - balances: [], - }, - { - accountId: solanaAccountId, - balances: [], - }, - ], - }), - ) - .mockResolvedValueOnce( - buildMockBalancesResponse({ - accounts: [ - { - accountId: `eip155:0:${EVM_ADDRESS}`, - balances: buildMockBalancesResponse().accounts[0].balances, - }, - { - accountId: solanaAccountId, - balances: [], - }, - ], - }), - ), - }); + }, + ], + }, + ], + }), + ) + .mockResolvedValueOnce( + buildMockBalancesResponse({ + accounts: [ + { + accountId: `eip155:0:${EVM_ADDRESS}`, + processingDefiPositions: true, + balances: [], + }, + { + accountId: solanaAccountId, + balances: [], + }, + ], + }), + ) + .mockResolvedValueOnce( + buildMockBalancesResponse({ + accounts: [ + { + accountId: `eip155:0:${EVM_ADDRESS}`, + balances: buildMockBalancesResponse().accounts[0].balances, + }, + { + accountId: solanaAccountId, + balances: [], + }, + ], + }), + ), + }); await controller.fetchDeFiPositions(); const evmPositions = controller.state.allDeFiPositionsV2['evm-account-id']; From 2a39daa13c56144761d6327c0f9397471c7af8cc Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Thu, 30 Jul 2026 15:29:13 +0100 Subject: [PATCH 03/21] better strategy --- packages/assets-controllers/CHANGELOG.md | 4 +- ...sitionsControllerV2-method-action-types.ts | 4 +- .../DeFiPositionsControllerV2.test.ts | 132 ++++++++++++++++++ .../DeFiPositionsControllerV2.ts | 58 +++++--- 4 files changed, 175 insertions(+), 23 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index a6d3210a71d..09ed5420234 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -12,8 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected account has `processingDefiPositions: true`, applying ready accounts immediately, invalidating the balances cache between attempts, sharing one - in-flight promise across concurrent calls, and stopping on request failure or - the max attempt limit + in-flight promise per selected-account key (so fast switches can join an + earlier poll), and stopping on request failure or the max attempt limit ## [110.0.2] diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts index 89ead7af83c..f0c67a8ed9f 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts @@ -11,7 +11,9 @@ import type { DeFiPositionsControllerV2 } from './DeFiPositionsControllerV2.js'; * Accounts still indexing (`processingDefiPositions`) keep prior state; the * method polls (invalidating the balances cache between attempts) until all * selected accounts are ready, the attempt limit is reached, or a request - * fails. Concurrent calls share one in-flight promise. No-ops when disabled + * fails. Concurrent calls for the same selected accounts share one in-flight + * promise; calls for a different selection start a new fetch and leave prior + * polls running so a later switch back can join them. No-ops when disabled * or when the group has no supported accounts. Caching / spam prevention is * handled by the apiClient TanStack Query cache (keyed by accounts + query * options including `vsCurrency`). Pass `{ forceRefresh: true }` to bypass diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index 59828fa1149..4c6686d6460 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -622,6 +622,138 @@ describe('DeFiPositionsControllerV2', () => { expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); }); + it('starts a new fetch when selection changes during an in-flight call', async () => { + const otherEvmAddress = '0x0000000000000000000000000000000000000002'; + const otherEvmAccount = createMockInternalAccount({ + id: 'evm-account-id-2', + address: otherEvmAddress, + type: EthAccountType.Eoa, + }); + let groupAccounts: InternalAccount[] = GROUP_ACCOUNTS; + + let resolveFirstFetch!: (value: V6BalancesResponse) => void; + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstFetch = resolve; + }), + ) + .mockResolvedValueOnce( + buildMockBalancesResponse({ + accounts: [ + { + accountId: `eip155:0:${otherEvmAddress}`, + balances: buildMockBalancesResponse().accounts[0].balances, + }, + ], + }), + ); + + const { controller } = setupController({ + getGroupAccounts: () => groupAccounts, + mockFetchV6MultiAccountBalances, + }); + + const firstFetch = controller.fetchDeFiPositions(); + await Promise.resolve(); + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); + expect(mockFetchV6MultiAccountBalances.mock.calls[0][0]).toContain( + `eip155:0:${EVM_ADDRESS}`, + ); + + groupAccounts = [otherEvmAccount]; + const secondFetch = controller.fetchDeFiPositions({ forceRefresh: true }); + await Promise.resolve(); + + // Different selection does not join the in-flight promise. + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + expect(mockFetchV6MultiAccountBalances).toHaveBeenLastCalledWith( + expect.arrayContaining([`eip155:0:${otherEvmAddress}`]), + expect.objectContaining({ vsCurrency: 'usd' }), + { staleTime: 0 }, + ); + + resolveFirstFetch(buildMockBalancesResponse()); + await Promise.all([firstFetch, secondFetch]); + + // Prior poll may still write the old group; new fetch writes the new group. + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + expect(controller.state.allDeFiPositionsV2['evm-account-id-2']).toHaveLength( + 1, + ); + }); + + it('rejoins an in-flight fetch when switching back to the same accounts', async () => { + const otherEvmAddress = '0x0000000000000000000000000000000000000002'; + const otherEvmAccount = createMockInternalAccount({ + id: 'evm-account-id-2', + address: otherEvmAddress, + type: EthAccountType.Eoa, + }); + let groupAccounts: InternalAccount[] = GROUP_ACCOUNTS; + + let resolveFirstFetch!: (value: V6BalancesResponse) => void; + let resolveSecondFetch!: (value: V6BalancesResponse) => void; + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstFetch = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecondFetch = resolve; + }), + ); + + const { controller } = setupController({ + getGroupAccounts: () => groupAccounts, + mockFetchV6MultiAccountBalances, + }); + + const firstFetch = controller.fetchDeFiPositions(); + await Promise.resolve(); + + groupAccounts = [otherEvmAccount]; + const secondFetch = controller.fetchDeFiPositions(); + await Promise.resolve(); + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + + groupAccounts = GROUP_ACCOUNTS; + const thirdFetch = controller.fetchDeFiPositions(); + + // Switched back to the first group — join its still-in-flight promise. + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + + resolveFirstFetch(buildMockBalancesResponse()); + resolveSecondFetch( + buildMockBalancesResponse({ + accounts: [ + { + accountId: `eip155:0:${otherEvmAddress}`, + balances: [], + }, + ], + }), + ); + await Promise.all([firstFetch, secondFetch, thirdFetch]); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( + 1, + ); + expect(controller.state.allDeFiPositionsV2['evm-account-id-2']).toStrictEqual( + [], + ); + }); + it('merges fetched accounts into state without clearing other accounts', async () => { const otherEvmAddress = '0x0000000000000000000000000000000000000002'; const otherEvmAccount = createMockInternalAccount({ diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 074836a6081..53ffb2ea319 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -9,6 +9,7 @@ import type { ApiPlatformClient } from '@metamask/core-backend'; import type { Messenger } from '@metamask/messenger'; import { buildDeFiBalancesQuery } from './build-defi-balances-query.js'; +import type { DeFiBalancesQuery } from './build-defi-balances-query.js'; import type { DeFiPositionsControllerV2MethodActions } from './DeFiPositionsControllerV2-method-action-types.js'; import type { DeFiPositionsByAccount } from './group-defi-positions-v6.js'; import { groupDeFiPositionsV6 } from './group-defi-positions-v6.js'; @@ -119,8 +120,10 @@ export type DeFiPositionsControllerV2Messenger = Messenger< * * When the API reports `processingDefiPositions` for any account, this * controller polls until indexing finishes or the attempt limit is reached. - * Concurrent calls share one in-flight promise so the UI can treat the pending - * promise as a loading signal. + * Concurrent calls for the same selected accounts share one in-flight promise + * so the UI can treat the pending promise as a loading signal. Calls for a + * different selection start their own fetch and leave any prior poll running, + * so switching back can join an in-flight fetch for that group. */ export class DeFiPositionsControllerV2 extends BaseController< typeof controllerName, @@ -133,7 +136,12 @@ export class DeFiPositionsControllerV2 extends BaseController< readonly #getVsCurrency: () => string; - #inFlightFetch: Promise | null = null; + /** + * In-flight fetches keyed by selected DeFi-queryable account IDs. Concurrent + * callers for the same selection share a promise; different selections keep + * independent fetches so fast account switching can join an earlier poll. + */ + readonly #inFlightFetches = new Map>(); /** * @param options - Constructor options. @@ -182,7 +190,9 @@ export class DeFiPositionsControllerV2 extends BaseController< * Accounts still indexing (`processingDefiPositions`) keep prior state; the * method polls (invalidating the balances cache between attempts) until all * selected accounts are ready, the attempt limit is reached, or a request - * fails. Concurrent calls share one in-flight promise. No-ops when disabled + * fails. Concurrent calls for the same selected accounts share one in-flight + * promise; calls for a different selection start a new fetch and leave prior + * polls running so a later switch back can join them. No-ops when disabled * or when the group has no supported accounts. Caching / spam prevention is * handled by the apiClient TanStack Query cache (keyed by accounts + query * options including `vsCurrency`). Pass `{ forceRefresh: true }` to bypass @@ -200,28 +210,36 @@ export class DeFiPositionsControllerV2 extends BaseController< return; } - if (this.#inFlightFetch) { - await this.#inFlightFetch; + const selectedAccounts = this.messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); + const query = buildDeFiBalancesQuery(selectedAccounts); + const accountIdsKey = [...query.internalAccountIdByCaip.keys()] + .sort() + .join('\0'); + + const existing = this.#inFlightFetches.get(accountIdsKey); + if (existing) { + await existing; return; } - this.#inFlightFetch = this.#fetchDeFiPositions(options).finally(() => { - this.#inFlightFetch = null; - }); - - await this.#inFlightFetch; - } - - async #fetchDeFiPositions(options?: { - forceRefresh?: boolean; - }): Promise { - const selectedAccounts = this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', + const fetchPromise = this.#fetchDeFiPositions(options, query).finally( + () => { + if (this.#inFlightFetches.get(accountIdsKey) === fetchPromise) { + this.#inFlightFetches.delete(accountIdsKey); + } + }, ); + this.#inFlightFetches.set(accountIdsKey, fetchPromise); - const { networks, internalAccountIdByCaip } = - buildDeFiBalancesQuery(selectedAccounts); + await fetchPromise; + } + async #fetchDeFiPositions( + options: { forceRefresh?: boolean } | undefined, + { networks, internalAccountIdByCaip }: DeFiBalancesQuery, + ): Promise { if (internalAccountIdByCaip.size === 0 || networks.length === 0) { return; } From 9d4205d15de7941cfd162febaeb64d8361ce7814 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Thu, 30 Jul 2026 15:38:35 +0100 Subject: [PATCH 04/21] linting --- .../DeFiPositionsControllerV2.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index 4c6686d6460..429499f9d96 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -682,9 +682,9 @@ describe('DeFiPositionsControllerV2', () => { expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( 1, ); - expect(controller.state.allDeFiPositionsV2['evm-account-id-2']).toHaveLength( - 1, - ); + expect( + controller.state.allDeFiPositionsV2['evm-account-id-2'], + ).toHaveLength(1); }); it('rejoins an in-flight fetch when switching back to the same accounts', async () => { @@ -749,9 +749,9 @@ describe('DeFiPositionsControllerV2', () => { expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( 1, ); - expect(controller.state.allDeFiPositionsV2['evm-account-id-2']).toStrictEqual( - [], - ); + expect( + controller.state.allDeFiPositionsV2['evm-account-id-2'], + ).toStrictEqual([]); }); it('merges fetched accounts into state without clearing other accounts', async () => { From d2b59d08841a2d3abf8209b98c9318b0737e4c95 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Thu, 30 Jul 2026 15:44:52 +0100 Subject: [PATCH 05/21] changelog --- packages/assets-controllers/CHANGELOG.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 09ed5420234..7e187404b4e 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,11 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected - account has `processingDefiPositions: true`, applying ready accounts - immediately, invalidating the balances cache between attempts, sharing one - in-flight promise per selected-account key (so fast switches can join an - earlier poll), and stopping on request failure or the max attempt limit +- `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected account has `processingDefiPositions: true`, applying ready accounts immediately, invalidating the balances cache between attempts, sharing one in-flight promise per selected-account key (so fast switches can join an earlier poll), and stopping on request failure or the max attempt limit ([#9711](https://github.com/MetaMask/core/pull/9711)) ## [110.0.2] From d77c814bbbf6b81da941ceaf0ca671a205833743 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Thu, 30 Jul 2026 15:48:21 +0100 Subject: [PATCH 06/21] use currency as key --- packages/assets-controllers/CHANGELOG.md | 2 +- ...sitionsControllerV2-method-action-types.ts | 15 ++--- .../DeFiPositionsControllerV2.test.ts | 46 ++++++++++++++ .../DeFiPositionsControllerV2.ts | 63 +++++++++++-------- 4 files changed, 91 insertions(+), 35 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 7e187404b4e..08705c467f1 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected account has `processingDefiPositions: true`, applying ready accounts immediately, invalidating the balances cache between attempts, sharing one in-flight promise per selected-account key (so fast switches can join an earlier poll), and stopping on request failure or the max attempt limit ([#9711](https://github.com/MetaMask/core/pull/9711)) +- `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected account has `processingDefiPositions: true`, applying ready accounts immediately, invalidating the balances cache between attempts, sharing one in-flight promise per selected-account + `vsCurrency` key (so fast switches can join an earlier matching poll), and stopping on request failure or the max attempt limit ([#9711](https://github.com/MetaMask/core/pull/9711)) ## [110.0.2] diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts index f0c67a8ed9f..da68e672b44 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts @@ -11,13 +11,14 @@ import type { DeFiPositionsControllerV2 } from './DeFiPositionsControllerV2.js'; * Accounts still indexing (`processingDefiPositions`) keep prior state; the * method polls (invalidating the balances cache between attempts) until all * selected accounts are ready, the attempt limit is reached, or a request - * fails. Concurrent calls for the same selected accounts share one in-flight - * promise; calls for a different selection start a new fetch and leave prior - * polls running so a later switch back can join them. No-ops when disabled - * or when the group has no supported accounts. Caching / spam prevention is - * handled by the apiClient TanStack Query cache (keyed by accounts + query - * options including `vsCurrency`). Pass `{ forceRefresh: true }` to bypass - * the cache on the first attempt (e.g. pull-to-refresh). + * fails. Concurrent calls for the same selected accounts and `vsCurrency` + * share one in-flight promise; calls for a different selection or fiat + * currency start a new fetch and leave prior polls running so a later + * switch back can join them. No-ops when disabled or when the group has no + * supported accounts. Caching / spam prevention is handled by the apiClient + * TanStack Query cache (keyed by accounts + query options including + * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache on the + * first attempt (e.g. pull-to-refresh). * * @param options - Optional fetch modifiers. * @param options.forceRefresh - When true, bypass the apiClient cache on the diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index 429499f9d96..ac4d6a9761c 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -622,6 +622,52 @@ describe('DeFiPositionsControllerV2', () => { expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); }); + it('starts a new fetch when vsCurrency changes during an in-flight call', async () => { + let vsCurrency = 'USD'; + + let resolveUsdFetch!: (value: V6BalancesResponse) => void; + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveUsdFetch = resolve; + }), + ) + .mockResolvedValueOnce(buildMockBalancesResponse()); + + const { controller } = setupController({ + getVsCurrency: () => vsCurrency, + mockFetchV6MultiAccountBalances, + }); + + const usdFetch = controller.fetchDeFiPositions(); + await Promise.resolve(); + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(1); + expect(mockFetchV6MultiAccountBalances).toHaveBeenLastCalledWith( + expect.any(Array), + expect.objectContaining({ vsCurrency: 'usd' }), + {}, + ); + + vsCurrency = 'EUR'; + const eurFetch = controller.fetchDeFiPositions({ forceRefresh: true }); + await Promise.resolve(); + + // Different fiat currency must not join the USD in-flight promise. + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + expect(mockFetchV6MultiAccountBalances).toHaveBeenLastCalledWith( + expect.any(Array), + expect.objectContaining({ vsCurrency: 'eur' }), + { staleTime: 0 }, + ); + + resolveUsdFetch(buildMockBalancesResponse()); + await Promise.all([usdFetch, eurFetch]); + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes(2); + }); + it('starts a new fetch when selection changes during an in-flight call', async () => { const otherEvmAddress = '0x0000000000000000000000000000000000000002'; const otherEvmAccount = createMockInternalAccount({ diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 53ffb2ea319..7a0d1c9ff4b 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -120,10 +120,11 @@ export type DeFiPositionsControllerV2Messenger = Messenger< * * When the API reports `processingDefiPositions` for any account, this * controller polls until indexing finishes or the attempt limit is reached. - * Concurrent calls for the same selected accounts share one in-flight promise - * so the UI can treat the pending promise as a loading signal. Calls for a - * different selection start their own fetch and leave any prior poll running, - * so switching back can join an in-flight fetch for that group. + * Concurrent calls for the same selected accounts and `vsCurrency` share one + * in-flight promise so the UI can treat the pending promise as a loading + * signal. Calls for a different selection or fiat currency start their own + * fetch and leave any prior poll running, so switching back can join an + * in-flight fetch for that group and currency. */ export class DeFiPositionsControllerV2 extends BaseController< typeof controllerName, @@ -137,9 +138,10 @@ export class DeFiPositionsControllerV2 extends BaseController< readonly #getVsCurrency: () => string; /** - * In-flight fetches keyed by selected DeFi-queryable account IDs. Concurrent - * callers for the same selection share a promise; different selections keep - * independent fetches so fast account switching can join an earlier poll. + * In-flight fetches keyed by selected DeFi-queryable account IDs plus + * `vsCurrency`. Concurrent callers for the same selection and currency share + * a promise; different selections or fiat currencies keep independent + * fetches so fast switching can join an earlier matching poll. */ readonly #inFlightFetches = new Map>(); @@ -190,13 +192,14 @@ export class DeFiPositionsControllerV2 extends BaseController< * Accounts still indexing (`processingDefiPositions`) keep prior state; the * method polls (invalidating the balances cache between attempts) until all * selected accounts are ready, the attempt limit is reached, or a request - * fails. Concurrent calls for the same selected accounts share one in-flight - * promise; calls for a different selection start a new fetch and leave prior - * polls running so a later switch back can join them. No-ops when disabled - * or when the group has no supported accounts. Caching / spam prevention is - * handled by the apiClient TanStack Query cache (keyed by accounts + query - * options including `vsCurrency`). Pass `{ forceRefresh: true }` to bypass - * the cache on the first attempt (e.g. pull-to-refresh). + * fails. Concurrent calls for the same selected accounts and `vsCurrency` + * share one in-flight promise; calls for a different selection or fiat + * currency start a new fetch and leave prior polls running so a later + * switch back can join them. No-ops when disabled or when the group has no + * supported accounts. Caching / spam prevention is handled by the apiClient + * TanStack Query cache (keyed by accounts + query options including + * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache on the + * first attempt (e.g. pull-to-refresh). * * @param options - Optional fetch modifiers. * @param options.forceRefresh - When true, bypass the apiClient cache on the @@ -214,24 +217,30 @@ export class DeFiPositionsControllerV2 extends BaseController< 'AccountTreeController:getAccountsFromSelectedAccountGroup', ); const query = buildDeFiBalancesQuery(selectedAccounts); - const accountIdsKey = [...query.internalAccountIdByCaip.keys()] - .sort() - .join('\0'); + const vsCurrency = this.#getVsCurrency().toLowerCase(); + // Include vsCurrency so a fiat change does not join a poll priced in the + // previous currency (TanStack also keys the balances cache on vsCurrency). + const inFlightKey = [ + ...[...query.internalAccountIdByCaip.keys()].sort(), + vsCurrency, + ].join('\0'); - const existing = this.#inFlightFetches.get(accountIdsKey); + const existing = this.#inFlightFetches.get(inFlightKey); if (existing) { await existing; return; } - const fetchPromise = this.#fetchDeFiPositions(options, query).finally( - () => { - if (this.#inFlightFetches.get(accountIdsKey) === fetchPromise) { - this.#inFlightFetches.delete(accountIdsKey); - } - }, - ); - this.#inFlightFetches.set(accountIdsKey, fetchPromise); + const fetchPromise = this.#fetchDeFiPositions( + options, + query, + vsCurrency, + ).finally(() => { + if (this.#inFlightFetches.get(inFlightKey) === fetchPromise) { + this.#inFlightFetches.delete(inFlightKey); + } + }); + this.#inFlightFetches.set(inFlightKey, fetchPromise); await fetchPromise; } @@ -239,13 +248,13 @@ export class DeFiPositionsControllerV2 extends BaseController< async #fetchDeFiPositions( options: { forceRefresh?: boolean } | undefined, { networks, internalAccountIdByCaip }: DeFiBalancesQuery, + vsCurrency: string, ): Promise { if (internalAccountIdByCaip.size === 0 || networks.length === 0) { return; } const accountIds = [...internalAccountIdByCaip.keys()]; - const vsCurrency = this.#getVsCurrency().toLowerCase(); const queryOptions = { networks, includeDeFiBalances: true, From c6a881ad4a96e1b490fbad22cd2532c331bcebcd Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 08:55:20 +0100 Subject: [PATCH 07/21] only update when all accounts are ready --- packages/assets-controllers/CHANGELOG.md | 2 +- .../DeFiPositionsControllerV2.test.ts | 19 ++++--- .../DeFiPositionsControllerV2.ts | 57 +++++++++---------- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 08705c467f1..ef51ab8eaa5 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected account has `processingDefiPositions: true`, applying ready accounts immediately, invalidating the balances cache between attempts, sharing one in-flight promise per selected-account + `vsCurrency` key (so fast switches can join an earlier matching poll), and stopping on request failure or the max attempt limit ([#9711](https://github.com/MetaMask/core/pull/9711)) +- `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected account has `processingDefiPositions: true`, updating state only when every account is ready, invalidating the balances cache between attempts, sharing one in-flight promise per selected-account + `vsCurrency` key (so fast switches can join an earlier matching poll), and stopping on request failure or the max attempt limit ([#9711](https://github.com/MetaMask/core/pull/9711)) ## [110.0.2] diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index ac4d6a9761c..9d9376c2235 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -459,7 +459,7 @@ describe('DeFiPositionsControllerV2', () => { ); }); - it('updates ready accounts immediately while polling ones still indexing', async () => { + it('keeps prior state for all accounts until every account is ready', async () => { jest.useFakeTimers(); const solanaAccountId = `solana:${SolScope.Mainnet.split(':')[1]}:${SOLANA_ADDRESS}`; @@ -538,21 +538,21 @@ describe('DeFiPositionsControllerV2', () => { await controller.fetchDeFiPositions(); const evmPositions = controller.state.allDeFiPositionsV2['evm-account-id']; + const solanaPositions = + controller.state.allDeFiPositionsV2['solana-account-id']; expect(evmPositions).toHaveLength(1); - expect( - controller.state.allDeFiPositionsV2['solana-account-id'], - ).toHaveLength(1); + expect(solanaPositions).toHaveLength(1); const secondFetch = controller.fetchDeFiPositions({ forceRefresh: true }); await Promise.resolve(); - // Ready Solana clears immediately; still-indexing EVM keeps prior positions. + // Mixed ready/processing response: keep prior state for every account. expect(controller.state.allDeFiPositionsV2['evm-account-id']).toBe( evmPositions, ); - expect( - controller.state.allDeFiPositionsV2['solana-account-id'], - ).toStrictEqual([]); + expect(controller.state.allDeFiPositionsV2['solana-account-id']).toBe( + solanaPositions, + ); expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); await jest.advanceTimersByTimeAsync(DEFAULT_PROCESSING_POLL_INTERVAL_MS); @@ -562,6 +562,9 @@ describe('DeFiPositionsControllerV2', () => { expect(controller.state.allDeFiPositionsV2['evm-account-id']).toHaveLength( 1, ); + expect(controller.state.allDeFiPositionsV2['evm-account-id']).not.toBe( + evmPositions, + ); expect( controller.state.allDeFiPositionsV2['solana-account-id'], ).toStrictEqual([]); diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 7a0d1c9ff4b..0526f48e4ae 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -23,8 +23,8 @@ export const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; /** * Maximum fetch attempts (including the first) while any account still has - * `processingDefiPositions: true`. After this, the call resolves and keeps - * prior state for accounts that never became ready. + * `processingDefiPositions: true`. After this, the call resolves without + * updating state, so prior positions are kept for every selected account. */ export const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; @@ -119,12 +119,13 @@ export type DeFiPositionsControllerV2Messenger = Messenger< * `{ forceRefresh: true }` to bypass that cache for pull-to-refresh. * * When the API reports `processingDefiPositions` for any account, this - * controller polls until indexing finishes or the attempt limit is reached. - * Concurrent calls for the same selected accounts and `vsCurrency` share one - * in-flight promise so the UI can treat the pending promise as a loading - * signal. Calls for a different selection or fiat currency start their own - * fetch and leave any prior poll running, so switching back can join an - * in-flight fetch for that group and currency. + * controller polls until indexing finishes or the attempt limit is reached, + * and only then processes and writes state — mixed ready/processing responses + * leave prior state untouched. Concurrent calls for the same selected accounts + * and `vsCurrency` share one in-flight promise so the UI can treat the pending + * promise as a loading signal. Calls for a different selection or fiat currency + * start their own fetch and leave any prior poll running, so switching back can + * join an in-flight fetch for that group and currency. */ export class DeFiPositionsControllerV2 extends BaseController< typeof controllerName, @@ -187,17 +188,18 @@ export class DeFiPositionsControllerV2 extends BaseController< } /** - * Fetches DeFi positions for the selected account group. Each account key in - * a ready response replaces that account's state (other accounts stay). - * Accounts still indexing (`processingDefiPositions`) keep prior state; the - * method polls (invalidating the balances cache between attempts) until all - * selected accounts are ready, the attempt limit is reached, or a request - * fails. Concurrent calls for the same selected accounts and `vsCurrency` - * share one in-flight promise; calls for a different selection or fiat - * currency start a new fetch and leave prior polls running so a later - * switch back can join them. No-ops when disabled or when the group has no - * supported accounts. Caching / spam prevention is handled by the apiClient - * TanStack Query cache (keyed by accounts + query options including + * Fetches DeFi positions for the selected account group. State is updated only + * when every selected account in the response is ready (none report + * `processingDefiPositions`); each account key in that response replaces that + * account's state (other accounts stay). While any account is still indexing, + * prior state is kept and the method polls (invalidating the balances cache + * between attempts) until all selected accounts are ready, the attempt limit + * is reached, or a request fails. Concurrent calls for the same selected + * accounts and `vsCurrency` share one in-flight promise; calls for a different + * selection or fiat currency start a new fetch and leave prior polls running + * so a later switch back can join them. No-ops when disabled or when the group + * has no supported accounts. Caching / spam prevention is handled by the + * apiClient TanStack Query cache (keyed by accounts + query options including * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache on the * first attempt (e.g. pull-to-refresh). * @@ -282,21 +284,19 @@ export class DeFiPositionsControllerV2 extends BaseController< fetchOptions, ); - const processingAccounts = response.accounts.filter( + const stillProcessing = response.accounts.some( (account) => account.processingDefiPositions, ); - const readyAccounts = response.accounts.filter( - (account) => !account.processingDefiPositions, - ); - if (readyAccounts.length > 0) { + // Only process and write when every account is ready so a partial + // response cannot clear or overwrite positions for accounts that are + // still indexing. + if (!stillProcessing) { const positionsByAccount = groupDeFiPositionsV6( - { ...response, accounts: readyAccounts }, + response, internalAccountIdByCaip, ); - // Last valid response wins per ready account; still-indexing accounts - // stay untouched. this.update((state) => { for (const [accountId, positions] of Object.entries( positionsByAccount, @@ -304,9 +304,6 @@ export class DeFiPositionsControllerV2 extends BaseController< state.allDeFiPositionsV2[accountId] = positions; } }); - } - - if (processingAccounts.length === 0) { return; } From e00a3e7048f7286c56b216b8f40c8e110e619ca6 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 09:22:17 +0100 Subject: [PATCH 08/21] generate messenger --- ...sitionsControllerV2-method-action-types.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts index da68e672b44..6249760a1d1 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts @@ -6,17 +6,18 @@ import type { DeFiPositionsControllerV2 } from './DeFiPositionsControllerV2.js'; /** - * Fetches DeFi positions for the selected account group. Each account key in - * a ready response replaces that account's state (other accounts stay). - * Accounts still indexing (`processingDefiPositions`) keep prior state; the - * method polls (invalidating the balances cache between attempts) until all - * selected accounts are ready, the attempt limit is reached, or a request - * fails. Concurrent calls for the same selected accounts and `vsCurrency` - * share one in-flight promise; calls for a different selection or fiat - * currency start a new fetch and leave prior polls running so a later - * switch back can join them. No-ops when disabled or when the group has no - * supported accounts. Caching / spam prevention is handled by the apiClient - * TanStack Query cache (keyed by accounts + query options including + * Fetches DeFi positions for the selected account group. State is updated only + * when every selected account in the response is ready (none report + * `processingDefiPositions`); each account key in that response replaces that + * account's state (other accounts stay). While any account is still indexing, + * prior state is kept and the method polls (invalidating the balances cache + * between attempts) until all selected accounts are ready, the attempt limit + * is reached, or a request fails. Concurrent calls for the same selected + * accounts and `vsCurrency` share one in-flight promise; calls for a different + * selection or fiat currency start a new fetch and leave prior polls running + * so a later switch back can join them. No-ops when disabled or when the group + * has no supported accounts. Caching / spam prevention is handled by the + * apiClient TanStack Query cache (keyed by accounts + query options including * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache on the * first attempt (e.g. pull-to-refresh). * From a07a48d5e4086682c9184f0b6f2154fdab7bfafe Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 09:53:03 +0100 Subject: [PATCH 09/21] coverage --- .../DeFiPositionsController/DeFiPositionsControllerV2.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 0526f48e4ae..2e677cf91d8 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -233,14 +233,14 @@ export class DeFiPositionsControllerV2 extends BaseController< return; } + // Same-key callers join this promise instead of replacing it, so cleanup + // can always delete without checking identity. const fetchPromise = this.#fetchDeFiPositions( options, query, vsCurrency, ).finally(() => { - if (this.#inFlightFetches.get(inFlightKey) === fetchPromise) { - this.#inFlightFetches.delete(inFlightKey); - } + this.#inFlightFetches.delete(inFlightKey); }); this.#inFlightFetches.set(inFlightKey, fetchPromise); From 5ea92133bef18aebac9476c21227c2d5ed63ba79 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 12:10:31 +0100 Subject: [PATCH 10/21] feature flag controlled retries --- packages/assets-controllers/CHANGELOG.md | 2 + packages/assets-controllers/package.json | 1 + ...sitionsControllerV2-method-action-types.ts | 9 +- .../DeFiPositionsControllerV2.test.ts | 127 +++++++++++++++++- .../DeFiPositionsControllerV2.ts | 60 ++++++--- .../defi-controller-v2-feature-flag.test.ts | 104 ++++++++++++++ .../defi-controller-v2-feature-flag.ts | 120 +++++++++++++++++ yarn.lock | 1 + 8 files changed, 396 insertions(+), 28 deletions(-) create mode 100644 packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts create mode 100644 packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 4a6595ab414..8cc7f0c94e1 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `DeFiPositionsControllerV2` reads processing-poll `maxAttempts` / `pollInterval` from the `defiControllerV2` remote feature flag via `RemoteFeatureFlagController:getState` (clients must delegate that action); missing or invalid values fall back to 5 attempts and a 5000ms poll interval +- `DeFiPositionsControllerV2.fetchDeFiPositions` reports the number of fetch attempts to Sentry (via `messenger.captureException`, error name `DeFiPositionsV2FetchAttempts`) when positions become ready after more than one attempt, so processing-poll limits can be tuned from production data - `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected account has `processingDefiPositions: true`, updating state only when every account is ready, invalidating the balances cache between attempts, sharing one in-flight promise per selected-account + `vsCurrency` key (so fast switches can join an earlier matching poll), and stopping on request failure or the max attempt limit ([#9711](https://github.com/MetaMask/core/pull/9711)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) - Bump `@metamask/phishing-controller` from `^17.3.0` to `^17.3.1` ([#9746](https://github.com/MetaMask/core/pull/9746)) diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 366d790e329..1e65fc0c82d 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -82,6 +82,7 @@ "@metamask/polling-controller": "^16.0.9", "@metamask/preferences-controller": "^23.1.0", "@metamask/profile-sync-controller": "^28.3.0", + "@metamask/remote-feature-flag-controller": "^5.0.0", "@metamask/rpc-errors": "^7.0.2", "@metamask/snaps-controllers": "^19.0.0", "@metamask/snaps-sdk": "^11.0.0", diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts index 6249760a1d1..84f9a62cae2 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts @@ -15,9 +15,12 @@ import type { DeFiPositionsControllerV2 } from './DeFiPositionsControllerV2.js'; * is reached, or a request fails. Concurrent calls for the same selected * accounts and `vsCurrency` share one in-flight promise; calls for a different * selection or fiat currency start a new fetch and leave prior polls running - * so a later switch back can join them. No-ops when disabled or when the group - * has no supported accounts. Caching / spam prevention is handled by the - * apiClient TanStack Query cache (keyed by accounts + query options including + * so a later switch back can join them. When a successful ready response + * required more than one attempt, reports the attempt count to Sentry via + * `messenger.captureException` (error name `DeFiPositionsV2FetchAttempts`) so + * poll limits can be tuned. No-ops when disabled or when the group has no + * supported accounts. Caching / spam prevention is handled by the apiClient + * TanStack Query cache (keyed by accounts + query options including * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache on the * first attempt (e.g. pull-to-refresh). * diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index 9d9376c2235..23aecd632a0 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -18,17 +18,21 @@ import type { MessengerEvents, MockAnyNamespace, } from '@metamask/messenger'; +import type { FeatureFlags } from '@metamask/remote-feature-flag-controller'; import { createMockInternalAccount } from '../../../accounts-controller/tests/mocks.js'; import { DEFI_SUPPORTED_NETWORKS } from './build-defi-balances-query.js'; import type { DeFiPositionsControllerV2Messenger } from './DeFiPositionsControllerV2.js'; import { - DEFAULT_PROCESSING_POLL_INTERVAL_MS, - DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS, DeFiPositionsControllerV2, getDefaultDeFiPositionsControllerV2State, } from './DeFiPositionsControllerV2.js'; +/** Mirrors the internal defaults in `defi-controller-v2-feature-flag.ts`. */ +const DEFI_CONTROLLER_V2_FEATURE_FLAG = 'defiControllerV2'; +const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; +const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; + const EVM_ADDRESS = '0x0000000000000000000000000000000000000001'; const SOLANA_ADDRESS = 'So11111111111111111111111111111111111111112'; @@ -150,28 +154,35 @@ function buildProcessingBalancesResponse(): V6BalancesResponse { * @param config - Configuration for the mock setup. * @param config.isEnabled - Whether the controller is enabled. * @param config.getVsCurrency - Fiat currency getter. + * @param config.remoteFeatureFlags - Remote feature flags returned by + * `RemoteFeatureFlagController:getState` (defaults to empty). * @param config.mockGroupAccounts - Accounts returned for the selected group. * @param config.getGroupAccounts - Getter for the selected group accounts * (preferred when the selection changes between fetches). * @param config.mockFetchV6MultiAccountBalances - Mock API fetch function. + * @param config.captureException - Mock Sentry capture function. * @param config.state - Initial controller state. * @returns The controller instance and mocks. */ function setupController({ isEnabled = (): boolean => true, getVsCurrency = (): string => 'USD', + remoteFeatureFlags = {}, mockGroupAccounts = GROUP_ACCOUNTS, getGroupAccounts, mockFetchV6MultiAccountBalances = jest .fn() .mockResolvedValue(buildMockBalancesResponse()), + captureException = jest.fn(), state, }: { isEnabled?: () => boolean; getVsCurrency?: () => string; + remoteFeatureFlags?: FeatureFlags; mockGroupAccounts?: InternalAccount[]; getGroupAccounts?: () => InternalAccount[]; mockFetchV6MultiAccountBalances?: jest.Mock; + captureException?: jest.Mock; state?: Partial>; } = {}): { controller: DeFiPositionsControllerV2; @@ -184,15 +195,24 @@ function setupController({ mockFetchV6MultiAccountBalances: jest.Mock; mockInvalidateQueries: jest.Mock; mockGetV6MultiAccountBalancesQueryOptions: jest.Mock; + mockCaptureException: jest.Mock; } { const messenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, + captureException, }); messenger.registerActionHandler( 'AccountTreeController:getAccountsFromSelectedAccountGroup', () => getGroupAccounts?.() ?? mockGroupAccounts, ); + messenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags, + cacheTimestamp: 0, + }), + ); const controllerMessenger = new Messenger< 'DeFiPositionsControllerV2', @@ -205,7 +225,10 @@ function setupController({ }); messenger.delegate({ messenger: controllerMessenger, - actions: ['AccountTreeController:getAccountsFromSelectedAccountGroup'], + actions: [ + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + 'RemoteFeatureFlagController:getState', + ], }); const mockInvalidateQueries = jest.fn().mockResolvedValue(undefined); @@ -238,6 +261,7 @@ function setupController({ mockFetchV6MultiAccountBalances, mockInvalidateQueries, mockGetV6MultiAccountBalancesQueryOptions, + mockCaptureException: captureException, }; } @@ -459,6 +483,68 @@ describe('DeFiPositionsControllerV2', () => { ); }); + it('does not report attempt count to Sentry when the first fetch succeeds', async () => { + const { controller, mockCaptureException } = setupController(); + + await controller.fetchDeFiPositions(); + + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('reports attempt count to Sentry when positions become ready after polling', async () => { + jest.useFakeTimers(); + + const { controller, mockCaptureException } = setupController({ + mockFetchV6MultiAccountBalances: jest + .fn() + .mockResolvedValueOnce(buildProcessingBalancesResponse()) + .mockResolvedValueOnce(buildProcessingBalancesResponse()) + .mockResolvedValueOnce(buildMockBalancesResponse()), + }); + + const fetchPromise = controller.fetchDeFiPositions(); + await Promise.resolve(); + expect(mockCaptureException).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + await Promise.resolve(); + expect(mockCaptureException).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + await fetchPromise; + + expect(mockCaptureException).toHaveBeenCalledTimes(1); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'DeFiPositionsV2FetchAttempts', + message: + 'DeFiPositionsControllerV2: positions ready after 3 attempt(s)', + }), + ); + }); + + it('does not report attempt count to Sentry when polling hits the max limit', async () => { + jest.useFakeTimers(); + + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockResolvedValue(buildProcessingBalancesResponse()); + const { controller, mockCaptureException } = setupController({ + mockFetchV6MultiAccountBalances, + }); + + const fetchPromise = controller.fetchDeFiPositions(); + + for (let i = 0; i < DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS - 1; i++) { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + } + + await fetchPromise; + + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + it('keeps prior state for all accounts until every account is ready', async () => { jest.useFakeTimers(); @@ -599,6 +685,41 @@ describe('DeFiPositionsControllerV2', () => { expect(controller.state.allDeFiPositionsV2).toStrictEqual({}); }); + it('uses maxAttempts and pollInterval from the defiControllerV2 remote flag', async () => { + jest.useFakeTimers(); + + const remoteMaxAttempts = 3; + const remotePollInterval = 1_000; + const mockFetchV6MultiAccountBalances = jest + .fn() + .mockResolvedValue(buildProcessingBalancesResponse()); + + const { controller, mockInvalidateQueries } = setupController({ + mockFetchV6MultiAccountBalances, + remoteFeatureFlags: { + [DEFI_CONTROLLER_V2_FEATURE_FLAG]: { + maxAttempts: remoteMaxAttempts, + pollInterval: remotePollInterval, + }, + }, + }); + + const fetchPromise = controller.fetchDeFiPositions(); + + for (let i = 0; i < remoteMaxAttempts - 1; i++) { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(remotePollInterval); + } + + await fetchPromise; + + expect(mockFetchV6MultiAccountBalances).toHaveBeenCalledTimes( + remoteMaxAttempts, + ); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(remoteMaxAttempts); + expect(controller.state.allDeFiPositionsV2).toStrictEqual({}); + }); + it('shares one in-flight promise across concurrent fetchDeFiPositions calls', async () => { jest.useFakeTimers(); diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 2e677cf91d8..11ee25dddf5 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -7,9 +7,11 @@ import type { } from '@metamask/base-controller'; import type { ApiPlatformClient } from '@metamask/core-backend'; import type { Messenger } from '@metamask/messenger'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import { buildDeFiBalancesQuery } from './build-defi-balances-query.js'; import type { DeFiBalancesQuery } from './build-defi-balances-query.js'; +import { getProcessingPollConfig } from './defi-controller-v2-feature-flag.js'; import type { DeFiPositionsControllerV2MethodActions } from './DeFiPositionsControllerV2-method-action-types.js'; import type { DeFiPositionsByAccount } from './group-defi-positions-v6.js'; import { groupDeFiPositionsV6 } from './group-defi-positions-v6.js'; @@ -18,16 +20,6 @@ const controllerName = 'DeFiPositionsControllerV2'; const MESSENGER_EXPOSED_METHODS = ['fetchDeFiPositions'] as const; -/** Delay between polls while Accounts API reports DeFi indexing in progress. */ -export const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; - -/** - * Maximum fetch attempts (including the first) while any account still has - * `processingDefiPositions: true`. After this, the call resolves without - * updating state, so prior positions are kept for every selected account. - */ -export const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; - /** * @param ms - Milliseconds to wait. * @returns A promise that resolves after `ms`. @@ -91,7 +83,8 @@ export type DeFiPositionsControllerV2Events = * The external actions available to the {@link DeFiPositionsControllerV2}. */ export type AllowedActions = - AccountTreeControllerGetAccountsFromSelectedAccountGroupAction; + | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction + | RemoteFeatureFlagControllerGetStateAction; /** * The external events available to the {@link DeFiPositionsControllerV2}. @@ -126,6 +119,13 @@ export type DeFiPositionsControllerV2Messenger = Messenger< * promise as a loading signal. Calls for a different selection or fiat currency * start their own fetch and leave any prior poll running, so switching back can * join an in-flight fetch for that group and currency. + * + * Processing-poll `maxAttempts` / `pollInterval` are read via + * {@link getProcessingPollConfig} from the `defiControllerV2` remote feature + * flag (`RemoteFeatureFlagController:getState`), falling back to + * `DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS` / + * `DEFAULT_PROCESSING_POLL_INTERVAL_MS` when unset or invalid. Clients + * must delegate that action to this controller's messenger. */ export class DeFiPositionsControllerV2 extends BaseController< typeof controllerName, @@ -197,9 +197,12 @@ export class DeFiPositionsControllerV2 extends BaseController< * is reached, or a request fails. Concurrent calls for the same selected * accounts and `vsCurrency` share one in-flight promise; calls for a different * selection or fiat currency start a new fetch and leave prior polls running - * so a later switch back can join them. No-ops when disabled or when the group - * has no supported accounts. Caching / spam prevention is handled by the - * apiClient TanStack Query cache (keyed by accounts + query options including + * so a later switch back can join them. When a successful ready response + * required more than one attempt, reports the attempt count to Sentry via + * `messenger.captureException` (error name `DeFiPositionsV2FetchAttempts`) so + * poll limits can be tuned. No-ops when disabled or when the group has no + * supported accounts. Caching / spam prevention is handled by the apiClient + * TanStack Query cache (keyed by accounts + query options including * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache on the * first attempt (e.g. pull-to-refresh). * @@ -265,11 +268,13 @@ export class DeFiPositionsControllerV2 extends BaseController< vsCurrency, }; - for ( - let attempt = 0; - attempt < DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS; - attempt++ - ) { + // Resolve once per fetch so a mid-poll remote-flag change cannot stretch + // or shrink this poll sequence inconsistently. + const { maxAttempts, pollInterval } = getProcessingPollConfig( + this.messenger, + ); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { // First attempt respects forceRefresh; later polls always bypass cache // so we do not spin on a stale processing snapshot. const fetchOptions = { @@ -304,6 +309,18 @@ export class DeFiPositionsControllerV2 extends BaseController< state.allDeFiPositionsV2[accountId] = positions; } }); + + // Report how many attempts were needed (only when polling was + // required) so we can tune DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS / + // INTERVAL from Sentry without flooding on first-try successes. + const attemptsTaken = attempt + 1; + if (attemptsTaken > 1) { + const sentryError = new Error( + `DeFiPositionsControllerV2: positions ready after ${attemptsTaken} attempt(s)`, + ); + sentryError.name = 'DeFiPositionsV2FetchAttempts'; + this.messenger.captureException?.(sentryError); + } return; } @@ -317,13 +334,12 @@ export class DeFiPositionsControllerV2 extends BaseController< queryKey, }); - const isLastAttempt = - attempt >= DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS - 1; + const isLastAttempt = attempt >= maxAttempts - 1; if (isLastAttempt) { return; } - await delay(DEFAULT_PROCESSING_POLL_INTERVAL_MS); + await delay(pollInterval); } catch (error) { console.error('Failed to fetch DeFi positions', error); return; diff --git a/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts new file mode 100644 index 00000000000..8178b40a0c9 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts @@ -0,0 +1,104 @@ +import { getProcessingPollConfig } from './defi-controller-v2-feature-flag.js'; + +/** Mirrors the internal defaults in `defi-controller-v2-feature-flag.ts`. */ +const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; +const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; +const DEFI_CONTROLLER_V2_FEATURE_FLAG = 'defiControllerV2'; + +/** + * @param remoteFeatureFlags - Remote feature flags to return from getState. + * @returns A minimal messenger stub for `getProcessingPollConfig`. + */ +function buildMessenger(remoteFeatureFlags: Record): { + call: jest.Mock; +} { + return { + call: jest.fn().mockReturnValue({ + remoteFeatureFlags, + cacheTimestamp: 0, + }), + }; +} + +describe('getProcessingPollConfig', () => { + it('returns defaults when the remote flag is missing', () => { + const messenger = buildMessenger({}); + + expect(getProcessingPollConfig(messenger)).toStrictEqual({ + maxAttempts: DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS, + pollInterval: DEFAULT_PROCESSING_POLL_INTERVAL_MS, + }); + expect(messenger.call).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + }); + + it('returns defaults when the remote flag is malformed', () => { + const messenger = buildMessenger({ + [DEFI_CONTROLLER_V2_FEATURE_FLAG]: 'not-an-object', + }); + + expect(getProcessingPollConfig(messenger)).toStrictEqual({ + maxAttempts: DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS, + pollInterval: DEFAULT_PROCESSING_POLL_INTERVAL_MS, + }); + }); + + it('resolves maxAttempts and pollInterval from the remote flag', () => { + const messenger = buildMessenger({ + [DEFI_CONTROLLER_V2_FEATURE_FLAG]: { + enabled: true, + maxAttempts: 3, + pollInterval: 1000, + }, + }); + + expect(getProcessingPollConfig(messenger)).toStrictEqual({ + maxAttempts: 3, + pollInterval: 1000, + }); + }); + + it('floors maxAttempts and falls back for non-positive or non-finite values', () => { + expect( + getProcessingPollConfig( + buildMessenger({ + [DEFI_CONTROLLER_V2_FEATURE_FLAG]: { + maxAttempts: 2.9, + pollInterval: 2500, + }, + }), + ), + ).toStrictEqual({ + maxAttempts: 2, + pollInterval: 2500, + }); + + expect( + getProcessingPollConfig( + buildMessenger({ + [DEFI_CONTROLLER_V2_FEATURE_FLAG]: { + maxAttempts: 0, + pollInterval: Number.NaN, + }, + }), + ), + ).toStrictEqual({ + maxAttempts: DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS, + pollInterval: DEFAULT_PROCESSING_POLL_INTERVAL_MS, + }); + + expect( + getProcessingPollConfig( + buildMessenger({ + [DEFI_CONTROLLER_V2_FEATURE_FLAG]: { + maxAttempts: 2, + }, + }), + ), + ).toStrictEqual({ + maxAttempts: 2, + pollInterval: DEFAULT_PROCESSING_POLL_INTERVAL_MS, + }); + }); +}); diff --git a/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts new file mode 100644 index 00000000000..6237b632375 --- /dev/null +++ b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts @@ -0,0 +1,120 @@ +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; +import type { Json } from '@metamask/utils'; + +/** + * Remote feature flag key for DeFi Positions Controller V2 (camelCase, as + * stored by RemoteFeatureFlagController after client-config resolution). + */ +const DEFI_CONTROLLER_V2_FEATURE_FLAG = 'defiControllerV2'; + +/** Delay between polls while Accounts API reports DeFi indexing in progress. */ +const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; + +/** + * Maximum fetch attempts (including the first) while any account still has + * `processingDefiPositions: true`. After this, the call resolves without + * updating state, so prior positions are kept for every selected account. + */ +const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; + +/** + * Resolved `defiControllerV2` remote feature flag shape used for processing + * poll overrides. `enabled` is read by clients for gating; the controller + * only consumes `maxAttempts` / `pollInterval`. + */ +type DeFiControllerV2FeatureFlag = { + enabled?: boolean; + maxAttempts?: number; + pollInterval?: number; +}; + +/** + * Optional processing-poll overrides from {@link DeFiControllerV2FeatureFlag}. + * Missing or non-positive values fall back to + * {@link DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS} / + * {@link DEFAULT_PROCESSING_POLL_INTERVAL_MS}. + */ +type DeFiPositionsControllerV2ProcessingPollConfig = { + maxAttempts?: number; + pollInterval?: number; +}; + +/** + * Resolved positive integer processing-poll limits. + */ +type ResolvedProcessingPollConfig = { + maxAttempts: number; + pollInterval: number; +}; + +/** + * Messenger surface needed to read the DeFi V2 remote feature flag. + */ +type GetProcessingPollConfigMessenger = { + call: ( + actionType: 'RemoteFeatureFlagController:getState', + ) => RemoteFeatureFlagControllerState; +}; + +/** + * @param config - Optional remote poll overrides. + * @returns Resolved positive integer max attempts and poll interval ms. + */ +function resolveProcessingPollConfig( + config?: DeFiPositionsControllerV2ProcessingPollConfig | null, +): ResolvedProcessingPollConfig { + const maxAttempts = + typeof config?.maxAttempts === 'number' && + Number.isFinite(config.maxAttempts) && + config.maxAttempts > 0 + ? Math.floor(config.maxAttempts) + : DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS; + const pollInterval = + typeof config?.pollInterval === 'number' && + Number.isFinite(config.pollInterval) && + config.pollInterval > 0 + ? config.pollInterval + : DEFAULT_PROCESSING_POLL_INTERVAL_MS; + + return { maxAttempts, pollInterval }; +} + +/** + * Narrows a remote feature-flag JSON value to the DeFi V2 poll config fields. + * + * @param flag - Raw flag value from RemoteFeatureFlagController state. + * @returns Poll config fields when present, otherwise `undefined`. + */ +function parseDeFiControllerV2FeatureFlag( + flag: Json | undefined, +): DeFiPositionsControllerV2ProcessingPollConfig | undefined { + if (!flag || typeof flag !== 'object' || Array.isArray(flag)) { + return undefined; + } + + const { maxAttempts, pollInterval } = flag as DeFiControllerV2FeatureFlag; + return { maxAttempts, pollInterval }; +} + +/** + * Reads `defiControllerV2` from RemoteFeatureFlagController and returns + * resolved processing-poll limits. Missing or invalid flag values fall back to + * {@link DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS} / + * {@link DEFAULT_PROCESSING_POLL_INTERVAL_MS}. + * + * @param messenger - Messenger that can call + * `RemoteFeatureFlagController:getState`. + * @returns Positive integer max attempts and poll interval ms. + */ +export function getProcessingPollConfig( + messenger: GetProcessingPollConfigMessenger, +): ResolvedProcessingPollConfig { + const { remoteFeatureFlags } = messenger.call( + 'RemoteFeatureFlagController:getState', + ); + return resolveProcessingPollConfig( + parseDeFiControllerV2FeatureFlag( + remoteFeatureFlags?.[DEFI_CONTROLLER_V2_FEATURE_FLAG], + ), + ); +} diff --git a/yarn.lock b/yarn.lock index 1cdbe464c28..1a7546c1d2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6065,6 +6065,7 @@ __metadata: "@metamask/preferences-controller": "npm:^23.1.0" "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/providers": "npm:^22.1.0" + "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" From 733f2ded0b97f860d3d887ae0abc96ec6c836ed9 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 12:11:51 +0100 Subject: [PATCH 11/21] change default --- .../defi-controller-v2-feature-flag.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts index 6237b632375..3af4a2fae56 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts @@ -8,14 +8,14 @@ import type { Json } from '@metamask/utils'; const DEFI_CONTROLLER_V2_FEATURE_FLAG = 'defiControllerV2'; /** Delay between polls while Accounts API reports DeFi indexing in progress. */ -const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; +const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 3_000; /** * Maximum fetch attempts (including the first) while any account still has * `processingDefiPositions: true`. After this, the call resolves without * updating state, so prior positions are kept for every selected account. */ -const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; +const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 3; /** * Resolved `defiControllerV2` remote feature flag shape used for processing From 75618d8c0c9cb4b876088f52aecfb4b467da6e55 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 12:36:10 +0100 Subject: [PATCH 12/21] readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 06fb06e2385..ed7f97862c6 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,7 @@ linkStyle default opacity:0.5 assets_controllers --> polling_controller; assets_controllers --> preferences_controller; assets_controllers --> profile_sync_controller; + assets_controllers --> remote_feature_flag_controller; assets_controllers --> storage_service; assets_controllers --> transaction_controller; authenticated_user_storage --> base_data_service; From 54ee0c6b956467316eac93fe6f0ba111e2db5198 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 12:36:40 +0100 Subject: [PATCH 13/21] tests --- packages/assets-controllers/CHANGELOG.md | 2 +- .../DeFiPositionsController/DeFiPositionsControllerV2.test.ts | 4 ++-- .../defi-controller-v2-feature-flag.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 8cc7f0c94e1..a76d3eb2e30 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `DeFiPositionsControllerV2` reads processing-poll `maxAttempts` / `pollInterval` from the `defiControllerV2` remote feature flag via `RemoteFeatureFlagController:getState` (clients must delegate that action); missing or invalid values fall back to 5 attempts and a 5000ms poll interval +- `DeFiPositionsControllerV2` reads processing-poll `maxAttempts` / `pollInterval` from the `defiControllerV2` remote feature flag via `RemoteFeatureFlagController:getState` (clients must delegate that action); missing or invalid values fall back to 3 attempts and a 3000ms poll interval - `DeFiPositionsControllerV2.fetchDeFiPositions` reports the number of fetch attempts to Sentry (via `messenger.captureException`, error name `DeFiPositionsV2FetchAttempts`) when positions become ready after more than one attempt, so processing-poll limits can be tuned from production data - `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected account has `processingDefiPositions: true`, updating state only when every account is ready, invalidating the balances cache between attempts, sharing one in-flight promise per selected-account + `vsCurrency` key (so fast switches can join an earlier matching poll), and stopping on request failure or the max attempt limit ([#9711](https://github.com/MetaMask/core/pull/9711)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index 23aecd632a0..0aafbc5e2b5 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -30,8 +30,8 @@ import { /** Mirrors the internal defaults in `defi-controller-v2-feature-flag.ts`. */ const DEFI_CONTROLLER_V2_FEATURE_FLAG = 'defiControllerV2'; -const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; -const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; +const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 3_000; +const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 3; const EVM_ADDRESS = '0x0000000000000000000000000000000000000001'; const SOLANA_ADDRESS = 'So11111111111111111111111111111111111111112'; diff --git a/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts index 8178b40a0c9..a5005f92403 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts @@ -1,8 +1,8 @@ import { getProcessingPollConfig } from './defi-controller-v2-feature-flag.js'; /** Mirrors the internal defaults in `defi-controller-v2-feature-flag.ts`. */ -const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; -const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; +const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 3_000; +const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 3; const DEFI_CONTROLLER_V2_FEATURE_FLAG = 'defiControllerV2'; /** From 2c1b43f24d131fc4d0758855eab7d7c20b0cb7af Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 12:42:53 +0100 Subject: [PATCH 14/21] tsconfig --- packages/assets-controllers/tsconfig.build.json | 3 +++ packages/assets-controllers/tsconfig.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/assets-controllers/tsconfig.build.json b/packages/assets-controllers/tsconfig.build.json index f440265759a..6e3978c1457 100644 --- a/packages/assets-controllers/tsconfig.build.json +++ b/packages/assets-controllers/tsconfig.build.json @@ -59,6 +59,9 @@ }, { "path": "../profile-sync-controller/tsconfig.build.json" + }, + { + "path": "../remote-feature-flag-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"], diff --git a/packages/assets-controllers/tsconfig.json b/packages/assets-controllers/tsconfig.json index 695e5d7c051..7a5d296a792 100644 --- a/packages/assets-controllers/tsconfig.json +++ b/packages/assets-controllers/tsconfig.json @@ -58,6 +58,9 @@ }, { "path": "../profile-sync-controller" + }, + { + "path": "../remote-feature-flag-controller" } ], "include": ["../../types", "./src", "../../tests"] From e57a7a08669a0b60fd7805ee8338f228c47f9ac9 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 13:25:31 +0100 Subject: [PATCH 15/21] add breaking label --- packages/assets-controllers/CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index a76d3eb2e30..6bce9c83752 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,9 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `DeFiPositionsControllerV2` reads processing-poll `maxAttempts` / `pollInterval` from the `defiControllerV2` remote feature flag via `RemoteFeatureFlagController:getState` (clients must delegate that action); missing or invalid values fall back to 3 attempts and a 3000ms poll interval -- `DeFiPositionsControllerV2.fetchDeFiPositions` reports the number of fetch attempts to Sentry (via `messenger.captureException`, error name `DeFiPositionsV2FetchAttempts`) when positions become ready after more than one attempt, so processing-poll limits can be tuned from production data -- `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected account has `processingDefiPositions: true`, updating state only when every account is ready, invalidating the balances cache between attempts, sharing one in-flight promise per selected-account + `vsCurrency` key (so fast switches can join an earlier matching poll), and stopping on request failure or the max attempt limit ([#9711](https://github.com/MetaMask/core/pull/9711)) +- **BREAKING:** `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected account has `processingDefiPositions: true`, updating state only when every account is ready, invalidating the balances cache between attempts, sharing one in-flight promise per selected-account + `vsCurrency` key (so fast switches can join an earlier matching poll), and stopping on request failure or the max attempt limit ([#9711](https://github.com/MetaMask/core/pull/9711)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) - Bump `@metamask/phishing-controller` from `^17.3.0` to `^17.3.1` ([#9746](https://github.com/MetaMask/core/pull/9746)) From dcc93f213cad2c360ecee0e0bcd4c6ca12676690 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 13:47:36 +0100 Subject: [PATCH 16/21] change defaults --- .../DeFiPositionsController/DeFiPositionsControllerV2.test.ts | 4 ++-- .../defi-controller-v2-feature-flag.test.ts | 4 ++-- .../defi-controller-v2-feature-flag.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index 0aafbc5e2b5..23aecd632a0 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -30,8 +30,8 @@ import { /** Mirrors the internal defaults in `defi-controller-v2-feature-flag.ts`. */ const DEFI_CONTROLLER_V2_FEATURE_FLAG = 'defiControllerV2'; -const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 3_000; -const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 3; +const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; +const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; const EVM_ADDRESS = '0x0000000000000000000000000000000000000001'; const SOLANA_ADDRESS = 'So11111111111111111111111111111111111111112'; diff --git a/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts index a5005f92403..8178b40a0c9 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.test.ts @@ -1,8 +1,8 @@ import { getProcessingPollConfig } from './defi-controller-v2-feature-flag.js'; /** Mirrors the internal defaults in `defi-controller-v2-feature-flag.ts`. */ -const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 3_000; -const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 3; +const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; +const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; const DEFI_CONTROLLER_V2_FEATURE_FLAG = 'defiControllerV2'; /** diff --git a/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts index 3af4a2fae56..6237b632375 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/defi-controller-v2-feature-flag.ts @@ -8,14 +8,14 @@ import type { Json } from '@metamask/utils'; const DEFI_CONTROLLER_V2_FEATURE_FLAG = 'defiControllerV2'; /** Delay between polls while Accounts API reports DeFi indexing in progress. */ -const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 3_000; +const DEFAULT_PROCESSING_POLL_INTERVAL_MS = 5_000; /** * Maximum fetch attempts (including the first) while any account still has * `processingDefiPositions: true`. After this, the call resolves without * updating state, so prior positions are kept for every selected account. */ -const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 3; +const DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS = 5; /** * Resolved `defiControllerV2` remote feature flag shape used for processing From 2da4fd54fc42fb389570b01c760f9626261988fa Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 16:30:34 +0100 Subject: [PATCH 17/21] comments --- .../DeFiPositionsControllerV2.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 11ee25dddf5..538dc441185 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -109,7 +109,8 @@ export type DeFiPositionsControllerV2Messenger = Messenger< * * Deduplication and freshness are handled by the shared TanStack Query cache on * {@link ApiPlatformClient} (balances default `staleTime` is 1 minute). Pass - * `{ forceRefresh: true }` to bypass that cache for pull-to-refresh. + * `{ forceRefresh: true }` to bypass that cache on the first attempt (e.g. + * pull-to-refresh); later processing polls always bypass the cache. * * When the API reports `processingDefiPositions` for any account, this * controller polls until indexing finishes or the attempt limit is reached, @@ -122,10 +123,9 @@ export type DeFiPositionsControllerV2Messenger = Messenger< * * Processing-poll `maxAttempts` / `pollInterval` are read via * {@link getProcessingPollConfig} from the `defiControllerV2` remote feature - * flag (`RemoteFeatureFlagController:getState`), falling back to - * `DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS` / - * `DEFAULT_PROCESSING_POLL_INTERVAL_MS` when unset or invalid. Clients - * must delegate that action to this controller's messenger. + * flag (`RemoteFeatureFlagController:getState`), falling back to built-in + * defaults when unset or invalid. Clients must delegate that action to this + * controller's messenger. */ export class DeFiPositionsControllerV2 extends BaseController< typeof controllerName, @@ -311,8 +311,8 @@ export class DeFiPositionsControllerV2 extends BaseController< }); // Report how many attempts were needed (only when polling was - // required) so we can tune DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS / - // INTERVAL from Sentry without flooding on first-try successes. + // required) so Sentry can inform remote-flag / default poll-limit + // tuning without flooding on first-try successes. const attemptsTaken = attempt + 1; if (attemptsTaken > 1) { const sentryError = new Error( From e39cfd933d2170b2a503b0a4de2eaea8f8bcd20d Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 16:32:25 +0100 Subject: [PATCH 18/21] changelog --- packages/assets-controllers/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 6bce9c83752..cc86ea37e5e 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING:** `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected account has `processingDefiPositions: true`, updating state only when every account is ready, invalidating the balances cache between attempts, sharing one in-flight promise per selected-account + `vsCurrency` key (so fast switches can join an earlier matching poll), and stopping on request failure or the max attempt limit ([#9711](https://github.com/MetaMask/core/pull/9711)) + - Clients must allow and delegate `RemoteFeatureFlagController:getState` on the `DeFiPositionsControllerV2` messenger. - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) - Bump `@metamask/phishing-controller` from `^17.3.0` to `^17.3.1` ([#9746](https://github.com/MetaMask/core/pull/9746)) From 38f37025ec99ef7c0c2159b993b8452a5b87ad40 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 16:39:35 +0100 Subject: [PATCH 19/21] error after exhausting attempts --- ...sitionsControllerV2-method-action-types.ts | 8 ++++--- .../DeFiPositionsControllerV2.test.ts | 10 +++++++-- .../DeFiPositionsControllerV2.ts | 21 +++++++++++++------ 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts index 84f9a62cae2..b2a4f67f065 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2-method-action-types.ts @@ -16,9 +16,11 @@ import type { DeFiPositionsControllerV2 } from './DeFiPositionsControllerV2.js'; * accounts and `vsCurrency` share one in-flight promise; calls for a different * selection or fiat currency start a new fetch and leave prior polls running * so a later switch back can join them. When a successful ready response - * required more than one attempt, reports the attempt count to Sentry via - * `messenger.captureException` (error name `DeFiPositionsV2FetchAttempts`) so - * poll limits can be tuned. No-ops when disabled or when the group has no + * required more than one attempt, or when polling hits the attempt limit + * while still processing, reports to Sentry via `messenger.captureException` + * (error names `DeFiPositionsV2FetchAttempts` / + * `DeFiPositionsV2ProcessingPollExhausted`) so poll limits can be tuned. + * No-ops when disabled or when the group has no * supported accounts. Caching / spam prevention is handled by the apiClient * TanStack Query cache (keyed by accounts + query options including * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache on the diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts index 23aecd632a0..512273cd0b8 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.test.ts @@ -523,7 +523,7 @@ describe('DeFiPositionsControllerV2', () => { ); }); - it('does not report attempt count to Sentry when polling hits the max limit', async () => { + it('reports to Sentry when polling hits the max limit while still processing', async () => { jest.useFakeTimers(); const mockFetchV6MultiAccountBalances = jest @@ -542,7 +542,13 @@ describe('DeFiPositionsControllerV2', () => { await fetchPromise; - expect(mockCaptureException).not.toHaveBeenCalled(); + expect(mockCaptureException).toHaveBeenCalledTimes(1); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'DeFiPositionsV2ProcessingPollExhausted', + message: `DeFiPositionsControllerV2: still processing after ${DEFAULT_PROCESSING_POLL_MAX_ATTEMPTS} attempt(s)`, + }), + ); }); it('keeps prior state for all accounts until every account is ready', async () => { diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 538dc441185..41c6a6289a5 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -198,9 +198,11 @@ export class DeFiPositionsControllerV2 extends BaseController< * accounts and `vsCurrency` share one in-flight promise; calls for a different * selection or fiat currency start a new fetch and leave prior polls running * so a later switch back can join them. When a successful ready response - * required more than one attempt, reports the attempt count to Sentry via - * `messenger.captureException` (error name `DeFiPositionsV2FetchAttempts`) so - * poll limits can be tuned. No-ops when disabled or when the group has no + * required more than one attempt, or when polling hits the attempt limit + * while still processing, reports to Sentry via `messenger.captureException` + * (error names `DeFiPositionsV2FetchAttempts` / + * `DeFiPositionsV2ProcessingPollExhausted`) so poll limits can be tuned. + * No-ops when disabled or when the group has no * supported accounts. Caching / spam prevention is handled by the apiClient * TanStack Query cache (keyed by accounts + query options including * `vsCurrency`). Pass `{ forceRefresh: true }` to bypass the cache on the @@ -315,11 +317,11 @@ export class DeFiPositionsControllerV2 extends BaseController< // tuning without flooding on first-try successes. const attemptsTaken = attempt + 1; if (attemptsTaken > 1) { - const sentryError = new Error( + const multipleAttemptsError = new Error( `DeFiPositionsControllerV2: positions ready after ${attemptsTaken} attempt(s)`, ); - sentryError.name = 'DeFiPositionsV2FetchAttempts'; - this.messenger.captureException?.(sentryError); + multipleAttemptsError.name = 'DeFiPositionsV2FetchAttempts'; + this.messenger.captureException?.(multipleAttemptsError); } return; } @@ -336,6 +338,13 @@ export class DeFiPositionsControllerV2 extends BaseController< const isLastAttempt = attempt >= maxAttempts - 1; if (isLastAttempt) { + // Report exhausted polls so Sentry can inform remote-flag / default + // poll-limit tuning when indexing never finishes in time. + const multipleAttemptsError = new Error( + `DeFiPositionsControllerV2: still processing after ${maxAttempts} attempt(s)`, + ); + multipleAttemptsError.name = 'DeFiPositionsV2ProcessingPollExhausted'; + this.messenger.captureException?.(multipleAttemptsError); return; } From 371fc29867cfc7aae5a5929ae125e171d8900399 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 16:44:58 +0100 Subject: [PATCH 20/21] changelog --- packages/assets-controllers/CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 264db30fcc9..2c6339ebe47 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,12 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [110.1.1] - ### Changed - **BREAKING:** `DeFiPositionsControllerV2.fetchDeFiPositions` now polls while any selected account has `processingDefiPositions: true`, updating state only when every account is ready, invalidating the balances cache between attempts, sharing one in-flight promise per selected-account + `vsCurrency` key (so fast switches can join an earlier matching poll), and stopping on request failure or the max attempt limit ([#9711](https://github.com/MetaMask/core/pull/9711)) - Clients must allow and delegate `RemoteFeatureFlagController:getState` on the `DeFiPositionsControllerV2` messenger. + +## [110.1.1] + +### Changed + - Bump `@metamask/core-backend` from `^8.1.0` to `^8.1.1` ([#9779](https://github.com/MetaMask/core/pull/9779)) - Bump `@metamask/account-tree-controller` from `^7.5.5` to `^7.6.0` ([#9779](https://github.com/MetaMask/core/pull/9779)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) From abbcfa61d1a2fb2cde26b2c5c8d4e7995be83b4b Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Tue, 4 Aug 2026 18:26:53 +0100 Subject: [PATCH 21/21] narrow try/catch --- .../DeFiPositionsControllerV2.ts | 113 +++++++++--------- 1 file changed, 57 insertions(+), 56 deletions(-) diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts index 41c6a6289a5..d913972cd02 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsControllerV2.ts @@ -283,76 +283,77 @@ export class DeFiPositionsControllerV2 extends BaseController< ...(options?.forceRefresh || attempt > 0 ? { staleTime: 0 } : {}), }; + let response; try { - const response = - await this.#apiClient.accounts.fetchV6MultiAccountBalances( - accountIds, - queryOptions, - fetchOptions, - ); - - const stillProcessing = response.accounts.some( - (account) => account.processingDefiPositions, + response = await this.#apiClient.accounts.fetchV6MultiAccountBalances( + accountIds, + queryOptions, + fetchOptions, ); + } catch (error) { + // Soft-fail so prior state stays and the in-flight promise settles. + console.error('Failed to fetch DeFi positions', error); + return; + } - // Only process and write when every account is ready so a partial - // response cannot clear or overwrite positions for accounts that are - // still indexing. - if (!stillProcessing) { - const positionsByAccount = groupDeFiPositionsV6( - response, - internalAccountIdByCaip, - ); + const stillProcessing = response.accounts.some( + (account) => account.processingDefiPositions, + ); + + // Only process and write when every account is ready so a partial + // response cannot clear or overwrite positions for accounts that are + // still indexing. + if (!stillProcessing) { + const positionsByAccount = groupDeFiPositionsV6( + response, + internalAccountIdByCaip, + ); - this.update((state) => { - for (const [accountId, positions] of Object.entries( - positionsByAccount, - )) { - state.allDeFiPositionsV2[accountId] = positions; - } - }); - - // Report how many attempts were needed (only when polling was - // required) so Sentry can inform remote-flag / default poll-limit - // tuning without flooding on first-try successes. - const attemptsTaken = attempt + 1; - if (attemptsTaken > 1) { - const multipleAttemptsError = new Error( - `DeFiPositionsControllerV2: positions ready after ${attemptsTaken} attempt(s)`, - ); - multipleAttemptsError.name = 'DeFiPositionsV2FetchAttempts'; - this.messenger.captureException?.(multipleAttemptsError); + this.update((state) => { + for (const [accountId, positions] of Object.entries( + positionsByAccount, + )) { + state.allDeFiPositionsV2[accountId] = positions; } - return; - } - - const { queryKey } = - this.#apiClient.accounts.getV6MultiAccountBalancesQueryOptions( - accountIds, - queryOptions, - fetchOptions, - ); - await this.#apiClient.accounts.queryClient.invalidateQueries({ - queryKey, }); - const isLastAttempt = attempt >= maxAttempts - 1; - if (isLastAttempt) { - // Report exhausted polls so Sentry can inform remote-flag / default - // poll-limit tuning when indexing never finishes in time. + // Report how many attempts were needed (only when polling was + // required) so Sentry can inform remote-flag / default poll-limit + // tuning without flooding on first-try successes. + const attemptsTaken = attempt + 1; + if (attemptsTaken > 1) { const multipleAttemptsError = new Error( - `DeFiPositionsControllerV2: still processing after ${maxAttempts} attempt(s)`, + `DeFiPositionsControllerV2: positions ready after ${attemptsTaken} attempt(s)`, ); - multipleAttemptsError.name = 'DeFiPositionsV2ProcessingPollExhausted'; + multipleAttemptsError.name = 'DeFiPositionsV2FetchAttempts'; this.messenger.captureException?.(multipleAttemptsError); - return; } + return; + } - await delay(pollInterval); - } catch (error) { - console.error('Failed to fetch DeFi positions', error); + const { queryKey } = + this.#apiClient.accounts.getV6MultiAccountBalancesQueryOptions( + accountIds, + queryOptions, + fetchOptions, + ); + await this.#apiClient.accounts.queryClient.invalidateQueries({ + queryKey, + }); + + const isLastAttempt = attempt >= maxAttempts - 1; + if (isLastAttempt) { + // Report exhausted polls so Sentry can inform remote-flag / default + // poll-limit tuning when indexing never finishes in time. + const multipleAttemptsError = new Error( + `DeFiPositionsControllerV2: still processing after ${maxAttempts} attempt(s)`, + ); + multipleAttemptsError.name = 'DeFiPositionsV2ProcessingPollExhausted'; + this.messenger.captureException?.(multipleAttemptsError); return; } + + await delay(pollInterval); } } }