Skip to content

Commit 1e4728a

Browse files
authored
feat(predict): replace hardcoded live sports config with remote feature flag (MetaMask#24494)
## **Description** Replaces the hardcoded `LIVE_SPORTS_LEAGUES` configuration with a remote feature flag (`predictLiveSports`), allowing live sports functionality to be enabled/disabled and leagues to be configured remotely without app updates. **Motivation**: The live sports feature needs to be controllable via remote config to enable gradual rollout and quick disabling if issues arise. **Solution**: - Added `PredictLiveSportsFlag` type: `{ enabled: boolean; leagues: string[] }` - Added `DEFAULT_LIVE_SPORTS_FLAG` constant (disabled by default, empty leagues) - Renamed `LIVE_SPORTS_LEAGUES` to `SUPPORTED_SPORTS_LEAGUES` (defines what the app supports) - Added `filterSupportedLeagues()` helper to validate remote config against supported leagues - Updated `PredictController.getMarkets()` and `getMarket()` to read the flag from `RemoteFeatureFlagController` - Updated provider interfaces to accept `liveSportsLeagues` parameter - Removed the now-unused `selectPredictLiveNflEnabled` selector and `isLiveSportsEnabled()` function ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-464 ## **Manual testing steps** ```gherkin Feature: Live Sports Remote Feature Flag Scenario: Live sports disabled by default Given the predictLiveSports flag is not set in remote config When user views prediction markets Then live sports markets are not fetched Scenario: Live sports enabled via remote config Given the predictLiveSports flag is { enabled: true, leagues: ["nfl"] } When user views prediction markets Then NFL live sports markets are included in results Scenario: Unsupported league filtered out Given the predictLiveSports flag is { enabled: true, leagues: ["nfl", "mlb"] } When user views prediction markets Then only NFL markets are fetched (mlb not in SUPPORTED_SPORTS_LEAGUES) ``` ## **Screenshots/Recordings** ### **Before** N/A - Internal refactor, no UI changes ### **After** N/A - Internal refactor, 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] > Enables remotely configurable live sports by validating and passing leagues to market fetches. > > - Add `PredictLiveSportsFlag` and `DEFAULT_LIVE_SPORTS_FLAG`; introduce `SUPPORTED_SPORTS_LEAGUES` and `filterSupportedLeagues` to validate remote config > - `PredictController.getMarkets()`/`getMarket()` read `RemoteFeatureFlagController.predictLiveSports`, compute `liveSportsLeagues`, and pass to providers > - Update provider types/interfaces to accept `liveSportsLeagues`; `PolymarketProvider` uses provided leagues (removes `isLiveSportsEnabled` and `LIVE_SPORTS_LEAGUES`), loads `TeamsCache` conditionally, and overlays game data only when leagues provided > - Remove `selectPredictLiveNflEnabled` selector and related tests; refresh tests across controller/provider to cover enabled/disabled and undefined leagues paths > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 68f8e7d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a4f46fa commit 1e4728a

10 files changed

Lines changed: 135 additions & 241 deletions

File tree

app/components/UI/Predict/constants/flags.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { PredictFeeCollection } from '../types/flags';
1+
import { PredictFeeCollection, PredictLiveSportsFlag } from '../types/flags';
22

33
export const DEFAULT_FEE_COLLECTION_FLAG = {
44
enabled: true,
@@ -10,3 +10,8 @@ export const DEFAULT_FEE_COLLECTION_FLAG = {
1010
providerFee: 0.02, // 2%
1111
waiveList: [],
1212
} satisfies PredictFeeCollection;
13+
14+
export const DEFAULT_LIVE_SPORTS_FLAG: PredictLiveSportsFlag = {
15+
enabled: false,
16+
leagues: [],
17+
};

app/components/UI/Predict/constants/sports.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { PredictSportsLeague } from '../types';
99
* 3. Add the league to this array
1010
* 4. Add tests for the new league's slug parsing
1111
*/
12-
export const LIVE_SPORTS_LEAGUES: PredictSportsLeague[] = ['nfl'];
12+
export const SUPPORTED_SPORTS_LEAGUES: PredictSportsLeague[] = ['nfl'];
1313

14-
export const isLiveSportsEnabled = (): boolean =>
15-
Array.isArray(LIVE_SPORTS_LEAGUES) && LIVE_SPORTS_LEAGUES.length > 0;
14+
export const filterSupportedLeagues = (
15+
leagues: string[],
16+
): PredictSportsLeague[] =>
17+
leagues.filter((league): league is PredictSportsLeague =>
18+
SUPPORTED_SPORTS_LEAGUES.includes(league as PredictSportsLeague),
19+
);

app/components/UI/Predict/controllers/PredictController.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ jest.mock('../../../../core/Engine', () => ({
104104
providerFee: 0.02,
105105
waiveList: [],
106106
},
107+
predictLiveSports: {
108+
enabled: false,
109+
leagues: [],
110+
},
107111
},
108112
},
109113
},
@@ -538,6 +542,7 @@ describe('PredictController', () => {
538542
expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0);
539543
expect(mockPolymarketProvider.getMarketDetails).toHaveBeenCalledWith({
540544
marketId: 'market-1',
545+
liveSportsLeagues: [],
541546
});
542547
});
543548
});
@@ -562,6 +567,7 @@ describe('PredictController', () => {
562567
expect(result).toEqual(mockMarket);
563568
expect(mockPolymarketProvider.getMarketDetails).toHaveBeenCalledWith({
564569
marketId: 'market-2',
570+
liveSportsLeagues: [],
565571
});
566572
});
567573
});
@@ -633,6 +639,7 @@ describe('PredictController', () => {
633639
expect(result).toEqual(mockMarket);
634640
expect(mockPolymarketProvider.getMarketDetails).toHaveBeenCalledWith({
635641
marketId: '123',
642+
liveSportsLeagues: [],
636643
});
637644
});
638645
});

