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
49 changes: 46 additions & 3 deletions packages/perps-controller/src/PerpsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,10 @@ export type PerpsControllerActions =
| {
type: 'PerpsController:resetSelectedPaymentToken';
handler: PerpsController['resetSelectedPaymentToken'];
}
| {
type: 'PerpsController:startEligibilityMonitoring';
handler: PerpsController['startEligibilityMonitoring'];
};

/**
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -792,6 +803,7 @@ const MESSENGER_EXPOSED_METHODS = [
'saveOrderBookGrouping',
'setSelectedPaymentToken',
'resetSelectedPaymentToken',
'startEligibilityMonitoring',
] as const;

/**
Expand Down Expand Up @@ -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;

Expand All @@ -914,6 +928,7 @@ export class PerpsController extends BaseController<
state = {},
clientConfig = {},
infrastructure,
deferEligibilityCheck = false,
}: PerpsControllerOptions) {
super({
name: 'PerpsController',
Expand All @@ -922,6 +937,8 @@ export class PerpsController extends BaseController<
state: { ...getDefaultPerpsControllerState(), ...state },
});

this.#eligibilityCheckDeferred = deferEligibilityCheck;

// Store options for dependency injection
this.#options = {
messenger,
Expand Down Expand Up @@ -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<void> {
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
Expand Down
258 changes: 258 additions & 0 deletions packages/perps-controller/tests/defer-eligibility.test.ts
Original file line number Diff line number Diff line change
@@ -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<PerpsControllerMessenger>,
MessengerEvents<PerpsControllerMessenger>
>;

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();
});
});
});
Loading