Skip to content

Commit bde8943

Browse files
authored
feat(perps): Add market-level allowlist/blocklist filtering for HIP-3 (MetaMask#22086)
## **Description** This PR implements market-level whitelisting and blacklisting for Perps HIP-3 markets, providing granular control over which perpetual trading markets are shown to users. ### What is the reason for the change? Improvement from the initial remote feature flag implementation in PR MetaMask#21823. While the initial PR established the remote feature flag infrastructure for Perps, we needed more granular control beyond simple DEX-level filtering. Specifically: 1. Users need the ability to enable/disable specific markets (e.g., "BTC", "xyz:XYZ100") rather than all markets from a DEX 2. Operators need both whitelist (enable only specific markets) and blacklist (block specific markets) capabilities 3. The system must support wildcard patterns for efficient bulk operations (e.g., "xyz:*" to enable/block all markets from a DEX) ### What is the improvement/solution? **Migration from DEX-level to Market-level Filtering:** - **Before**: Single `MM_PERPS_HIP3_ENABLED_DEXS` variable for whitelisting entire DEXs - **After**: Two variables for granular market control: - `MM_PERPS_HIP3_ALLOWLIST_MARKETS` - Whitelist (empty = enable all, non-empty = only show these) - `MM_PERPS_HIP3_BLOCKLIST_MARKETS` - Blacklist (empty = block none, non-empty = hide these) **Key Features:** 1. **Wildcard Pattern Support** - `"xyz:*"` or `"xyz"` - All markets from xyz DEX - `"xyz:TSLA"` - Specific market - `"BTC"` - Main DEX market 2. **Remote Feature Flag Integration** - LaunchDarkly provides remote configuration - Local env variables serve as fallback - "Sticky remote" pattern: once remote config loads, never downgrade to fallback 3. **Automatic Cache Invalidation & Reconnection** - `hip3ConfigVersion` increments when config changes - ConnectionManager monitors version via Redux - Automatically triggers cache clearing and provider reconnection - No manual intervention required 4. **Performance Optimizations** - Pattern caching (pre-compiled regex stored in Map) - Separate cache keys for filtered vs unfiltered data - `skipFilters` parameter for administrative queries **Architecture:** ``` LaunchDarkly � RemoteFeatureFlagController � PerpsController � (hip3ConfigVersion++) ConnectionManager � (monitors version change) Clear caches + Reconnect � HyperLiquidProvider � (applies filters) Market list with patterns applied ``` ## **Changelog** CHANGELOG entry: Added market-level whitelisting and blacklisting for Perps HIP-3 markets with wildcard pattern support and automatic reconnection on configuration changes ## **Related issues** Related to: MetaMask#21823 (Initial remote feature flag implementation for Perps) This PR splits out the market filtering functionality (Task #2) from the original feature flag work. ## **Manual testing steps** ```gherkin Feature: Perps HIP-3 Market Filtering Scenario: Whitelist enables all markets when empty Given MM_PERPS_HIP3_ENABLED is true And MM_PERPS_HIP3_ALLOWLIST_MARKETS is empty And MM_PERPS_HIP3_BLOCKLIST_MARKETS is empty When user opens the Perps tab Then all available markets should be visible And markets from both main DEX and HIP-3 DEXs should appear Scenario: Whitelist restricts to specific markets Given MM_PERPS_HIP3_ENABLED is true And MM_PERPS_HIP3_ALLOWLIST_MARKETS is "BTC,ETH,xyz:TSLA" And MM_PERPS_HIP3_BLOCKLIST_MARKETS is empty When user opens the Perps tab Then only BTC, ETH, and xyz:TSLA markets should be visible And all other markets should be hidden Scenario: Whitelist with wildcard enables all markets from DEX Given MM_PERPS_HIP3_ENABLED is true And MM_PERPS_HIP3_ALLOWLIST_MARKETS is "xyz:*,BTC" And MM_PERPS_HIP3_BLOCKLIST_MARKETS is empty When user opens the Perps tab Then BTC market should be visible And all markets from xyz DEX should be visible And markets from other HIP-3 DEXs should be hidden Scenario: Blacklist blocks specific markets Given MM_PERPS_HIP3_ENABLED is true And MM_PERPS_HIP3_ALLOWLIST_MARKETS is empty And MM_PERPS_HIP3_BLOCKLIST_MARKETS is "xyz:TSLA,BTC" When user opens the Perps tab Then xyz:TSLA market should be hidden And BTC market should be hidden And all other markets should be visible Scenario: Blacklist with wildcard blocks all markets from DEX Given MM_PERPS_HIP3_ENABLED is true And MM_PERPS_HIP3_ALLOWLIST_MARKETS is empty And MM_PERPS_HIP3_BLOCKLIST_MARKETS is "xyz:*" When user opens the Perps tab Then all markets from xyz DEX should be hidden And markets from main DEX and other HIP-3 DEXs should be visible Scenario: Remote feature flag overrides local configuration Given local env has MM_PERPS_HIP3_ALLOWLIST_MARKETS="BTC" And LaunchDarkly is configured with perpsAllowlistMarkets="ETH,SOL" When app initializes and fetches remote feature flags Then only ETH and SOL markets should be visible And BTC market should be hidden (remote overrides local) Scenario: Automatic reconnection on configuration change Given user has Perps tab open with markets loaded And LaunchDarkly configuration is "BTC,ETH" When LaunchDarkly configuration changes to "SOL,AVAX" Then hip3ConfigVersion should increment And ConnectionManager should detect the change And app should automatically clear caches And app should reconnect to providers And only SOL and AVAX markets should be visible without manual refresh Scenario: Fallback to local configuration when remote unavailable Given LaunchDarkly is unreachable And local env has MM_PERPS_HIP3_ALLOWLIST_MARKETS="BTC,ETH" When user opens the Perps tab Then local configuration should be used as fallback And only BTC and ETH markets should be visible Scenario: Shorthand wildcard notation Given MM_PERPS_HIP3_ALLOWLIST_MARKETS is "xyz" (without :*) When user opens the Perps tab Then "xyz" should be interpreted as "xyz:*" And all markets from xyz DEX should be visible ``` ## **Screenshots/Recordings** ### **Before** N/A - This is a configuration-based feature without UI changes ### **After** N/A - This is a configuration-based feature without UI changes ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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. --- ## Technical Implementation Details ### Environment Variables **New variables added:** ```bash # HIP-3 Feature Flags (remote override with local fallback) export MM_PERPS_HIP3_ENABLED="true" export MM_PERPS_HIP3_ALLOWLIST_MARKETS="" # Whitelist: Empty = enable all markets export MM_PERPS_HIP3_BLOCKLIST_MARKETS="" # Blacklist: Empty = no blocking ``` **Removed variables:** ```bash # OLD - Removed export MM_PERPS_HIP3_ENABLED_DEXS="" # Replaced by market-level filtering export MM_PERPS_ENABLED_DEXS="" # Replaced by market-level filtering ``` ### Pattern Matching Examples | Pattern | Matches | Description | |---------|---------|-------------| | `"BTC"` | `BTC` (main DEX) | Exact match on main DEX market | | `"xyz:TSLA"` | `xyz:TSLA` | Exact match on HIP-3 market | | `"xyz:*"` | `xyz:TSLA`, `xyz:AAPL`, etc. | All markets from xyz DEX | | `"xyz"` | `xyz:TSLA`, `xyz:AAPL`, etc. | Shorthand for `"xyz:*"` | | `""` (empty) | All markets | Discovery mode (whitelist) or no blocking (blacklist) | ### Pattern Matching Implementation **Type Safety:** - `CompiledPatternMatcher` - Type alias for pattern matchers (string for exact match, RegExp for wildcards) - `CompiledPattern` - Interface combining original pattern with compiled matcher **Compilation Strategy:** ```typescript // Pattern → Compiled Matcher "xyz:*" → /^xyz:/ (prefix regex) "xyz" → /^xyz:/ (shorthand → prefix regex) "xyz:TSLA" → "xyz:TSLA" (exact string match - fastest) ``` **Performance Optimization:** 1. All patterns pre-compiled at provider initialization via `recompileAllPatterns()` 2. Stored in typed arrays (`CompiledPattern[]`) for direct iteration 3. Eliminates repeated `compilePattern()` function call overhead during filtering 4. Better code clarity and type safety with pre-compiled matchers 5. Exact matches use string equality (fastest), wildcards use RegExp.test() **Filtering Logic in `shouldIncludeMarket()`:** ```typescript // Main DEX markets always included if (dex === null) return true; // Apply whitelist (if non-empty) if (compiledEnabledPatterns.length > 0) { if (!compiledEnabledPatterns.some(p => matches(symbol, p.matcher))) { return false; // Not whitelisted } } // Apply blacklist (if non-empty) if (compiledBlockedPatterns.length > 0) { if (compiledBlockedPatterns.some(p => matches(symbol, p.matcher))) { return false; // Blacklisted } } return true; ``` ### New Selectors - `selectHip3ConfigVersion()` - Returns version number for cache invalidation used by ConnectionManager to detect config changes ### Files Modified **Core Logic (258 lines added):** - `app/components/UI/Perps/controllers/PerpsController.ts` - `refreshHip3ConfigFromRemote()` - Extracts and validates remote config - Version tracking and increment on config change - "Sticky remote" pattern implementation **Provider Implementation (411 lines added):** - `app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts` - `CompiledPatternMatcher` type - Type alias for pattern matchers - `CompiledPattern` interface - Type for pre-compiled patterns - `recompileAllPatterns()` - Pre-compiles all patterns at initialization - `shouldIncludeMarket()` - Pattern matching logic using pre-compiled patterns - `matchesCompiledPattern()` - Fast pattern matching without Map lookups - `compilePattern()` - Regex compilation and caching - `skipFilters` parameter support - Separate cache keys for filtered/unfiltered data **Selectors:** - `app/components/UI/Perps/selectors/featureFlags/index.ts` - `selectHip3ConfigVersion` - Version selector for monitoring config changes **Connection Management (22 lines added):** - `app/components/UI/Perps/services/PerpsConnectionManager.ts` - Monitors `hip3ConfigVersion` changes via Redux - Triggers automatic cache clearing and reconnection **Tests:** - `app/components/UI/Perps/selectors/featureFlags/index.test.ts` - `selectHip3ConfigVersion` (3 tests) - Validates version tracking - Removed unused selector tests for unused allowlist/blocklist selectors **Configuration:** - `.js.env.example` - Environment variable documentation - `bitrise.yml` - CI/CD environment configuration - `app/core/Engine/controllers/perps-controller/index.ts` - Fallback parsing **Types:** - `app/components/UI/Perps/controllers/types/index.ts` - Updated `PerpsControllerConfig` interface - Added `skipFilters` to `GetMarketsParams` ### Performance Considerations 1. **Pattern Pre-Compilation**: All filter patterns are compiled once at initialization and stored in typed arrays (`CompiledPattern[]`), eliminating repeated `compilePattern()` function calls during market filtering 2. **Type-Safe Pattern Matchers**: Uses `CompiledPatternMatcher` type alias and `CompiledPattern` interface for better documentation and type safety 3. **Optimized Filtering**: `shouldIncludeMarket()` iterates pre-compiled arrays directly with compiled matchers readily available, improving code clarity and maintainability 4. **Separate Cache Keys**: Filtered and unfiltered data cached separately (`"${dex}_raw"` vs `"${dex}_filtered"`) 5. **StreamManager Integration**: Market metadata cached for 5 minutes, prices from WebSocket (real-time) ### Migration Guide **From DEX-level to Market-level Filtering** This PR replaces DEX-level filtering with more granular market-level filtering: **Removed Environment Variables:** ```bash # OLD - No longer used export MM_PERPS_HIP3_ENABLED_DEXS="xyz,abc" # Whitelist entire DEXs export MM_PERPS_ENABLED_DEXS="xyz,abc" # Alternative naming (removed) ``` **New Environment Variables:** ```bash # NEW - Market-level control export MM_PERPS_HIP3_ALLOWLIST_MARKETS="" # Whitelist: empty = all markets (discovery mode) export MM_PERPS_HIP3_BLOCKLIST_MARKETS="" # Blacklist: empty = block nothing ``` **Common Migration Scenarios:** 1. **Enable all markets from specific DEXs (most common):** ```bash # Before: Enable all markets from xyz and abc DEXs MM_PERPS_HIP3_ENABLED_DEXS="xyz,abc" # After: Use wildcard patterns MM_PERPS_HIP3_ALLOWLIST_MARKETS="xyz:*,abc:*" # Or use shorthand (equivalent) MM_PERPS_HIP3_ALLOWLIST_MARKETS="xyz,abc" ``` 2. **Enable specific markets only:** ```bash # Before: Not possible (DEX-level only) # After: List specific markets MM_PERPS_HIP3_ALLOWLIST_MARKETS="BTC,ETH,xyz:TSLA,xyz:AAPL" ``` 3. **Enable all markets except specific ones:** ```bash # Before: Not possible (no blacklist support) # After: Use blacklist (whitelist empty = all markets) MM_PERPS_HIP3_ALLOWLIST_MARKETS="" MM_PERPS_HIP3_BLOCKLIST_MARKETS="xyz:SCAM,abc:RISKY" ``` 4. **Block entire DEX:** ```bash # Before: Omit from ENABLED_DEXS list MM_PERPS_HIP3_ENABLED_DEXS="xyz" # abc implicitly blocked # After: Use blacklist wildcard MM_PERPS_HIP3_ALLOWLIST_MARKETS="" # Enable all MM_PERPS_HIP3_BLOCKLIST_MARKETS="abc:*" # Block abc DEX # Or use shorthand MM_PERPS_HIP3_BLOCKLIST_MARKETS="abc" ``` 5. **Enable all markets (discovery mode):** ```bash # Before: Leave ENABLED_DEXS empty or set to all known DEXs MM_PERPS_HIP3_ENABLED_DEXS="" # After: Leave ALLOWLIST_MARKETS empty MM_PERPS_HIP3_ALLOWLIST_MARKETS="" MM_PERPS_HIP3_BLOCKLIST_MARKETS="" ``` **LaunchDarkly Remote Feature Flags:** The same patterns apply to LaunchDarkly configuration: - `perpsAllowlistMarkets` - Array of market patterns (empty = all markets) - `perpsBlocklistMarkets` - Array of market patterns to block (empty = block nothing) **Example LaunchDarkly Config:** ```json { "perpsHip3Enabled": true, "perpsAllowlistMarkets": ["BTC", "ETH", "xyz:*"], "perpsBlocklistMarkets": ["xyz:SCAM"] } ``` ### Breaking Changes None - This is an additive change with backward-compatible fallback behavior. **Note:** While `MM_PERPS_HIP3_ENABLED_DEXS` is no longer used, removing it from your configuration will not break anything. The new market-level filtering is more flexible and supersedes DEX-level filtering. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Implements market-level allowlist/blocklist filtering for HIP-3 perps with remote-flag overrides, caching, and automatic reconnection via hip3ConfigVersion. > > - **Perps HIP-3 market filtering (allowlist/blocklist)**: > - Add pattern-based filtering (exact, wildcard, DEX shorthand) via `shouldIncludeMarket`, `compileMarketPattern`, etc. in `utils/marketUtils`. > - New parsing util `parseCommaSeparatedString` for LaunchDarkly/env values. > - New env vars in `.js.env.example` and CI (`bitrise.yml`): `MM_PERPS_HIP3_ENABLED`, `MM_PERPS_HIP3_ALLOWLIST_MARKETS`, `MM_PERPS_HIP3_BLOCKLIST_MARKETS` (replace old DEX-level flags). > - **Controller & reconnection**: > - `PerpsController`: ingest remote flags for HIP-3 (enabled/allowlist/blocklist), maintain `hip3ConfigVersion`, and propagate config to provider. > - `PerpsConnectionManager`: monitor `selectHip3ConfigVersion` to clear caches and reconnect on config changes. > - Add `selectHip3ConfigVersion` selector; default state updated to include `hip3ConfigVersion`. > - **Provider & subscriptions**: > - `HyperLiquidProvider`: apply market filtering, add market metadata caching (filtered/unfiltered), support `skipFilters`, and pass HIP-3 config to `HyperLiquidSubscriptionService`. > - `HyperLiquidSubscriptionService`: support HIP-3 on/off, map webData2 (main DEX) vs webData3 (multi-DEX), update feature flags without teardown; remove redundant clearinghouseState path. > - **Types & tests**: > - Extend `GetMarketsParams` with `skipFilters`; update `PerpsControllerConfig` fallbacks. > - Extensive unit tests added/updated for HIP-3 config parsing, filtering, subscriptions, selectors, and connection manager. > - **Misc**: > - Remove unused debug styles in `PerpsTabView.styles.ts`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d93ca77. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent bbfd284 commit bde8943

21 files changed

Lines changed: 2589 additions & 921 deletions

.js.env.example

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,10 @@ export MM_PERPS_ENABLED="true"
162162
export MM_PERPS_SERVICE_INTERRUPTION_BANNER_ENABLED="false"
163163
export MM_PERPS_BLOCKED_REGIONS="US,CA-ON,GB,BE"
164164
export MM_PERPS_GTM_MODAL_ENABLED="true"
165-
# HIP-3 Feature Flags
166-
export MM_PERPS_EQUITY_ENABLED="false"
167-
export MM_PERPS_ENABLED_DEXS=""
165+
# HIP-3 Feature Flags (remote override with local fallback)
166+
export MM_PERPS_HIP3_ENABLED="true"
167+
export MM_PERPS_HIP3_ALLOWLIST_MARKETS="" # Allowlist: Empty = enable all markets. Examples: "xyz:XYZ100,xyz:TSLA" or "xyz:*,abc:TSLA"
168+
export MM_PERPS_HIP3_BLOCKLIST_MARKETS="" # Blocklist: Empty = no blocking. Examples: "BTC,ETH" or "xyz:*"
168169

169170
## Card
170171
export MM_CARD_BAANX_API_CLIENT_KEY_DEV=""

app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.styles.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -90,17 +90,6 @@ const styleSheet = (params: { theme: Theme }) => {
9090
marginLeft: 12,
9191
flex: 1,
9292
},
93-
debugButton: {
94-
backgroundColor: colors.warning.default,
95-
padding: 8,
96-
marginHorizontal: 16,
97-
marginVertical: 8,
98-
borderRadius: 8,
99-
alignItems: 'center',
100-
},
101-
debugButtonText: {
102-
color: colors.text.default,
103-
},
10493
});
10594
};
10695