app/components/UI/Predict/controllers/PredictController.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,12 @@ import { PREDICT_CONSTANTS, PREDICT_ERROR_CODES } from '../constants/errors';
8383
import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts';
8484
import { GEO_BLOCKED_COUNTRIES } from '../constants/geoblock';
8585
import { MATIC_CONTRACTS } from '../providers/polymarket/constants';
86-
import { DEFAULT_FEE_COLLECTION_FLAG } from '../constants/flags';
87-
import { PredictFeeCollection } from '../types/flags';
86+
import {
87+
DEFAULT_FEE_COLLECTION_FLAG,
88+
DEFAULT_LIVE_SPORTS_FLAG,
89+
} from '../constants/flags';
90+
import { filterSupportedLeagues } from '../constants/sports';
91+
import { PredictFeeCollection, PredictLiveSportsFlag } from '../types/flags';
8892

8993
/**
9094
* State shape for PredictController
@@ -463,9 +467,20 @@ export class PredictController extends BaseController<
463467
throw new Error('Provider not available');
464468
}
465469

470+
const { RemoteFeatureFlagController } = Engine.context;
471+
const liveSportsFlag =
472+
(RemoteFeatureFlagController.state.remoteFeatureFlags
473+
.predictLiveSports as unknown as PredictLiveSportsFlag | undefined) ??
474+
DEFAULT_LIVE_SPORTS_FLAG;
475+
const liveSportsLeagues = liveSportsFlag.enabled
476+
? filterSupportedLeagues(liveSportsFlag.leagues ?? [])
477+
: [];
478+
479+
const paramsWithLiveSports = { ...params, liveSportsLeagues };
480+
466481
const allMarkets = await Promise.all(
467482
providerIds.map((id: string) =>
468-
this.providers.get(id)?.getMarkets(params),
483+
this.providers.get(id)?.getMarkets(paramsWithLiveSports),
469484
),
470485
);
471486

@@ -560,8 +575,18 @@ export class PredictController extends BaseController<
560575
throw new Error('Provider not available');
561576
}
562577

578+
const { RemoteFeatureFlagController } = Engine.context;
579+
const liveSportsFlag =
580+
(RemoteFeatureFlagController.state.remoteFeatureFlags
581+
.predictLiveSports as unknown as PredictLiveSportsFlag | undefined) ??
582+
DEFAULT_LIVE_SPORTS_FLAG;
583+
const liveSportsLeagues = liveSportsFlag.enabled
584+
? filterSupportedLeagues(liveSportsFlag.leagues ?? [])
585+
: [];
586+
563587
const market = await provider.getMarketDetails({
564588
marketId: resolvedMarketId,
589+
liveSportsLeagues,
565590
});
566591

567592
this.update((state) => {

app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts

Lines changed: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,8 @@ jest.mock('./GameCache', () => ({
141141
},
142142
}));
143143

144-
const mockIsLiveSportsEnabled = jest.fn().mockReturnValue(true);
145144
jest.mock('../../constants/sports', () => ({
146-
LIVE_SPORTS_LEAGUES: ['nfl'],
147-
isLiveSportsEnabled: () => mockIsLiveSportsEnabled(),
145+
SUPPORTED_SPORTS_LEAGUES: ['nfl'],
148146
}));
149147

150148
const mockTeamsCacheInstance = {
@@ -277,7 +275,7 @@ describe('PolymarketProvider', () => {
277275

278276
mockGetMarketsFromPolymarketApi.mockResolvedValue(mockMarkets);
279277

280-
const markets = await provider.getMarkets();
278+
const markets = await provider.getMarkets({ liveSportsLeagues: ['nfl'] });
281279
expect(Array.isArray(markets)).toBe(true);
282280
expect(markets.length).toBeGreaterThan(0);
283281
expect(markets.length).toBe(2);
@@ -291,7 +289,7 @@ describe('PolymarketProvider', () => {
291289
const apiError = new Error('API request failed');
292290
mockGetMarketsFromPolymarketApi.mockRejectedValue(apiError);
293291

294-
const result = await provider.getMarkets();
292+
const result = await provider.getMarkets({ liveSportsLeagues: ['nfl'] });
295293

296294
expect(result).toEqual([]);
297295
expect(mockGetMarketsFromPolymarketApi).toHaveBeenCalledWith(
@@ -2425,7 +2423,10 @@ describe('PolymarketProvider', () => {
24252423
mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent);
24262424
mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]);
24272425

2428-
const result = await provider.getMarketDetails({ marketId: 'market-1' });
2426+
const result = await provider.getMarketDetails({
2427+
marketId: 'market-1',
2428+
liveSportsLeagues: ['nfl'],
2429+
});
24292430

24302431
expect(result).toEqual(mockParsedMarket);
24312432
expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledWith({
@@ -6442,22 +6443,22 @@ describe('PolymarketProvider', () => {
64426443
});
64436444

64446445
describe('getMarkets', () => {
6445-
it('applies GameCache overlay to fetched markets', async () => {
6446+
it('applies GameCache overlay to fetched markets when liveSportsLeagues is provided', async () => {
64466447
const provider = new PolymarketProvider();
64476448
const mockMarkets = [
64486449
{ id: 'market-1', title: 'Test Market 1' },
64496450
{ id: 'market-2', title: 'Test Market 2' },
64506451
];
64516452
mockGetMarketsFromPolymarketApi.mockResolvedValue(mockMarkets);
64526453

6453-
await provider.getMarkets();
6454+
await provider.getMarkets({ liveSportsLeagues: ['nfl'] });
64546455

64556456
expect(mockGameCacheInstance.overlayOnMarkets).toHaveBeenCalledWith(
64566457
mockMarkets,
64576458
);
64586459
});
64596460

6460-
it('returns markets with cached game data overlay applied', async () => {
6461+
it('returns markets with cached game data overlay applied when liveSportsLeagues is provided', async () => {
64616462
const provider = new PolymarketProvider();
64626463
const mockMarkets = [{ id: 'market-1', title: 'Test Market' }];
64636464
const overlaidMarkets = [
@@ -6470,7 +6471,9 @@ describe('PolymarketProvider', () => {
64706471
mockGetMarketsFromPolymarketApi.mockResolvedValue(mockMarkets);
64716472
mockGameCacheInstance.overlayOnMarkets.mockReturnValue(overlaidMarkets);
64726473

6473-
const result = await provider.getMarkets();
6474+
const result = await provider.getMarkets({
6475+
liveSportsLeagues: ['nfl'],
6476+
});
64746477

64756478
expect(result).toEqual(overlaidMarkets);
64766479
});
@@ -6481,15 +6484,17 @@ describe('PolymarketProvider', () => {
64816484
new Error('API error'),
64826485
);
64836486

6484-
const result = await provider.getMarkets();
6487+
const result = await provider.getMarkets({
6488+
liveSportsLeagues: ['nfl'],
6489+
});
64856490

64866491
expect(result).toEqual([]);
64876492
expect(mockGameCacheInstance.overlayOnMarkets).not.toHaveBeenCalled();
64886493
});
64896494
});
64906495

64916496
describe('getMarketDetails', () => {
6492-
it('applies GameCache overlay to fetched market details', async () => {
6497+
it('applies GameCache overlay to fetched market details when liveSportsLeagues is provided', async () => {
64936498
const provider = new PolymarketProvider();
64946499
const mockEvent = { id: 'market-1', question: 'Test Market?' };
64956500
const parsedMarket = {
@@ -6500,14 +6505,17 @@ describe('PolymarketProvider', () => {
65006505
mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent);
65016506
mockParsePolymarketEvents.mockReturnValue([parsedMarket]);
65026507

6503-
await provider.getMarketDetails({ marketId: 'market-1' });
6508+
await provider.getMarketDetails({
6509+
marketId: 'market-1',
6510+
liveSportsLeagues: ['nfl'],
6511+
});
65046512

65056513
expect(mockGameCacheInstance.overlayOnMarket).toHaveBeenCalledWith(
65066514
parsedMarket,
65076515
);
65086516
});
65096517

6510-
it('returns market with cached game data overlay applied', async () => {
6518+
it('returns market with cached game data overlay applied when liveSportsLeagues is provided', async () => {
65116519
const provider = new PolymarketProvider();
65126520
const mockEvent = { id: 'market-1', question: 'Test Market?' };
65136521
const parsedMarket = { id: 'market-1', title: 'Test Market' };
@@ -6522,6 +6530,7 @@ describe('PolymarketProvider', () => {
65226530

65236531
const result = await provider.getMarketDetails({
65246532
marketId: 'market-1',
6533+
liveSportsLeagues: ['nfl'],
65256534
});
65266535

65276536
expect(result).toEqual(overlaidMarket);
@@ -6533,7 +6542,10 @@ describe('PolymarketProvider', () => {
65336542
mockParsePolymarketEvents.mockReturnValue([]);
65346543

65356544
await expect(
6536-
provider.getMarketDetails({ marketId: 'market-1' }),
6545+
provider.getMarketDetails({
6546+
marketId: 'market-1',
6547+
liveSportsLeagues: ['nfl'],
6548+
}),
65376549
).rejects.toThrow('Failed to parse market details');
65386550
expect(mockGameCacheInstance.overlayOnMarket).not.toHaveBeenCalled();
65396551
});
@@ -6660,67 +6672,79 @@ describe('PolymarketProvider', () => {
66606672
});
66616673
});
66626674

6663-
describe('Live sports disabled', () => {
6675+
describe('Live sports disabled (empty liveSportsLeagues)', () => {
66646676
beforeEach(() => {
66656677
jest.clearAllMocks();
6666-
mockIsLiveSportsEnabled.mockReturnValue(false);
6667-
});
6668-
6669-
afterEach(() => {
6670-
mockIsLiveSportsEnabled.mockReturnValue(true);
66716678
});
66726679

66736680
describe('getMarkets', () => {
6674-
it('skips TeamsCache loading when live sports disabled', async () => {
6681+
it('skips TeamsCache loading when liveSportsLeagues is empty', async () => {
66756682
const provider = new PolymarketProvider();
66766683
mockGetMarketsFromPolymarketApi.mockResolvedValue([]);
66776684

6678-
await provider.getMarkets();
6685+
await provider.getMarkets({ liveSportsLeagues: [] });
66796686

66806687
expect(
66816688
mockTeamsCacheInstance.ensureLeaguesLoaded,
66826689
).not.toHaveBeenCalled();
66836690
});
66846691

6685-
it('skips GameCache overlay when live sports disabled', async () => {
6692+
it('skips GameCache overlay when liveSportsLeagues is empty', async () => {
66866693
const provider = new PolymarketProvider();
66876694
const mockMarkets = [{ id: 'market-1', title: 'Test Market' }];
66886695
mockGetMarketsFromPolymarketApi.mockResolvedValue(mockMarkets);
66896696

6690-
const result = await provider.getMarkets();
6697+
const result = await provider.getMarkets({ liveSportsLeagues: [] });
66916698

66926699
expect(mockGameCacheInstance.overlayOnMarkets).not.toHaveBeenCalled();
66936700
expect(result).toEqual(mockMarkets);
66946701
});
66956702

6696-
it('does not pass teamLookup when live sports disabled', async () => {
6703+
it('does not pass teamLookup when liveSportsLeagues is empty', async () => {
66976704
const provider = new PolymarketProvider();
66986705
mockGetMarketsFromPolymarketApi.mockResolvedValue([]);
66996706

6700-
await provider.getMarkets({ category: 'sports' });
6707+
await provider.getMarkets({
6708+
category: 'sports',
6709+
liveSportsLeagues: [],
6710+
});
67016711

67026712
expect(mockGetMarketsFromPolymarketApi).toHaveBeenCalledWith(
67036713
expect.objectContaining({ teamLookup: undefined }),
67046714
);
67056715
});
6716+
6717+
it('skips TeamsCache loading when liveSportsLeagues is undefined (default)', async () => {
6718+
const provider = new PolymarketProvider();
6719+
mockGetMarketsFromPolymarketApi.mockResolvedValue([]);
6720+
6721+
await provider.getMarkets();
6722+
6723+
expect(
6724+
mockTeamsCacheInstance.ensureLeaguesLoaded,
6725+
).not.toHaveBeenCalled();
6726+
});
67066727
});
67076728

67086729
describe('getMarketDetails', () => {
6709-
it('skips TeamsCache loading when live sports disabled', async () => {
6730+
it('skips TeamsCache loading when liveSportsLeagues is empty', async () => {
67106731
const provider = new PolymarketProvider();
67116732
const mockEvent = { id: 'market-1', question: 'Test?' };
67126733
const parsedMarket = { id: 'market-1', title: 'Test' };
67136734
mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent);
67146735
mockParsePolymarketEvents.mockReturnValue([parsedMarket]);
67156736

6716-
await provider.getMarketDetails({ marketId: 'market-1' });
6737+
await provider.getMarketDetails({
6738+
marketId: 'market-1',
6739+
liveSportsLeagues: [],
6740+
});
67176741

67186742
expect(
67196743
mockTeamsCacheInstance.ensureLeaguesLoaded,
67206744
).not.toHaveBeenCalled();
67216745
});
67226746

6723-
it('skips GameCache overlay when live sports disabled', async () => {
6747+
it('skips GameCache overlay when liveSportsLeagues is empty', async () => {
67246748
const provider = new PolymarketProvider();
67256749
const mockEvent = { id: 'market-1', question: 'Test?' };
67266750
const parsedMarket = { id: 'market-1', title: 'Test' };
@@ -6729,20 +6753,24 @@ describe('PolymarketProvider', () => {
67296753

67306754
const result = await provider.getMarketDetails({
67316755
marketId: 'market-1',
6756+
liveSportsLeagues: [],
67326757
});
67336758

67346759
expect(mockGameCacheInstance.overlayOnMarket).not.toHaveBeenCalled();
67356760
expect(result).toEqual(parsedMarket);
67366761
});
67376762

6738-
it('does not pass teamLookup when live sports disabled', async () => {
6763+
it('does not pass teamLookup when liveSportsLeagues is empty', async () => {
67396764
const provider = new PolymarketProvider();
67406765
const mockEvent = { id: 'market-1', question: 'Test?' };
67416766
const parsedMarket = { id: 'market-1', title: 'Test' };
67426767
mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent);
67436768
mockParsePolymarketEvents.mockReturnValue([parsedMarket]);
67446769

6745-
await provider.getMarketDetails({ marketId: 'market-1' });
6770+
await provider.getMarketDetails({
6771+
marketId: 'market-1',
6772+
liveSportsLeagues: [],
6773+
});
67466774

67476775
expect(mockParsePolymarketEvents).toHaveBeenCalledWith(
67486776
[mockEvent],

0 commit comments

Comments
 (0)