Skip to content

Commit 62f6136

Browse files
authored
feat(perps): core resolver — providerCredentials, builder fee injection, env var centralization (MetaMask#27899)
## **Description** Resolves several core-parity and architecture concerns for the perps controller: ### 1. Backport core-only changes (existing) - **`stopEligibilityMonitoring()`** — Disables geo-blocking eligibility checks when `useExternalServices` is toggled off. - **Dynamic MYX import** — `await import()` → `.then()/.catch()` to avoid bundling heavy MYX dependencies in extension. ### 2. Nested `providerCredentials` on `PerpsControllerConfig` - Restructures flat MYX config fields (`myxAppIdTestnet`, `myxProviderEnabled`, etc.) into `providerCredentials.myx.*`. - Adds `providerCredentials.hyperliquid.*` for builder fee wallet addresses. - New types: `PerpsProviderCredentials`, `HyperLiquidCredentials`, `MYXCredentials`. ### 3. Builder fee address injection - `HyperLiquidProvider` accepts optional `builderAddressTestnet`/`builderAddressMainnet` via constructor. - Falls back to hardcoded `BUILDER_FEE_CONFIG` defaults when env vars are empty. - New env vars in `.js.env.example`: `MM_PERPS_HL_BUILDER_ADDRESS_TESTNET`, `MM_PERPS_HL_BUILDER_ADDRESS_MAINNET`. ### 4. Env var centralization in mobile adapter - New `createMobileClientConfig()` in `mobileInfrastructure.ts` — all `process.env.*` reads in one place. - Engine init (`perps-controller/index.ts`) reduced from ~73 to ~37 lines — pure controller wiring, no env var reads. ### 5. MYX dynamic import race condition fix - `await import()` inside non-async `#createProviders(): void` → `.then()/.catch()` chain. - Removes `@ts-expect-error` suppression. Fixes provider setup race condition flagged by bugbot. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: N/A — architecture cleanup + core parity ## **Manual testing steps** ```gherkin Feature: Provider credentials and builder fee injection Scenario: HyperLiquid uses env-var builder address when set Given MM_PERPS_HL_BUILDER_ADDRESS_TESTNET is set in .js.env When a trade is placed on testnet Then the builder fee uses the env-var address (not hardcoded default) Scenario: HyperLiquid falls back to default when env var is empty Given MM_PERPS_HL_BUILDER_ADDRESS_TESTNET is empty When a trade is placed on testnet Then the builder fee uses BUILDER_FEE_CONFIG.TestnetBuilder Scenario: MYX provider registers via dynamic import Given MYX provider is enabled When PerpsController initializes Then MYX registers asynchronously via .then()/.catch() And initialization completes without waiting for MYX Scenario: Engine init uses adapter for config Given the app starts When PerpsController is initialized Then clientConfig comes from createMobileClientConfig() And no process.env reads exist in the Engine init file ``` ## **Screenshots/Recordings** N/A — no UI changes ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches Perps provider initialization/selection and geo-eligibility monitoring, which can impact trading availability and protocol routing if misconfigured. Changes are largely additive but include async/dynamic-import ordering and new config wiring that should be validated across networks/providers. > > **Overview** > **Refactors Perps controller configuration to use a nested `providerCredentials` structure** and centralizes all Perps `process.env` reads into `createMobileClientConfig()`, simplifying `perpsControllerInit` to pure wiring. > > **Adds HyperLiquid builder-fee address injection** via new env vars and passes these through `PerpsController` into `HyperLiquidProvider`, falling back to hardcoded defaults when env values are empty. > > **Hardens MYX provider registration and eligibility controls** by switching MYX to a dynamic `import()` flow with explicit error handling/awaiting during initialization, adding `stopEligibilityMonitoring()` (and messenger action typing) to defer geolocation checks, and extending tests to cover these behaviors. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 30f6e0a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 6df8a92 commit 62f6136

13 files changed

Lines changed: 872 additions & 106 deletions

File tree

.js.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,9 @@ export MM_PERPS_MYX_BROKER_ADDRESS_TESTNET=""
197197
export MM_PERPS_MYX_APP_ID_MAINNET=""
198198
export MM_PERPS_MYX_API_SECRET_MAINNET=""
199199
export MM_PERPS_MYX_BROKER_ADDRESS_MAINNET=""
200+
# HyperLiquid builder fee wallet addresses (empty = uses hardcoded defaults)
201+
export MM_PERPS_HL_BUILDER_ADDRESS_TESTNET=""
202+
export MM_PERPS_HL_BUILDER_ADDRESS_MAINNET=""
200203
# HIP-3 Feature Flags (remote override with local fallback)
201204
export MM_PERPS_HIP3_ENABLED="true"
202205
export MM_PERPS_HIP3_ALLOWLIST_MARKETS="" # Allowlist: Empty = enable all markets. Examples: "xyz:XYZ100,xyz:TSLA" or "xyz:*,abc:TSLA"

app/components/UI/Perps/adapters/mobileInfrastructure.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEvent
33
import { analytics } from '../../../../util/analytics/analytics';
44
import Logger from '../../../../util/Logger';
55
import type { PerpsAnalyticsEvent } from '@metamask/perps-controller';
6-
import { createMobileInfrastructure } from './mobileInfrastructure';
6+
import {
7+
createMobileInfrastructure,
8+
createMobileClientConfig,
9+
} from './mobileInfrastructure';
710
import Engine from '../../../../core/Engine';
811

912
jest.mock('../../../../util/analytics/analytics', () => ({
@@ -212,3 +215,62 @@ describe('createMobileInfrastructure', () => {
212215
});
213216
});
214217
});
218+
219+
describe('createMobileClientConfig', () => {
220+
it('returns default config with empty strings and arrays when no env vars are set', () => {
221+
// Arrange — ensure relevant env vars are absent
222+
const envVars = [
223+
'MM_PERPS_BLOCKED_REGIONS',
224+
'MM_PERPS_HIP3_ENABLED',
225+
'MM_PERPS_HIP3_ALLOWLIST_MARKETS',
226+
'MM_PERPS_HIP3_BLOCKLIST_MARKETS',
227+
'MM_PERPS_HL_BUILDER_ADDRESS_TESTNET',
228+
'MM_PERPS_HL_BUILDER_ADDRESS_MAINNET',
229+
'MM_PERPS_MYX_PROVIDER_ENABLED',
230+
'MM_PERPS_MYX_APP_ID_TESTNET',
231+
'MM_PERPS_MYX_API_SECRET_TESTNET',
232+
'MM_PERPS_MYX_BROKER_ADDRESS_TESTNET',
233+
'MM_PERPS_MYX_APP_ID_MAINNET',
234+
'MM_PERPS_MYX_API_SECRET_MAINNET',
235+
'MM_PERPS_MYX_BROKER_ADDRESS_MAINNET',
236+
];
237+
const saved: Record<string, string | undefined> = {};
238+
for (const key of envVars) {
239+
saved[key] = process.env[key];
240+
delete process.env[key];
241+
}
242+
243+
// Act
244+
const config = createMobileClientConfig();
245+
246+
// Assert
247+
expect(config).toEqual({
248+
fallbackBlockedRegions: [],
249+
fallbackHip3Enabled: false,
250+
fallbackHip3AllowlistMarkets: [],
251+
fallbackHip3BlocklistMarkets: [],
252+
providerCredentials: {
253+
hyperliquid: {
254+
builderAddressTestnet: '',
255+
builderAddressMainnet: '',
256+
},
257+
myx: {
258+
enabled: false,
259+
appIdTestnet: '',
260+
apiSecretTestnet: '',
261+
brokerAddressTestnet: '',
262+
appIdMainnet: '',
263+
apiSecretMainnet: '',
264+
brokerAddressMainnet: '',
265+
},
266+
},
267+
});
268+
269+
// Restore
270+
for (const key of envVars) {
271+
if (saved[key] !== undefined) {
272+
process.env[key] = saved[key];
273+
}
274+
}
275+
});
276+
});

