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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions app/core/Engine/controllers/card-controller/CardController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ jest.mock('../../../../util/Logger');

const mockTokenStore = CardTokenStore as jest.Mocked<typeof CardTokenStore>;

async function flushPromises(times = 5): Promise<void> {
for (let i = 0; i < times; i++) {
await Promise.resolve();
}
}

function buildMessenger() {
return new Messenger<
'CardController',
Expand Down Expand Up @@ -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<ICardProvider['getOnChainAssets']>
>;
provider.initiateAuth.mockResolvedValue(mockSession);
provider.submitCredentials.mockResolvedValue({
done: true,
tokenSet: mockTokenSet,
});
getOnChainAssetsMock.mockReturnValue(
new Promise<CardHomeData>((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);
Expand Down Expand Up @@ -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<string, null>,
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);
Expand Down
77 changes: 48 additions & 29 deletions app/core/Engine/controllers/card-controller/CardController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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;

Expand Down Expand Up @@ -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<string, Record<string, string>>)[
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;
Expand Down Expand Up @@ -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 };
Expand Down
23 changes: 15 additions & 8 deletions app/core/Engine/controllers/card-controller/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 --
Expand Down
6 changes: 5 additions & 1 deletion app/core/Engine/controllers/card-controller/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,6 +74,7 @@ type CardControllerAllowedActions =

type CardControllerAllowedEvents =
| AccountTreeControllerStateChangeEvent
| RemoteFeatureFlagControllerStateChangeEvent
| KeyringControllerUnlockEvent;

export type CardControllerMessenger = Messenger<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading