diff --git a/app/core/Engine/controllers/card-controller/CardController.test.ts b/app/core/Engine/controllers/card-controller/CardController.test.ts index 33f92d4269eb..25a08ab12d26 100644 --- a/app/core/Engine/controllers/card-controller/CardController.test.ts +++ b/app/core/Engine/controllers/card-controller/CardController.test.ts @@ -24,6 +24,12 @@ jest.mock('../../../../util/Logger'); const mockTokenStore = CardTokenStore as jest.Mocked; +async function flushPromises(times = 5): Promise { + for (let i = 0; i < times; i++) { + await Promise.resolve(); + } +} + function buildMessenger() { return new Messenger< 'CardController', @@ -314,6 +320,71 @@ describe('CardController — auth methods', () => { expect(result.done).toBe(true); }); + it('drops in-flight unauthenticated card home data after successful auth', async () => { + const staleAsset = { ...mockAsset, symbol: 'STALE' }; + const staleHomeData = { + ...mockCardHomeData, + primaryFundingAsset: staleAsset, + fundingAssets: [staleAsset], + }; + const authenticatedAsset = { ...mockAsset, symbol: 'AUTH' }; + const authenticatedHomeData = { + ...mockCardHomeData, + primaryFundingAsset: authenticatedAsset, + fundingAssets: [authenticatedAsset], + }; + let resolveStaleFetch: (data: CardHomeData) => void = () => undefined; + + const provider = buildMockProvider(); + const getOnChainAssetsMock = + provider.getOnChainAssets as jest.MockedFunction< + NonNullable + >; + provider.initiateAuth.mockResolvedValue(mockSession); + provider.submitCredentials.mockResolvedValue({ + done: true, + tokenSet: mockTokenSet, + }); + getOnChainAssetsMock.mockReturnValue( + new Promise((resolve) => { + resolveStaleFetch = resolve; + }), + ); + provider.validateTokens.mockReturnValue('valid'); + provider.getCardHomeData.mockResolvedValue(authenticatedHomeData); + mockTokenStore.get + .mockResolvedValueOnce(null) + .mockResolvedValue(mockTokenSet); + mockTokenStore.set.mockResolvedValue(true); + + const { controller } = buildControllerWithMockMessenger(provider); + const staleFetchPromise = controller.fetchCardHomeData(); + await flushPromises(); + + expect(getOnChainAssetsMock).toHaveBeenCalledTimes(1); + + await controller.initiateAuth('US'); + await controller.submitCredentials({ + type: 'email_password', + email: 'a@b.com', + password: 'pass', + }); + await flushPromises(); + + expect(provider.getCardHomeData).toHaveBeenCalledTimes(1); + expect(controller.state.cardHomeData).toStrictEqual( + authenticatedHomeData, + ); + + resolveStaleFetch(staleHomeData); + await staleFetchPromise; + await flushPromises(); + + expect(controller.state.cardHomeData).toStrictEqual( + authenticatedHomeData, + ); + }); + it('still sets isAuthenticated when token store write fails', async () => { const provider = buildMockProvider(); provider.initiateAuth.mockResolvedValue(mockSession); @@ -646,6 +717,46 @@ describe('CardController — event subscriptions', () => { ); }); + it('subscribes to RemoteFeatureFlagController:stateChange on construction', () => { + const mockMessenger = buildMockMessenger(); + new CardController({ + messenger: mockMessenger, + providers: {}, + }); + + expect(mockMessenger.subscribe).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:stateChange', + expect.any(Function), + expect.any(Function), + ); + }); + + it('clears and refetches card home data when the card feature flag changes', () => { + const provider = buildMockProvider(); + const { controller, messenger } = buildControllerWithMockMessenger( + provider, + { + cardHomeData: mockCardHomeData as unknown as Record, + cardHomeDataStatus: 'success', + }, + ); + const fetchSpy = jest + .spyOn(controller, 'fetchCardHomeData') + .mockResolvedValue(); + + const remoteFeatureFlagHandler = ( + messenger.subscribe as jest.Mock + ).mock.calls.find( + ([event]) => event === 'RemoteFeatureFlagController:stateChange', + )?.[1]; + + remoteFeatureFlagHandler?.('{}'); + + expect(controller.state.cardHomeData).toBeNull(); + expect(controller.state.cardHomeDataStatus).toBe('idle'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + it('calls validateAndRefreshSession on KeyringController:unlock', async () => { const provider = buildMockProvider(); mockTokenStore.get.mockResolvedValue(null); diff --git a/app/core/Engine/controllers/card-controller/CardController.ts b/app/core/Engine/controllers/card-controller/CardController.ts index ddd1fa35fe32..62931996edf3 100644 --- a/app/core/Engine/controllers/card-controller/CardController.ts +++ b/app/core/Engine/controllers/card-controller/CardController.ts @@ -37,6 +37,10 @@ import { import { CardTokenStore } from './CardTokenStore'; import { isEthAccount } from '../../../Multichain/utils'; import { pickPrimaryFromReordered, reorderAssets } from './utils/assetPriority'; +import { + resolveCardFeatureFlag, + type CardFeatureFlag, +} from '../../../../selectors/featureFlagController/card'; const CARDHOLDER_BATCH_SIZE = 50; const CARDHOLDER_MAX_BATCHES = 3; @@ -170,6 +174,26 @@ export class CardController extends BaseController< .sort() .join(','), ); + + this.messenger.subscribe( + 'RemoteFeatureFlagController:stateChange', + (_cardFeatureKey: string) => { + this.#handleCardFeatureFlagChange(); + }, + (state) => JSON.stringify(state.remoteFeatureFlags?.cardFeature ?? {}), + ); + } + + #fetchCardHomeDataWithLogging(method: string): void { + this.fetchCardHomeData().catch((error) => + Logger.error(error as Error, { + tags: { feature: 'card' }, + context: { + name: 'CardController', + data: { method }, + }, + }), + ); } #handleAccountSwitch(): void { @@ -182,18 +206,22 @@ export class CardController extends BaseController< s.cardHomeData = null; s.cardHomeDataStatus = 'idle'; }); - this.fetchCardHomeData().catch((error) => - Logger.error(error as Error, { - tags: { feature: 'card' }, - context: { - name: 'CardController', - data: { method: '#handleAccountSwitch' }, - }, - }), - ); + this.#fetchCardHomeDataWithLogging('#handleAccountSwitch'); } } + #handleCardFeatureFlagChange(): void { + const currentAddress = this.#getSelectedEvmAddress(); + if (!currentAddress) return; + + this.invalidateFetch(); + this.update((s) => { + s.cardHomeData = null; + s.cardHomeDataStatus = 'idle'; + }); + this.#fetchCardHomeDataWithLogging('#handleCardFeatureFlagChange'); + } + #triggerCardholderCheck(): void { // Debounce: coalesce rapid consecutive triggers (e.g. KeyringController:unlock // and AccountTreeController:stateChange both fire on wallet unlock) into a @@ -220,9 +248,11 @@ export class CardController extends BaseController< const featureState = this.messenger.call( 'RemoteFeatureFlagController:getState', ); - const cardFeature = featureState.remoteFeatureFlags?.cardFeature as - | { constants?: { accountsApiUrl?: string } } - | undefined; + const cardFeature = resolveCardFeatureFlag( + featureState.remoteFeatureFlags?.cardFeature as + | CardFeatureFlag + | undefined, + ); const accountsApiUrl = cardFeature?.constants?.accountsApiUrl; if (!accountsApiUrl) return; @@ -475,19 +505,14 @@ export class CardController extends BaseController< } this.update((s) => { s.isAuthenticated = true; + s.cardHomeData = null; + s.cardHomeDataStatus = 'idle'; (s.providerData as unknown as Record>)[ pid ] = { location: tokenSet.location }; }); - this.fetchCardHomeData().catch((error) => - Logger.error(error as Error, { - tags: { feature: 'card' }, - context: { - name: 'CardController', - data: { method: 'submitCredentials/fetchCardHomeData' }, - }, - }), - ); + this.invalidateFetch(); + this.#fetchCardHomeDataWithLogging('submitCredentials/fetchCardHomeData'); } return result; @@ -541,14 +566,8 @@ export class CardController extends BaseController< // Always fetch card home data regardless of auth state: authenticated users // get full card data, unauthenticated users get on-chain asset state. - this.fetchCardHomeData().catch((error) => - Logger.error(error as Error, { - tags: { feature: 'card' }, - context: { - name: 'CardController', - data: { method: 'validateAndRefreshSession/fetchCardHomeData' }, - }, - }), + this.#fetchCardHomeDataWithLogging( + 'validateAndRefreshSession/fetchCardHomeData', ); if (!tokens) return { isAuthenticated: false }; diff --git a/app/core/Engine/controllers/card-controller/index.ts b/app/core/Engine/controllers/card-controller/index.ts index ba78306b5b78..8a6cc315799b 100644 --- a/app/core/Engine/controllers/card-controller/index.ts +++ b/app/core/Engine/controllers/card-controller/index.ts @@ -4,7 +4,10 @@ import type { CardControllerMessenger } from './types'; import { BaanxService } from './services/BaanxService'; import { BaanxProvider } from './providers/BaanxProvider'; import { resolveBaanxConfig } from './services/baanx-config'; -import type { CardFeatureFlag } from '../../../../selectors/featureFlagController/card'; +import { + resolveCardFeatureFlag, + type CardFeatureFlag, +} from '../../../../selectors/featureFlagController/card'; /** * Initialize the CardController. @@ -18,17 +21,21 @@ export const cardControllerInit: MessengerClientInitFunction< > = (request) => { const { controllerMessenger, persistedState } = request; - const featureState = controllerMessenger.call( - 'RemoteFeatureFlagController:getState', - ); - const cardFeatureFlag = featureState.remoteFeatureFlags?.cardFeature as - | CardFeatureFlag - | undefined; + const getCardFeatureFlag = () => { + const featureState = controllerMessenger.call( + 'RemoteFeatureFlagController:getState', + ); + return resolveCardFeatureFlag( + featureState.remoteFeatureFlags?.cardFeature as + | CardFeatureFlag + | undefined, + ); + }; const baanxConfig = resolveBaanxConfig(); const baanxProvider = new BaanxProvider({ service: new BaanxService(baanxConfig), - cardFeatureFlag, + getCardFeatureFlag, }); const controller = new CardController({ diff --git a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts index a3f2793f7c7c..bd2b53d05747 100644 --- a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts +++ b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts @@ -688,6 +688,27 @@ describe('BaanxProvider', () => { ); }); + it('reads card feature flags lazily when checking on-chain assets', async () => { + spendersMock.mockResolvedValue([limitedTuple('50'), limitedTuple('0')]); + let currentCardFeatureFlag: CardFeatureFlag | null = null; + + const p = new BaanxProvider({ + service, + getCardFeatureFlag: () => currentCardFeatureFlag, + }); + currentCardFeatureFlag = cardFeatureFlag; + + const result = await p.getOnChainAssets(ownerAddr); + + expect(spendersMock).toHaveBeenCalledWith( + ownerAddr, + [tokenA, tokenB], + expect.any(Array), + ); + expect(result.primaryFundingAsset?.symbol).toBe('USDC'); + expect(result.primaryFundingAsset?.address).toBe(tokenA); + }); + it('uses #findLastApprovedToken when multiple tokens have non-zero allowance and prefers latest Approval log', async () => { spendersMock.mockResolvedValue([limitedTuple('10'), limitedTuple('20')]); diff --git a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts index befb50fbf6d2..281c2cb288d7 100644 --- a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts +++ b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts @@ -247,17 +247,24 @@ export class BaanxProvider implements ICardProvider { } private readonly service: BaanxService; - private readonly cardFeatureFlag: CardFeatureFlag | null; + private readonly getCardFeatureFlag: () => CardFeatureFlag | null; constructor({ service, cardFeatureFlag, + getCardFeatureFlag, }: { service: BaanxService; cardFeatureFlag?: CardFeatureFlag; + getCardFeatureFlag?: () => CardFeatureFlag | null | undefined; }) { this.service = service; - this.cardFeatureFlag = cardFeatureFlag ?? null; + this.getCardFeatureFlag = () => + getCardFeatureFlag?.() ?? cardFeatureFlag ?? null; + } + + private get cardFeatureFlag(): CardFeatureFlag | null { + return this.getCardFeatureFlag(); } // -- Auth -- diff --git a/app/core/Engine/controllers/card-controller/types.ts b/app/core/Engine/controllers/card-controller/types.ts index e2fec89b4d9e..e56afb831f46 100644 --- a/app/core/Engine/controllers/card-controller/types.ts +++ b/app/core/Engine/controllers/card-controller/types.ts @@ -13,7 +13,10 @@ import type { KeyringControllerUnlockEvent, KeyringControllerSignPersonalMessageAction, } from '@metamask/keyring-controller'; -import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerStateChangeEvent, +} from '@metamask/remote-feature-flag-controller'; import type { NetworkControllerFindNetworkClientIdByChainIdAction } from '@metamask/network-controller'; import type { TransactionControllerAddTransactionAction } from '@metamask/transaction-controller'; import type { CardHomeData } from './provider-types'; @@ -71,6 +74,7 @@ type CardControllerAllowedActions = type CardControllerAllowedEvents = | AccountTreeControllerStateChangeEvent + | RemoteFeatureFlagControllerStateChangeEvent | KeyringControllerUnlockEvent; export type CardControllerMessenger = Messenger< diff --git a/app/core/Engine/messengers/card-controller-messenger/index.ts b/app/core/Engine/messengers/card-controller-messenger/index.ts index 8b7aa6ca103d..f2b5c42a0720 100644 --- a/app/core/Engine/messengers/card-controller-messenger/index.ts +++ b/app/core/Engine/messengers/card-controller-messenger/index.ts @@ -35,7 +35,11 @@ export function getCardControllerMessenger( 'NetworkController:findNetworkClientIdByChainId', 'TransactionController:addTransaction', ], - events: ['AccountTreeController:stateChange', 'KeyringController:unlock'], + events: [ + 'AccountTreeController:stateChange', + 'RemoteFeatureFlagController:stateChange', + 'KeyringController:unlock', + ], }); return messenger; diff --git a/app/selectors/featureFlagController/card/index.ts b/app/selectors/featureFlagController/card/index.ts index 281e9117f628..433ddaae9ec5 100644 --- a/app/selectors/featureFlagController/card/index.ts +++ b/app/selectors/featureFlagController/card/index.ts @@ -2,7 +2,7 @@ import { createSelector } from 'reselect'; import { selectRemoteFeatureFlags } from '..'; import { validatedVersionGatedFeatureFlag } from '../../../util/remoteFeatureFlag'; -const defaultCardFeatureFlag: CardFeatureFlag = { +export const defaultCardFeatureFlag: CardFeatureFlag = { chains: { 'eip155:59144': { balanceScannerAddress: '0xed9f04f2da1b42ae558d5e688fe2ef7080931c9a', @@ -147,14 +147,21 @@ export interface SupportedToken { symbol?: string | null; } +export const resolveCardFeatureFlag = ( + cardFeatureFlag?: CardFeatureFlag | null, +): CardFeatureFlag => + Object.keys(cardFeatureFlag ?? {}).length > 0 + ? (cardFeatureFlag as CardFeatureFlag) + : defaultCardFeatureFlag; + export const selectCardFeatureFlag = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => { const cardFeatureFlag = remoteFeatureFlags?.cardFeature; - return Object.keys(cardFeatureFlag ?? {}).length > 0 - ? cardFeatureFlag - : defaultCardFeatureFlag; + return resolveCardFeatureFlag( + cardFeatureFlag as CardFeatureFlag | undefined, + ); }, );