app/components/UI/Perps/adapters/mobileInfrastructure.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import { getStreamManagerInstance } from '../providers/PerpsStreamManager';
2121
import Engine from '../../../../core/Engine';
2222
import {
2323
PERPS_CONSTANTS,
24+
parseCommaSeparatedString,
2425
type PerpsPlatformDependencies,
26+
type PerpsControllerConfig,
2527
type PerpsMetrics,
2628
type PerpsTraceName,
2729
type PerpsTraceValue,
@@ -155,6 +157,44 @@ function createCacheInvalidatorAdapter() {
155157
};
156158
}
157159

160+
/**
161+
* Creates mobile-specific client config from environment variables.
162+
* Centralizes all process.env reads so the Engine init file stays pure wiring.
163+
*/
164+
export function createMobileClientConfig(): PerpsControllerConfig {
165+
return {
166+
fallbackBlockedRegions: parseCommaSeparatedString(
167+
process.env.MM_PERPS_BLOCKED_REGIONS ?? '',
168+
),
169+
fallbackHip3Enabled: process.env.MM_PERPS_HIP3_ENABLED === 'true',
170+
fallbackHip3AllowlistMarkets: parseCommaSeparatedString(
171+
process.env.MM_PERPS_HIP3_ALLOWLIST_MARKETS ?? '',
172+
),
173+
fallbackHip3BlocklistMarkets: parseCommaSeparatedString(
174+
process.env.MM_PERPS_HIP3_BLOCKLIST_MARKETS ?? '',
175+
),
176+
providerCredentials: {
177+
hyperliquid: {
178+
builderAddressTestnet:
179+
process.env.MM_PERPS_HL_BUILDER_ADDRESS_TESTNET ?? '',
180+
builderAddressMainnet:
181+
process.env.MM_PERPS_HL_BUILDER_ADDRESS_MAINNET ?? '',
182+
},
183+
myx: {
184+
enabled: process.env.MM_PERPS_MYX_PROVIDER_ENABLED === 'true',
185+
appIdTestnet: process.env.MM_PERPS_MYX_APP_ID_TESTNET ?? '',
186+
apiSecretTestnet: process.env.MM_PERPS_MYX_API_SECRET_TESTNET ?? '',
187+
brokerAddressTestnet:
188+
process.env.MM_PERPS_MYX_BROKER_ADDRESS_TESTNET ?? '',
189+
appIdMainnet: process.env.MM_PERPS_MYX_APP_ID_MAINNET ?? '',
190+
apiSecretMainnet: process.env.MM_PERPS_MYX_API_SECRET_MAINNET ?? '',
191+
brokerAddressMainnet:
192+
process.env.MM_PERPS_MYX_BROKER_ADDRESS_MAINNET ?? '',
193+
},
194+
},
195+
};
196+
}
197+
158198
/**
159199
* Creates mobile-specific platform dependencies for PerpsController.
160200
* Controller access uses messenger pattern (messenger.call()).

app/controllers/perps/PerpsController-method-action-types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ export type PerpsControllerStartEligibilityMonitoringAction = {
175175
handler: PerpsController['startEligibilityMonitoring'];
176176
};
177177

178+
export type PerpsControllerStopEligibilityMonitoringAction = {
179+
type: 'PerpsController:stopEligibilityMonitoring';
180+
handler: PerpsController['stopEligibilityMonitoring'];
181+
};
182+
178183
export type PerpsControllerMethodActions =
179184
| PerpsControllerPlaceOrderAction
180185
| PerpsControllerEditOrderAction
@@ -210,4 +215,5 @@ export type PerpsControllerMethodActions =
210215
| PerpsControllerSaveOrderBookGroupingAction
211216
| PerpsControllerSetSelectedPaymentTokenAction
212217
| PerpsControllerResetSelectedPaymentTokenAction
213-
| PerpsControllerStartEligibilityMonitoringAction;
218+
| PerpsControllerStartEligibilityMonitoringAction
219+
| PerpsControllerStopEligibilityMonitoringAction;

app/controllers/perps/PerpsController.test.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
PerpsController,
2525
getDefaultPerpsControllerState,
2626
InitializationState,
27+
firstNonEmpty,
28+
resolveMyxAuthConfig,
2729
} from './PerpsController';
2830
import type { PerpsControllerState } from './PerpsController';
2931
import { PERPS_ERROR_CODES } from './perpsErrorCodes';
@@ -387,6 +389,16 @@ class TestablePerpsController extends PerpsController {
387389
public testHasStandaloneProvider(): boolean {
388390
return this.hasStandaloneProvider();
389391
}
392+
393+
public testRegisterMYXProvider(
394+
MYXProvider: new (opts: Record<string, unknown>) => PerpsProvider,
395+
) {
396+
this.registerMYXProvider(MYXProvider as never);
397+
}
398+
399+
public testHandleMYXImportError(error: unknown) {
400+
this.handleMYXImportError(error);
401+
}
390402
}
391403

392404
describe('PerpsController', () => {
@@ -800,6 +812,35 @@ describe('PerpsController', () => {
800812
}),
801813
);
802814
});
815+
816+
it('stopEligibilityMonitoring defers subsequent refreshEligibility calls', async () => {
817+
// Arrange — controller without deferral
818+
const testMockCall = jest.fn().mockImplementation((action: string) => {
819+
if (action === 'RemoteFeatureFlagController:getState') {
820+
return { remoteFeatureFlags: {} };
821+
}
822+
if (action === 'GeolocationController:getGeolocation') {
823+
return 'US';
824+
}
825+
return undefined;
826+
});
827+
828+
const testController = new TestablePerpsController({
829+
messenger: createMockMessenger({ call: testMockCall }),
830+
state: getDefaultPerpsControllerState(),
831+
infrastructure: createMockInfrastructure(),
832+
});
833+
testMockCall.mockClear();
834+
835+
// Act
836+
testController.stopEligibilityMonitoring();
837+
await testController.refreshEligibility();
838+
839+
// Assert — geolocation was never called
840+
expect(testMockCall).not.toHaveBeenCalledWith(
841+
'GeolocationController:getGeolocation',
842+
);
843+
});
803844
});
804845

805846
describe('HIP-3 Configuration Integration', () => {
@@ -4551,6 +4592,17 @@ describe('PerpsController', () => {
45514592
providers.set('myx', mockMYXProvider as any);
45524593
myxController.testSetProviders(providers);
45534594

4595+
// Mock init on the reinit call inside switchProvider.
4596+
// Dynamic import() rejects in Jest (no --experimental-vm-modules),
4597+
// so MYX can't register via #createProviders. Mock init to
4598+
// simulate successful reinitialization while preserving our
4599+
// manually-injected MYX provider in the map.
4600+
jest.spyOn(myxController, 'init').mockImplementationOnce(async () => {
4601+
myxController.testUpdate((state) => {
4602+
state.initializationState = InitializationState.Initialized;
4603+
});
4604+
});
4605+
45544606
const result = await myxController.switchProvider('myx');
45554607

45564608
expect(result.success).toBe(true);
@@ -4643,6 +4695,59 @@ describe('PerpsController', () => {
46434695
// The init path should detect MYX is not available and fall back
46444696
expect(controller.state.activeProvider).toBe('hyperliquid');
46454697
});
4698+
4699+
it('registerMYXProvider creates and registers the MYX provider', () => {
4700+
// Arrange
4701+
const mockMYXInstance = createMockHyperLiquidProvider();
4702+
const MockMYXConstructor = jest.fn(() => mockMYXInstance);
4703+
4704+
// Act
4705+
controller.testRegisterMYXProvider(
4706+
MockMYXConstructor as unknown as new (
4707+
opts: Record<string, unknown>,
4708+
) => PerpsProvider,
4709+
);
4710+
4711+
// Assert
4712+
const providers = controller.testGetProviders();
4713+
expect(providers.get('myx')).toBe(mockMYXInstance);
4714+
expect(MockMYXConstructor).toHaveBeenCalledWith(
4715+
expect.objectContaining({ isTestnet: false }),
4716+
);
4717+
});
4718+
4719+
it('handleMYXImportError logs debug for MODULE_NOT_FOUND errors', () => {
4720+
// Arrange — Node sets code: 'MODULE_NOT_FOUND' on missing modules
4721+
const moduleError = Object.assign(
4722+
new Error('Cannot find module ./providers/MYXProvider'),
4723+
{ code: 'MODULE_NOT_FOUND' },
4724+
);
4725+
4726+
// Act
4727+
controller.testHandleMYXImportError(moduleError);
4728+
4729+
// Assert
4730+
expect(mockInfrastructure.debugLogger.log).toHaveBeenCalledWith(
4731+
'PerpsController: MYX provider module not available, skipping registration',
4732+
);
4733+
});
4734+
4735+
it('handleMYXImportError routes runtime errors to logError', () => {
4736+
// Act — error without MODULE_NOT_FOUND code goes to Sentry
4737+
controller.testHandleMYXImportError(new Error('Invalid auth config'));
4738+
4739+
// Assert
4740+
expect(mockInfrastructure.logger.error).toHaveBeenCalledWith(
4741+
expect.objectContaining({ message: 'Invalid auth config' }),
4742+
expect.objectContaining({
4743+
context: expect.objectContaining({
4744+
data: expect.objectContaining({
4745+
method: 'createProviders.myx',
4746+
}),
4747+
}),
4748+
}),
4749+
);
4750+
});
46464751
});
46474752

46484753
describe('getOpenOrders with standalone mode', () => {
@@ -5675,3 +5780,93 @@ describe('PerpsController', () => {
56755780
});
56765781
});
56775782
});
5783+
5784+
describe('firstNonEmpty', () => {
5785+
it('returns the first non-empty string', () => {
5786+
expect(firstNonEmpty('', undefined, 'hello', 'world')).toBe('hello');
5787+
});
5788+
5789+
it('returns empty string when all values are empty or undefined', () => {
5790+
expect(firstNonEmpty('', undefined, '')).toBe('');
5791+
});
5792+
5793+
it('returns the first value if it is non-empty', () => {
5794+
expect(firstNonEmpty('first', 'second')).toBe('first');
5795+
});
5796+
5797+
it('skips empty strings and returns the fallback', () => {
5798+
expect(firstNonEmpty('', 'fallback')).toBe('fallback');
5799+
});
5800+
});
5801+
5802+
describe('resolveMyxAuthConfig', () => {
5803+
it('uses testnet credentials on testnet', () => {
5804+
// Arrange
5805+
const myx = {
5806+
appIdTestnet: 'test-app',
5807+
apiSecretTestnet: 'test-secret',
5808+
brokerAddressTestnet: '0xTestBroker',
5809+
appIdMainnet: 'main-app',
5810+
apiSecretMainnet: 'main-secret',
5811+
brokerAddressMainnet: '0xMainBroker',
5812+
};
5813+
5814+
// Act
5815+
const result = resolveMyxAuthConfig(myx, true);
5816+
5817+
// Assert
5818+
expect(result.appId).toBe('test-app');
5819+
expect(result.apiSecret).toBe('test-secret');
5820+
expect(result.brokerAddress).toBe('0xTestBroker');
5821+
});
5822+
5823+
it('uses mainnet credentials on mainnet', () => {
5824+
// Arrange
5825+
const myx = {
5826+
appIdTestnet: 'test-app',
5827+
apiSecretTestnet: 'test-secret',
5828+
brokerAddressTestnet: '0xTestBroker',
5829+
appIdMainnet: 'main-app',
5830+
apiSecretMainnet: 'main-secret',
5831+
brokerAddressMainnet: '0xMainBroker',
5832+
};
5833+
5834+
// Act
5835+
const result = resolveMyxAuthConfig(myx, false);
5836+
5837+
// Assert
5838+
expect(result.appId).toBe('main-app');
5839+
expect(result.apiSecret).toBe('main-secret');
5840+
expect(result.brokerAddress).toBe('0xMainBroker');
5841+
});
5842+
5843+
it('falls back to testnet credentials when mainnet are empty', () => {
5844+
// Arrange
5845+
const myx = {
5846+
appIdTestnet: 'test-app',
5847+
apiSecretTestnet: 'test-secret',
5848+
brokerAddressTestnet: '0xTestBroker',
5849+
appIdMainnet: '',
5850+
apiSecretMainnet: '',
5851+
brokerAddressMainnet: '',
5852+
};
5853+
5854+
// Act
5855+
const result = resolveMyxAuthConfig(myx, false);
5856+
5857+
// Assert
5858+
expect(result.appId).toBe('test-app');
5859+
expect(result.apiSecret).toBe('test-secret');
5860+
expect(result.brokerAddress).toBe('0xTestBroker');
5861+
});
5862+
5863+
it('returns empty strings when no credentials are set', () => {
5864+
// Act
5865+
const result = resolveMyxAuthConfig({}, true);
5866+
5867+
// Assert
5868+
expect(result.appId).toBe('');
5869+
expect(result.apiSecret).toBe('');
5870+
expect(result.brokerAddress).toBe('');
5871+
});
5872+
});

0 commit comments

Comments
 (0)