app/components/UI/Perps/controllers/PerpsController.test.ts

Lines changed: 292 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ describe('PerpsController', () => {
151151
});
152152

153153
describe('constructor', () => {
154-
it('should initialize with default state', () => {
154+
it('initializes with default state', () => {
155155
expect(controller.state).toEqual(getDefaultPerpsControllerState());
156156
expect(controller.state.activeProvider).toBe('hyperliquid');
157157
expect(controller.state.positions).toEqual([]);
@@ -161,7 +161,7 @@ describe('PerpsController', () => {
161161
expect(controller.state.isTestnet).toBe(false); // Default to mainnet
162162
});
163163

164-
it('should read current RemoteFeatureFlagController state during construction', () => {
164+
it('reads current RemoteFeatureFlagController state during construction', () => {
165165
// Given: A mock messenger that tracks calls
166166
const mockCall = jest.fn().mockImplementation((action: string) => {
167167
if (action === 'RemoteFeatureFlagController:getState') {
@@ -374,6 +374,296 @@ describe('PerpsController', () => {
374374
});
375375
});
376376

377+
describe('refreshHip3ConfigOnFeatureFlagChange', () => {
378+
describe('allowlist parsing', () => {
379+
it('parses comma-separated allowlist string from LaunchDarkly', () => {
380+
// Arrange
381+
const remoteFlags = {
382+
remoteFeatureFlags: {
383+
perpsHip3AllowlistMarkets: 'BTC-USD,ETH-USD,SOL-USD',
384+
},
385+
};
386+
387+
// Act
388+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
389+
390+
// Assert
391+
expect((controller as any).hip3AllowlistMarkets).toEqual([
392+
'BTC-USD',
393+
'ETH-USD',
394+
'SOL-USD',
395+
]);
396+
});
397+
398+
it('parses allowlist array format', () => {
399+
// Arrange
400+
const remoteFlags = {
401+
remoteFeatureFlags: {
402+
perpsHip3AllowlistMarkets: ['BTC-USD', 'ETH-USD'],
403+
},
404+
};
405+
406+
// Act
407+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
408+
409+
// Assert
410+
expect((controller as any).hip3AllowlistMarkets).toEqual([
411+
'BTC-USD',
412+
'ETH-USD',
413+
]);
414+
});
415+
416+
it('trims whitespace from allowlist array items', () => {
417+
// Arrange
418+
const remoteFlags = {
419+
remoteFeatureFlags: {
420+
perpsHip3AllowlistMarkets: [' BTC-USD ', ' ETH-USD'],
421+
},
422+
};
423+
424+
// Act
425+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
426+
427+
// Assert
428+
expect((controller as any).hip3AllowlistMarkets).toEqual([
429+
'BTC-USD',
430+
'ETH-USD',
431+
]);
432+
});
433+
434+
it('falls back to local config when allowlist format is invalid (non-string array)', () => {
435+
// Arrange
436+
const initialAllowlist = ['LOCAL-BTC'];
437+
(controller as any).hip3AllowlistMarkets = initialAllowlist;
438+
const remoteFlags = {
439+
remoteFeatureFlags: {
440+
perpsHip3AllowlistMarkets: [123, null, 'BTC-USD'],
441+
},
442+
};
443+
444+
// Act
445+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
446+
447+
// Assert
448+
expect((controller as any).hip3AllowlistMarkets).toEqual(
449+
initialAllowlist,
450+
);
451+
});
452+
453+
it('falls back to local config when allowlist format is invalid (empty string array)', () => {
454+
// Arrange
455+
const initialAllowlist = ['LOCAL-ETH'];
456+
(controller as any).hip3AllowlistMarkets = initialAllowlist;
457+
const remoteFlags = {
458+
remoteFeatureFlags: {
459+
perpsHip3AllowlistMarkets: ['BTC-USD', '', 'ETH-USD'],
460+
},
461+
};
462+
463+
// Act
464+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
465+
466+
// Assert
467+
expect((controller as any).hip3AllowlistMarkets).toEqual(
468+
initialAllowlist,
469+
);
470+
});
471+
472+
it('falls back to local config when allowlist is empty string after parsing', () => {
473+
// Arrange
474+
const initialAllowlist = ['LOCAL-SOL'];
475+
(controller as any).hip3AllowlistMarkets = initialAllowlist;
476+
const remoteFlags = {
477+
remoteFeatureFlags: {
478+
perpsHip3AllowlistMarkets: '',
479+
},
480+
};
481+
482+
// Act
483+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
484+
485+
// Assert
486+
expect((controller as any).hip3AllowlistMarkets).toEqual(
487+
initialAllowlist,
488+
);
489+
});
490+
});
491+
492+
describe('blocklist parsing', () => {
493+
it('parses comma-separated blocklist string from LaunchDarkly', () => {
494+
// Arrange
495+
const remoteFlags = {
496+
remoteFeatureFlags: {
497+
perpsHip3BlocklistMarkets: 'SCAM-USD,FAKE-USD',
498+
},
499+
};
500+
501+
// Act
502+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
503+
504+
// Assert
505+
expect((controller as any).hip3BlocklistMarkets).toEqual([
506+
'SCAM-USD',
507+
'FAKE-USD',
508+
]);
509+
});
510+
511+
it('parses blocklist array format', () => {
512+
// Arrange
513+
const remoteFlags = {
514+
remoteFeatureFlags: {
515+
perpsHip3BlocklistMarkets: ['SCAM-USD', 'FAKE-USD'],
516+
},
517+
};
518+
519+
// Act
520+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
521+
522+
// Assert
523+
expect((controller as any).hip3BlocklistMarkets).toEqual([
524+
'SCAM-USD',
525+
'FAKE-USD',
526+
]);
527+
});
528+
529+
it('trims whitespace from blocklist array items', () => {
530+
// Arrange
531+
const remoteFlags = {
532+
remoteFeatureFlags: {
533+
perpsHip3BlocklistMarkets: [' SCAM-USD ', ' FAKE-USD'],
534+
},
535+
};
536+
537+
// Act
538+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
539+
540+
// Assert
541+
expect((controller as any).hip3BlocklistMarkets).toEqual([
542+
'SCAM-USD',
543+
'FAKE-USD',
544+
]);
545+
});
546+
547+
it('falls back to local config when blocklist format is invalid (non-string array)', () => {
548+
// Arrange
549+
const initialBlocklist = ['LOCAL-SCAM'];
550+
(controller as any).hip3BlocklistMarkets = initialBlocklist;
551+
const remoteFlags = {
552+
remoteFeatureFlags: {
553+
perpsHip3BlocklistMarkets: [456, null, 'SCAM-USD'],
554+
},
555+
};
556+
557+
// Act
558+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
559+
560+
// Assert
561+
expect((controller as any).hip3BlocklistMarkets).toEqual(
562+
initialBlocklist,
563+
);
564+
});
565+
566+
it('falls back to local config when blocklist format is invalid (empty string array)', () => {
567+
// Arrange
568+
const initialBlocklist = ['LOCAL-FAKE'];
569+
(controller as any).hip3BlocklistMarkets = initialBlocklist;
570+
const remoteFlags = {
571+
remoteFeatureFlags: {
572+
perpsHip3BlocklistMarkets: ['SCAM-USD', '', 'FAKE-USD'],
573+
},
574+
};
575+
576+
// Act
577+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
578+
579+
// Assert
580+
expect((controller as any).hip3BlocklistMarkets).toEqual(
581+
initialBlocklist,
582+
);
583+
});
584+
585+
it('falls back to local config when blocklist is empty string after parsing', () => {
586+
// Arrange
587+
const initialBlocklist = ['LOCAL-BAD'];
588+
(controller as any).hip3BlocklistMarkets = initialBlocklist;
589+
const remoteFlags = {
590+
remoteFeatureFlags: {
591+
perpsHip3BlocklistMarkets: '',
592+
},
593+
};
594+
595+
// Act
596+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
597+
598+
// Assert
599+
expect((controller as any).hip3BlocklistMarkets).toEqual(
600+
initialBlocklist,
601+
);
602+
});
603+
});
604+
605+
describe('config change detection', () => {
606+
it('increments hip3ConfigVersion when allowlist changes', () => {
607+
// Arrange
608+
const initialVersion = controller.state.hip3ConfigVersion;
609+
(controller as any).hip3AllowlistMarkets = ['BTC-USD'];
610+
const remoteFlags = {
611+
remoteFeatureFlags: {
612+
perpsHip3AllowlistMarkets: 'ETH-USD,SOL-USD',
613+
},
614+
};
615+
616+
// Act
617+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
618+
619+
// Assert
620+
expect(controller.state.hip3ConfigVersion).toBe(initialVersion + 1);
621+
expect((controller as any).hip3AllowlistMarkets).toEqual([
622+
'ETH-USD',
623+
'SOL-USD',
624+
]);
625+
});
626+
627+
it('increments hip3ConfigVersion when blocklist changes', () => {
628+
// Arrange
629+
const initialVersion = controller.state.hip3ConfigVersion;
630+
(controller as any).hip3BlocklistMarkets = ['OLD-SCAM'];
631+
const remoteFlags = {
632+
remoteFeatureFlags: {
633+
perpsHip3BlocklistMarkets: 'NEW-SCAM,NEW-FAKE',
634+
},
635+
};
636+
637+
// Act
638+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
639+
640+
// Assert
641+
expect(controller.state.hip3ConfigVersion).toBe(initialVersion + 1);
642+
expect((controller as any).hip3BlocklistMarkets).toEqual([
643+
'NEW-SCAM',
644+
'NEW-FAKE',
645+
]);
646+
});
647+
648+
it('does not increment version when config stays the same', () => {
649+
// Arrange
650+
const initialVersion = controller.state.hip3ConfigVersion;
651+
(controller as any).hip3AllowlistMarkets = ['BTC-USD', 'ETH-USD'];
652+
const remoteFlags = {
653+
remoteFeatureFlags: {
654+
perpsHip3AllowlistMarkets: 'ETH-USD,BTC-USD', // Same, just different order
655+
},
656+
};
657+
658+
// Act
659+
(controller as any).refreshHip3ConfigOnFeatureFlagChange(remoteFlags);
660+
661+
// Assert
662+
expect(controller.state.hip3ConfigVersion).toBe(initialVersion);
663+
});
664+
});
665+
});
666+
377667
describe('getActiveProvider', () => {
378668
it('should throw error when not initialized', () => {
379669
// Mock the controller as not initialized

0 commit comments

Comments
 (0)