diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 32f188d44c0..b67329574d8 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -723,6 +723,10 @@ export type PerpsControllerActions = | { type: 'PerpsController:resetSelectedPaymentToken'; handler: PerpsController['resetSelectedPaymentToken']; + } + | { + type: 'PerpsController:startEligibilityMonitoring'; + handler: PerpsController['startEligibilityMonitoring']; }; /** @@ -750,6 +754,13 @@ export type PerpsControllerOptions = { * Must be provided by the platform (mobile/extension) at instantiation time. */ infrastructure: PerpsPlatformDependencies; + /** + * When true, defers geo-blocking eligibility checks until + * {@link PerpsController.startEligibilityMonitoring} is called. + * Use this during wallet onboarding to prevent the geolocation fetch + * from firing before onboarding is complete. + */ + deferEligibilityCheck?: boolean; }; type BlockedRegionList = { @@ -792,6 +803,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'saveOrderBookGrouping', 'setSelectedPaymentToken', 'resetSelectedPaymentToken', + 'startEligibilityMonitoring', ] as const; /** @@ -889,6 +901,8 @@ export class PerpsController extends BaseController< #standaloneProviderHip3Version: number | null = null; + #eligibilityCheckDeferred: boolean; + // Store options for dependency injection (allows core package to inject platform-specific services) readonly #options: PerpsControllerOptions; @@ -914,6 +928,7 @@ export class PerpsController extends BaseController< state = {}, clientConfig = {}, infrastructure, + deferEligibilityCheck = false, }: PerpsControllerOptions) { super({ name: 'PerpsController', @@ -922,6 +937,8 @@ export class PerpsController extends BaseController< state: { ...getDefaultPerpsControllerState(), ...state }, }); + this.#eligibilityCheckDeferred = deferEligibilityCheck; + // Store options for dependency injection this.#options = { messenger, @@ -3698,15 +3715,41 @@ export class PerpsController extends BaseController< */ /** - * Fetch geo location + * Activates geo-blocking eligibility monitoring. + * Call this after onboarding is complete when the controller was constructed + * with `deferEligibilityCheck: true`. * - * Returned in Country or Country-Region format - * Example: FR, DE, US-MI, CA-ON + * Reads the current RemoteFeatureFlagController state, performs the initial + * eligibility check, and unblocks the existing stateChange subscription so + * future feature-flag updates flow through normally. + * Safe to call multiple times; subsequent calls simply re-check eligibility. */ + startEligibilityMonitoring(): void { + this.#eligibilityCheckDeferred = false; + + try { + const currentState = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + this.refreshEligibilityOnFeatureFlagChange(currentState); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.startEligibilityMonitoring'), + this.#getErrorContext('startEligibilityMonitoring', { + operation: 'readRemoteFeatureFlags', + }), + ); + } + } + /** * Refresh eligibility status */ async refreshEligibility(): Promise { + if (this.#eligibilityCheckDeferred) { + return; + } + // Capture the current version before starting the async operation. // This prevents race conditions where stale eligibility checks // (started with fallback config) overwrite results from newer checks diff --git a/packages/perps-controller/tests/defer-eligibility.test.ts b/packages/perps-controller/tests/defer-eligibility.test.ts new file mode 100644 index 00000000000..fa9b241af0f --- /dev/null +++ b/packages/perps-controller/tests/defer-eligibility.test.ts @@ -0,0 +1,258 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import { PerpsController } from '../src/PerpsController'; +import type { PerpsControllerMessenger } from '../src/PerpsController'; +import type { PerpsPlatformDependencies } from '../src/types'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({})); + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +const noopLogger = { + error: jest.fn(), + warn: jest.fn(), +}; + +const noopDebugLogger = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; + +function buildMockInfrastructure(): PerpsPlatformDependencies { + return { + logger: noopLogger as unknown as PerpsPlatformDependencies['logger'], + debugLogger: + noopDebugLogger as unknown as PerpsPlatformDependencies['debugLogger'], + metrics: { + trackEvent: jest.fn(), + } as unknown as PerpsPlatformDependencies['metrics'], + performance: { + startTrace: jest.fn(), + endTrace: jest.fn(), + } as unknown as PerpsPlatformDependencies['performance'], + tracer: { + trace: jest.fn(), + } as unknown as PerpsPlatformDependencies['tracer'], + streamManager: { + subscribe: jest.fn(), + unsubscribe: jest.fn(), + pauseChannel: jest.fn(), + resumeChannel: jest.fn(), + } as unknown as PerpsPlatformDependencies['streamManager'], + featureFlags: { validateVersionGated: jest.fn() }, + marketDataFormatters: { + formatPrice: jest.fn(), + formatSize: jest.fn(), + } as unknown as PerpsPlatformDependencies['marketDataFormatters'], + cacheInvalidator: { + invalidate: jest.fn(), + } as unknown as PerpsPlatformDependencies['cacheInvalidator'], + rewards: { getPerpsDiscountForAccount: jest.fn().mockResolvedValue(0) }, + }; +} + +const MOCK_REMOTE_FEATURE_FLAG_STATE = { + remoteFeatureFlags: {}, + cacheTimestamp: 0, +}; + +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function getControllerMessenger( + rootMessenger: RootMessenger, +): PerpsControllerMessenger { + const messenger: PerpsControllerMessenger = new Messenger({ + namespace: 'PerpsController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [ + 'RemoteFeatureFlagController:getState', + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'NetworkController:findNetworkClientIdByChainId', + 'KeyringController:getState', + 'KeyringController:signTypedMessage', + 'TransactionController:addTransaction', + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + 'AuthenticationController:getBearerToken', + ], + events: [ + 'RemoteFeatureFlagController:stateChange', + 'AccountTreeController:selectedAccountGroupChange', + ], + messenger, + }); + + return messenger; +} + +type BuildControllerOptions = { + deferEligibilityCheck?: boolean; +}; + +function buildController({ + deferEligibilityCheck, +}: BuildControllerOptions = {}): { + controller: PerpsController; + rootMessenger: RootMessenger; + controllerMessenger: PerpsControllerMessenger; +} { + const rootMessenger = getRootMessenger(); + + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => MOCK_REMOTE_FEATURE_FLAG_STATE, + ); + + const controllerMessenger = getControllerMessenger(rootMessenger); + + const controller = new PerpsController({ + messenger: controllerMessenger, + infrastructure: buildMockInfrastructure(), + deferEligibilityCheck, + }); + + return { controller, rootMessenger, controllerMessenger }; +} + +describe('PerpsController - deferEligibilityCheck', () => { + describe('when deferEligibilityCheck is true', () => { + it('does not trigger a geolocation fetch during construction', async () => { + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + text: () => Promise.resolve('US'), + } as globalThis.Response); + + buildController({ deferEligibilityCheck: true }); + + // Allow any pending microtasks to flush + await new Promise((resolve) => process.nextTick(resolve)); + + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it('does not trigger a geolocation fetch from subscription events', async () => { + const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + text: () => Promise.resolve('US'), + } as globalThis.Response); + + const { rootMessenger } = buildController({ + deferEligibilityCheck: true, + }); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { ...MOCK_REMOTE_FEATURE_FLAG_STATE }, + [], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it('keeps isEligible at default (false) during deferral', () => { + const { controller } = buildController({ + deferEligibilityCheck: true, + }); + + expect(controller.state.isEligible).toBe(false); + }); + }); + + describe('startEligibilityMonitoring', () => { + it('is callable after deferred construction', () => { + const { controller } = buildController({ + deferEligibilityCheck: true, + }); + + expect(() => controller.startEligibilityMonitoring()).not.toThrow(); + }); + + it('reads current RemoteFeatureFlagController state', () => { + const rootMessenger = getRootMessenger(); + const getStateMock = jest + .fn() + .mockReturnValue(MOCK_REMOTE_FEATURE_FLAG_STATE); + + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + getStateMock, + ); + + const controllerMessenger = getControllerMessenger(rootMessenger); + const controller = new PerpsController({ + messenger: controllerMessenger, + infrastructure: buildMockInfrastructure(), + deferEligibilityCheck: true, + }); + + getStateMock.mockClear(); + + controller.startEligibilityMonitoring(); + + expect(getStateMock).toHaveBeenCalledTimes(1); + }); + + it('unblocks future subscription-driven eligibility checks', () => { + const refreshSpy = jest.spyOn( + PerpsController.prototype as unknown as { + refreshEligibilityOnFeatureFlagChange: (...args: unknown[]) => void; + }, + 'refreshEligibilityOnFeatureFlagChange', + ); + + const { controller, rootMessenger } = buildController({ + deferEligibilityCheck: true, + }); + + const callCountAfterConstruction = refreshSpy.mock.calls.length; + + controller.startEligibilityMonitoring(); + + const callCountAfterStart = refreshSpy.mock.calls.length; + expect(callCountAfterStart).toBe(callCountAfterConstruction + 1); + + rootMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { ...MOCK_REMOTE_FEATURE_FLAG_STATE }, + [], + ); + + expect(refreshSpy.mock.calls).toHaveLength(callCountAfterStart + 1); + refreshSpy.mockRestore(); + }); + }); + + describe('when deferEligibilityCheck is false (default)', () => { + it('triggers eligibility processing during construction', () => { + const refreshSpy = jest.spyOn( + PerpsController.prototype as unknown as { + refreshEligibilityOnFeatureFlagChange: (...args: unknown[]) => void; + }, + 'refreshEligibilityOnFeatureFlagChange', + ); + + buildController({ deferEligibilityCheck: false }); + + expect(refreshSpy).toHaveBeenCalled(); + refreshSpy.mockRestore(); + }); + }); +});