Skip to content

Commit 1f49900

Browse files
tommasinicaieu
andauthored
fix(predict): odds prices stale on feed cards and event page during live markets cp-7.81.2 (#31951)
## Description Cherry-pick of #31922 into `release/7.81.2-ota`. Updates Predict sport game market cards so Moneyline odds can update from live WebSocket market prices. The main sport market card now subscribes to live prices for the visible Moneyline tokens and displays live `bestAsk` values when available, falling back to static token prices when live data is missing or disabled. The featured carousel sport card uses the same live-price fallback for payout rows and buy button odds. The live sport-card price behavior is controlled by a default-on remote feature flag, `predictSportCardLivePrices`, so the WebSocket subscriptions can be disabled remotely while preserving the existing static price display. ## Changelog Updated Predict sport game market cards to show live Moneyline prices. ## Related issues Fixes: - PRED-958 - PRED-1028 ## Manual testing steps See original PR #31922 for full manual testing steps. ## Cherry-pick details | Field | Value | |---|---| | Original PR | #31922 | | Target branch | `release/7.81.2-ota` | | Squashed commit cherry-picked | `676e748` | ### Conflict resolution notes - `tests/feature-flags/feature-flag-registry.ts`: kept only the new `predictSportCardLivePrices` flag; unrelated main-only flags surfaced as patch context were not introduced. - `app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx`: kept deleted — the `PredictHome` view does not exist on `release/7.81.2-ota`. ## 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. Made with [Cursor](https://cursor.com) Co-authored-by: Caainã Jeronimo <caainaje@gmail.com>
1 parent 0476432 commit 1f49900

14 files changed

Lines changed: 393 additions & 12 deletions

File tree

app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.test.tsx

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '../../types';
1414
import FeaturedCarouselSportCard from './FeaturedCarouselSportCard';
1515
import { FEATURED_CAROUSEL_TEST_IDS } from './FeaturedCarousel.testIds';
16+
import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
1617

1718
jest.mock('@metamask/design-system-twrnc-preset', () => ({
1819
useTailwind: () => ({
@@ -83,6 +84,14 @@ jest.mock('../../hooks/useLiveGameUpdates', () => ({
8384
useLiveGameUpdates: () => ({ gameUpdate: null }),
8485
}));
8586

87+
const mockGetLivePrice = jest.fn();
88+
jest.mock('../../hooks/useLiveMarketPrices', () => ({
89+
useLiveMarketPrices: jest.fn(() => ({
90+
getPrice: mockGetLivePrice,
91+
})),
92+
}));
93+
const mockUseLiveMarketPrices = jest.mocked(useLiveMarketPrices);
94+
8695
jest.mock('../../constants/sportLeagueConfigs', () => ({
8796
getLeagueConfig: () => ({}),
8897
}));
@@ -107,6 +116,24 @@ const initialState = {
107116
},
108117
};
109118

119+
const stateWithSportCardLivePricesEnabled = (enabled: boolean) => ({
120+
engine: {
121+
backgroundState: {
122+
...backgroundState,
123+
RemoteFeatureFlagController: {
124+
...backgroundState.RemoteFeatureFlagController,
125+
remoteFeatureFlags: {
126+
...backgroundState.RemoteFeatureFlagController?.remoteFeatureFlags,
127+
predictSportCardLivePrices: {
128+
enabled,
129+
minimumVersion: '0.0.0',
130+
},
131+
},
132+
},
133+
},
134+
},
135+
});
136+
110137
const createMockOutcome = (
111138
tokens: PredictOutcomeToken[],
112139
volume = 1500000,
@@ -184,6 +211,7 @@ const createMockSportMarket = (
184211
describe('FeaturedCarouselSportCard', () => {
185212
beforeEach(() => {
186213
jest.clearAllMocks();
214+
mockGetLivePrice.mockReturnValue(undefined);
187215
});
188216

189217
it('renders league name and live indicator for ongoing games', () => {
@@ -257,7 +285,9 @@ describe('FeaturedCarouselSportCard', () => {
257285
{ state: initialState },
258286
);
259287

260-
expect(getByText(strings('predict.outcome_draw'))).toBeOnTheScreen();
288+
expect(
289+
getByText(`${strings('predict.outcome_draw')} 20%`),
290+
).toBeOnTheScreen();
261291
});
262292

263293
it.each(['nba', 'nfl'] as const)(
@@ -414,4 +444,60 @@ describe('FeaturedCarouselSportCard', () => {
414444
}),
415445
);
416446
});
447+
448+
it('renders live best ask prices when available', () => {
449+
mockGetLivePrice.mockImplementation((tokenId: string) => ({
450+
tokenId,
451+
price: 0,
452+
bestBid: 0,
453+
bestAsk:
454+
tokenId === 'home-token'
455+
? 0.75
456+
: tokenId === 'draw-token'
457+
? 0.18
458+
: 0.25,
459+
}));
460+
const market = createMockSportMarket();
461+
462+
const { getByText } = renderWithProvider(
463+
<FeaturedCarouselSportCard market={market} index={0} />,
464+
{ state: initialState },
465+
);
466+
467+
expect(getByText('$133.33')).toBeOnTheScreen();
468+
expect(getByText('$400.00')).toBeOnTheScreen();
469+
expect(getByText('75%')).toBeOnTheScreen();
470+
expect(
471+
getByText(`${strings('predict.outcome_draw')} 18%`),
472+
).toBeOnTheScreen();
473+
expect(getByText('25%')).toBeOnTheScreen();
474+
});
475+
476+
it('renders static prices and disables live subscriptions when the flag is off', () => {
477+
mockGetLivePrice.mockImplementation((tokenId: string) => ({
478+
tokenId,
479+
price: 0,
480+
bestBid: 0,
481+
bestAsk: 0.99,
482+
}));
483+
const market = createMockSportMarket();
484+
485+
const { getByText, queryByText } = renderWithProvider(
486+
<FeaturedCarouselSportCard market={market} index={0} />,
487+
{ state: stateWithSportCardLivePricesEnabled(false) },
488+
);
489+
490+
expect(getByText('$166.67')).toBeOnTheScreen();
491+
expect(getByText('$250.00')).toBeOnTheScreen();
492+
expect(getByText('60%')).toBeOnTheScreen();
493+
expect(
494+
getByText(`${strings('predict.outcome_draw')} 20%`),
495+
).toBeOnTheScreen();
496+
expect(getByText('40%')).toBeOnTheScreen();
497+
expect(queryByText('99%')).not.toBeOnTheScreen();
498+
expect(mockUseLiveMarketPrices).toHaveBeenLastCalledWith(
499+
['home-token', 'draw-token', 'away-token'],
500+
{ enabled: false },
501+
);
502+
});
417503
});

app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.tsx

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useCallback, useMemo } from 'react';
22
import { TouchableOpacity } from 'react-native';
33
import { NavigationProp, useNavigation } from '@react-navigation/native';
4+
import { useSelector } from 'react-redux';
45
import { useTailwind } from '@metamask/design-system-twrnc-preset';
56
import {
67
Box,
@@ -19,6 +20,7 @@ import Routes from '../../../../../constants/navigation/Routes';
1920
import {
2021
PredictMarket,
2122
PredictMarketGame,
23+
PredictMarketStatus,
2224
PredictOutcomeToken,
2325
} from '../../types';
2426
import {
@@ -30,10 +32,13 @@ import { PredictEventValues } from '../../constants/eventNames';
3032
import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
3133
import { usePredictPreviewSheet } from '../../contexts';
3234
import { useLiveGameUpdates } from '../../hooks/useLiveGameUpdates';
35+
import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
3336
import { isDrawCapableLeague } from '../../constants/sports';
37+
import { selectPredictSportCardLivePricesEnabledFlag } from '../../selectors/featureFlags';
3438
import PredictSportTeamLogo from '../PredictSportTeamLogo/PredictSportTeamLogo';
3539
import { getLeagueConfig } from '../../constants/sportLeagueConfigs';
3640
import { parseScore } from '../../utils/gameParser';
41+
import { isValidPrice } from '../../utils/prices';
3742
import FeaturedCarouselCardFooter from './FeaturedCarouselCardFooter';
3843
import FeaturedCarouselPayoutRow from './FeaturedCarouselPayoutRow';
3944
import { FEATURED_CAROUSEL_TEST_IDS } from './FeaturedCarousel.testIds';
@@ -62,6 +67,9 @@ const FeaturedCarouselSportCard: React.FC<FeaturedCarouselSportCardProps> = ({
6267
useNavigation<NavigationProp<PredictNavigationParamList>>();
6368
const { openBuySheet } = usePredictPreviewSheet();
6469
const { executeGuardedAction } = usePredictActionGuard({ navigation });
70+
const livePricesEnabled = useSelector(
71+
selectPredictSportCardLivePricesEnabledFlag,
72+
);
6573

6674
const game = market.game as PredictMarketGame;
6775
const config = getLeagueConfig(game.league);
@@ -117,6 +125,31 @@ const FeaturedCarouselSportCard: React.FC<FeaturedCarouselSportCardProps> = ({
117125
const drawToken = showDraw
118126
? outcome?.tokens?.find((t) => t.title?.toLowerCase() === 'draw')
119127
: undefined;
128+
const tokenIds = useMemo(
129+
() =>
130+
[homeToken, drawToken, awayToken]
131+
.map((token) => token?.id)
132+
.filter((id): id is string => Boolean(id)),
133+
[awayToken, drawToken, homeToken],
134+
);
135+
const { getPrice } = useLiveMarketPrices(tokenIds, {
136+
enabled:
137+
livePricesEnabled &&
138+
market.status === PredictMarketStatus.OPEN &&
139+
tokenIds.length > 0,
140+
});
141+
142+
const getDisplayPrice = useCallback(
143+
(token: PredictOutcomeToken): number => {
144+
if (!livePricesEnabled) {
145+
return token.price;
146+
}
147+
148+
const liveBestAsk = getPrice(token.id)?.bestAsk;
149+
return isValidPrice(liveBestAsk) ? liveBestAsk : token.price;
150+
},
151+
[getPrice, livePricesEnabled],
152+
);
120153

121154
const handleCardPress = useCallback(() => {
122155
navigation.navigate(Routes.PREDICT.ROOT, {
@@ -265,7 +298,7 @@ const FeaturedCarouselSportCard: React.FC<FeaturedCarouselSportCardProps> = ({
265298
{game.homeTeam.name}
266299
</Text>
267300
{homeToken && (
268-
<FeaturedCarouselPayoutRow price={homeToken.price} />
301+
<FeaturedCarouselPayoutRow price={getDisplayPrice(homeToken)} />
269302
)}
270303
</Box>
271304

@@ -279,7 +312,7 @@ const FeaturedCarouselSportCard: React.FC<FeaturedCarouselSportCardProps> = ({
279312
{game.awayTeam.name}
280313
</Text>
281314
{awayToken && (
282-
<FeaturedCarouselPayoutRow price={awayToken.price} />
315+
<FeaturedCarouselPayoutRow price={getDisplayPrice(awayToken)} />
283316
)}
284317
</Box>
285318
</Box>
@@ -298,7 +331,9 @@ const FeaturedCarouselSportCard: React.FC<FeaturedCarouselSportCardProps> = ({
298331
style={tw.style('font-medium')}
299332
color={TextColor.SuccessDefault}
300333
>
301-
{formatPercentage(Math.round(homeToken.price * 100))}
334+
{formatPercentage(
335+
Math.round(getDisplayPrice(homeToken) * 100),
336+
)}
302337
</Text>
303338
</Button>
304339
</Box>
@@ -315,7 +350,10 @@ const FeaturedCarouselSportCard: React.FC<FeaturedCarouselSportCardProps> = ({
315350
style={tw.style('font-medium')}
316351
color={TextColor.TextDefault}
317352
>
318-
{strings('predict.outcome_draw')}
353+
{strings('predict.outcome_draw')}{' '}
354+
{formatPercentage(
355+
Math.round(getDisplayPrice(drawToken) * 100),
356+
)}
319357
</Text>
320358
</Button>
321359
</Box>
@@ -333,7 +371,9 @@ const FeaturedCarouselSportCard: React.FC<FeaturedCarouselSportCardProps> = ({
333371
style={tw.style('font-medium')}
334372
color={TextColor.SuccessDefault}
335373
>
336-
{formatPercentage(Math.round(awayToken.price * 100))}
374+
{formatPercentage(
375+
Math.round(getDisplayPrice(awayToken) * 100),
376+
)}
337377
</Text>
338378
</Button>
339379
</Box>

app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { PredictEventValues } from '../../constants/eventNames';
1212
import PredictMarketSportCard from './';
1313
import Routes from '../../../../../constants/navigation/Routes';
14+
import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
1415

1516
const mockNavigate = jest.fn();
1617
jest.mock('@react-navigation/native', () => ({
@@ -51,6 +52,14 @@ jest.mock('../../hooks/useLiveGameUpdates', () => ({
5152
useLiveGameUpdates: () => ({ gameUpdate: mockGameUpdate }),
5253
}));
5354

55+
const mockGetLivePrice = jest.fn();
56+
jest.mock('../../hooks/useLiveMarketPrices', () => ({
57+
useLiveMarketPrices: jest.fn(() => ({
58+
getPrice: mockGetLivePrice,
59+
})),
60+
}));
61+
const mockUseLiveMarketPrices = jest.mocked(useLiveMarketPrices);
62+
5463
jest.mock('../../constants/sportLeagueConfigs', () => ({
5564
getLeagueConfig: () => ({}),
5665
}));
@@ -126,10 +135,29 @@ const initialState = {
126135
},
127136
};
128137

138+
const stateWithSportCardLivePricesEnabled = (enabled: boolean) => ({
139+
engine: {
140+
backgroundState: {
141+
...backgroundState,
142+
RemoteFeatureFlagController: {
143+
...backgroundState.RemoteFeatureFlagController,
144+
remoteFeatureFlags: {
145+
...backgroundState.RemoteFeatureFlagController?.remoteFeatureFlags,
146+
predictSportCardLivePrices: {
147+
enabled,
148+
minimumVersion: '0.0.0',
149+
},
150+
},
151+
},
152+
},
153+
},
154+
});
155+
129156
describe('PredictMarketSportCard', () => {
130157
beforeEach(() => {
131158
jest.clearAllMocks();
132159
mockIsFromTrending.mockReturnValue(false);
160+
mockGetLivePrice.mockReturnValue(undefined);
133161
mockGameUpdate = null;
134162
});
135163

@@ -158,6 +186,52 @@ describe('PredictMarketSportCard', () => {
158186
expect(getByText('ENG 62¢')).toBeOnTheScreen();
159187
});
160188

189+
it('renders live Moneyline best ask prices when available', () => {
190+
mockGetLivePrice.mockImplementation((tokenId: string) => ({
191+
tokenId,
192+
price: 0,
193+
bestBid: 0,
194+
bestAsk:
195+
tokenId === 'token-home'
196+
? 0.71
197+
: tokenId === 'token-draw'
198+
? 0.12
199+
: 0.29,
200+
}));
201+
202+
const { getByText } = renderWithProvider(
203+
<PredictMarketSportCard market={mockMarket} />,
204+
{ state: initialState },
205+
);
206+
207+
expect(getByText('SPA 71¢')).toBeOnTheScreen();
208+
expect(getByText('DRAW 12¢')).toBeOnTheScreen();
209+
expect(getByText('ENG 29¢')).toBeOnTheScreen();
210+
});
211+
212+
it('renders static prices and disables live subscriptions when the flag is off', () => {
213+
mockGetLivePrice.mockImplementation((tokenId: string) => ({
214+
tokenId,
215+
price: 0,
216+
bestBid: 0,
217+
bestAsk: 0.99,
218+
}));
219+
220+
const { getByText, queryByText } = renderWithProvider(
221+
<PredictMarketSportCard market={mockMarket} />,
222+
{ state: stateWithSportCardLivePricesEnabled(false) },
223+
);
224+
225+
expect(getByText('SPA 60¢')).toBeOnTheScreen();
226+
expect(getByText('DRAW 15¢')).toBeOnTheScreen();
227+
expect(getByText('ENG 62¢')).toBeOnTheScreen();
228+
expect(queryByText('SPA 99¢')).not.toBeOnTheScreen();
229+
expect(mockUseLiveMarketPrices).toHaveBeenLastCalledWith(
230+
['token-home', 'token-draw', 'token-away'],
231+
{ enabled: false },
232+
);
233+
});
234+
161235
it('uses the main moneyline outcome when extended sports markets are present', () => {
162236
const extendedMarket: PredictMarketType = {
163237
...mockMarket,

0 commit comments

Comments
 (0)