Skip to content
Open
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
279 changes: 275 additions & 4 deletions app/components/UI/Perps/controllers/PerpsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,31 @@ describe('PerpsController', () => {
HyperLiquidProvider as jest.MockedClass<typeof HyperLiquidProvider>
).mockImplementation(() => mockProvider);

// Create mock messenger call function that handles RemoteFeatureFlagController:getState
const mockCall = jest.fn().mockImplementation((action: string) => {
if (action === 'RemoteFeatureFlagController:getState') {
return {
remoteFeatureFlags: {
perpsPerpTradingGeoBlockedCountriesV2: {
blockedRegions: [],
},
},
};
}
return undefined;
});

// Create a new controller instance
controller = new PerpsController({
messenger: {
call: jest.fn(),
call: mockCall,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
registerEventHandler: jest.fn(),
registerInitialEventPayload: jest.fn(),
getRestricted: jest.fn().mockReturnValue({
call: jest.fn(),
call: mockCall,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
Expand All @@ -136,6 +150,250 @@ describe('PerpsController', () => {
expect(controller.state.connectionStatus).toBe('disconnected');
expect(controller.state.isEligible).toBe(false);
});

it('should read current RemoteFeatureFlagController state during construction', () => {
// Given: A mock messenger that tracks calls
const mockCall = jest.fn().mockImplementation((action: string) => {
if (action === 'RemoteFeatureFlagController:getState') {
return {
remoteFeatureFlags: {
perpsPerpTradingGeoBlockedCountriesV2: {
blockedRegions: ['US', 'CA'],
},
},
};
}
return undefined;
});

// When: Controller is constructed
const testController = new PerpsController({
messenger: {
call: mockCall,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
registerEventHandler: jest.fn(),
registerInitialEventPayload: jest.fn(),
getRestricted: jest.fn().mockReturnValue({
call: mockCall,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
registerEventHandler: jest.fn(),
registerInitialEventPayload: jest.fn(),
}),
} as unknown as any,
state: getDefaultPerpsControllerState(),
});

// Then: Should have called to get RemoteFeatureFlagController state
expect(testController).toBeDefined();
expect(mockCall).toHaveBeenCalledWith(
'RemoteFeatureFlagController:getState',
);
});

it('should apply remote blocked regions when available during construction', () => {
// Given: Remote feature flags with blocked regions
const mockCall = jest.fn().mockImplementation((action: string) => {
if (action === 'RemoteFeatureFlagController:getState') {
return {
remoteFeatureFlags: {
perpsPerpTradingGeoBlockedCountriesV2: {
blockedRegions: ['US-NY', 'CA-ON'],
},
},
};
}
return undefined;
});

// When: Controller is constructed
const testController = new PerpsController({
messenger: {
call: mockCall,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
registerEventHandler: jest.fn(),
registerInitialEventPayload: jest.fn(),
getRestricted: jest.fn().mockReturnValue({
call: mockCall,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
registerEventHandler: jest.fn(),
registerInitialEventPayload: jest.fn(),
}),
} as unknown as any,
state: getDefaultPerpsControllerState(),
clientConfig: {
fallbackBlockedRegions: ['FALLBACK-REGION'],
},
});

// Then: Should have used remote regions (not fallback)
// Verify by checking the internal blockedRegionList
expect((testController as any).blockedRegionList.source).toBe('remote');
expect((testController as any).blockedRegionList.list).toEqual([
'US-NY',
'CA-ON',
]);
});

it('should use fallback regions when remote flags are not available', () => {
// Given: Remote feature flags without blocked regions
const mockCall = jest.fn().mockImplementation((action: string) => {
if (action === 'RemoteFeatureFlagController:getState') {
return {
remoteFeatureFlags: {},
};
}
return undefined;
});

// When: Controller is constructed with fallback regions
const testController = new PerpsController({
messenger: {
call: mockCall,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
registerEventHandler: jest.fn(),
registerInitialEventPayload: jest.fn(),
getRestricted: jest.fn().mockReturnValue({
call: mockCall,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
registerEventHandler: jest.fn(),
registerInitialEventPayload: jest.fn(),
}),
} as unknown as any,
state: getDefaultPerpsControllerState(),
clientConfig: {
fallbackBlockedRegions: ['FALLBACK-US', 'FALLBACK-CA'],
},
});

// Then: Should have used fallback regions
expect((testController as any).blockedRegionList.source).toBe('fallback');
expect((testController as any).blockedRegionList.list).toEqual([
'FALLBACK-US',
'FALLBACK-CA',
]);
});

it('should never downgrade from remote to fallback regions', () => {
// Given: Remote feature flags with blocked regions
const mockCall = jest.fn().mockImplementation((action: string) => {
if (action === 'RemoteFeatureFlagController:getState') {
return {
remoteFeatureFlags: {
perpsPerpTradingGeoBlockedCountriesV2: {
blockedRegions: ['REMOTE-US'],
},
},
};
}
return undefined;
});

// When: Controller is constructed with both remote and fallback
const testController = new PerpsController({
messenger: {
call: mockCall,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
registerEventHandler: jest.fn(),
registerInitialEventPayload: jest.fn(),
getRestricted: jest.fn().mockReturnValue({
call: mockCall,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
registerEventHandler: jest.fn(),
registerInitialEventPayload: jest.fn(),
}),
} as unknown as any,
state: getDefaultPerpsControllerState(),
clientConfig: {
fallbackBlockedRegions: ['FALLBACK-US'],
},
});

// Then: Should use remote (set after fallback)
expect((testController as any).blockedRegionList.source).toBe('remote');
expect((testController as any).blockedRegionList.list).toEqual([
'REMOTE-US',
]);

// When: Attempt to set fallback again (simulating what setBlockedRegionList does)
(testController as any).setBlockedRegionList(
['NEW-FALLBACK'],
'fallback',
);

// Then: Should still use remote (no downgrade)
expect((testController as any).blockedRegionList.source).toBe('remote');
expect((testController as any).blockedRegionList.list).toEqual([
'REMOTE-US',
]);
});

it('continues initialization when RemoteFeatureFlagController state call throws error', () => {
// Arrange: Mock messenger that throws error for RemoteFeatureFlagController:getState
const mockCall = jest.fn().mockImplementation((action: string) => {
if (action === 'RemoteFeatureFlagController:getState') {
throw new Error('RemoteFeatureFlagController not ready');
}
return undefined;
});
const mockLoggerError = jest.spyOn(Logger, 'error');

// Act: Construct controller with fallback regions
const testController = new PerpsController({
messenger: {
call: mockCall,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
registerEventHandler: jest.fn(),
registerInitialEventPayload: jest.fn(),
getRestricted: jest.fn().mockReturnValue({
call: mockCall,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
registerEventHandler: jest.fn(),
registerInitialEventPayload: jest.fn(),
}),
} as unknown as any,
state: getDefaultPerpsControllerState(),
clientConfig: {
fallbackBlockedRegions: ['FALLBACK-US', 'FALLBACK-CA'],
},
});

// Assert: Controller initializes successfully and uses fallback
expect(testController).toBeDefined();
expect((testController as any).blockedRegionList.source).toBe('fallback');
expect((testController as any).blockedRegionList.list).toEqual([
'FALLBACK-US',
'FALLBACK-CA',
]);
expect(mockLoggerError).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
context: 'PerpsController.constructor',
operation: 'readRemoteFeatureFlags',
}),
);

mockLoggerError.mockRestore();
});
});

describe('getActiveProvider', () => {
Expand Down Expand Up @@ -1402,16 +1660,29 @@ describe('PerpsController', () => {

it('should skip data lake reporting for testnet', async () => {
// Arrange - create a new controller with testnet state
const mockCallTestnet = jest.fn().mockImplementation((action: string) => {
if (action === 'RemoteFeatureFlagController:getState') {
return {
remoteFeatureFlags: {
perpsPerpTradingGeoBlockedCountriesV2: {
blockedRegions: [],
},
},
};
}
return undefined;
});

const testnetController = new PerpsController({
messenger: {
call: jest.fn(),
call: mockCallTestnet,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
registerEventHandler: jest.fn(),
registerInitialEventPayload: jest.fn(),
getRestricted: jest.fn().mockReturnValue({
call: jest.fn(),
call: mockCallTestnet,
publish: jest.fn(),
subscribe: jest.fn(),
registerActionHandler: jest.fn(),
Expand Down
37 changes: 33 additions & 4 deletions app/components/UI/Perps/controllers/PerpsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ import type {
HistoricalPortfolioResult,
} from './types';
import { getEnvironment } from './utils';
import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller';
import type {
RemoteFeatureFlagControllerState,
RemoteFeatureFlagControllerGetStateAction,
} from '@metamask/remote-feature-flag-controller';
import type { RemoteFeatureFlagControllerStateChangeEvent } from '@metamask/remote-feature-flag-controller/dist/remote-feature-flag-controller.d.cts';

// Simple wait utility
Expand All @@ -95,7 +98,8 @@ const wait = (ms: number): Promise<void> =>
export { PERPS_ERROR_CODES, type PerpsErrorCode } from './perpsErrorCodes';

const ON_RAMP_GEO_BLOCKING_URLS = {
DEV: 'https://on-ramp.dev-api.cx.metamask.io/geolocation',
// Use UAT endpoint since DEV endpoint is less reliable.
DEV: 'https://on-ramp.uat-api.cx.metamask.io/geolocation',
PROD: 'https://on-ramp.api.cx.metamask.io/geolocation',
};

Expand Down Expand Up @@ -410,7 +414,8 @@ export type PerpsControllerActions =
*/
export type AllowedActions =
| NetworkControllerGetStateAction
| AuthenticationController.AuthenticationControllerGetBearerToken;
| AuthenticationController.AuthenticationControllerGetBearerToken
| RemoteFeatureFlagControllerGetStateAction;

/**
* External events the PerpsController can subscribe to
Expand Down Expand Up @@ -493,7 +498,31 @@ export class PerpsController extends BaseController<
'fallback',
);

// RemoteFeatureFlagController state is empty by default so we must wait for it to be populated.
/**
* Immediately read current state to catch any flags already loaded
* This is necessary to avoid race conditions where the RemoteFeatureFlagController fetches flags
* before the PerpsController initializes its RemoteFeatureFlagController subscription.
*
* We still subscribe in case the RemoteFeatureFlagController is not yet populated and updates later.
*/
try {
const currentRemoteFeatureFlagState = this.messagingSystem.call(
'RemoteFeatureFlagController:getState',
);

this.refreshEligibilityOnFeatureFlagChange(currentRemoteFeatureFlagState);
} catch (error) {
// If we can't read the remote feature flags at construction time, we'll rely on:
// 1. The fallback blocked regions already set above
// 2. The subscription to catch updates when RemoteFeatureFlagController is ready
Logger.error(
ensureError(error),
this.getErrorContext('constructor', {
operation: 'readRemoteFeatureFlags',
}),
);
}

this.messagingSystem.subscribe(
'RemoteFeatureFlagController:stateChange',
this.refreshEligibilityOnFeatureFlagChange.bind(this),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function getPerpsControllerMessenger(
'AccountsController:getSelectedAccount',
'NetworkController:getState',
'AuthenticationController:getBearerToken',
'RemoteFeatureFlagController:getState',
],
});
}
Loading