Skip to content

Commit f43f2a1

Browse files
chore(runway): cherry-pick fix(predict): improve crypto up/down (#30703)
- fix(predict): improve crypto up/down cp-7.79.0 (#30662) <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** Improves the crypto up/down market experience on the feed series card and market details screen. These changes are scoped to the existing crypto up/down work behind the Predict up/down feature flag. They improve release quality for the upcoming crypto up/down experience, where the current UX is unstable for users, with minimal regression risk outside the gated feature. This PR fixes several visible stability issues: - Prevents Up/Down CTA prices from briefly flickering to stale fallback prices while live CLOB price subscriptions remount or warm back up. - Uses a shared buy-price precedence for CTA labels: live WebSocket `bestAsk` → REST `entry.buy` → static market token price. - Keeps the details chart anchored to the currently live market so selecting a future time slot does not interrupt the BTC price stream. - Keeps the selected time slot independent from the chart market, so target price/actions can follow the selected slot while the chart remains continuous. - Preserves series data during transient series refetches so position rows and chart sizing do not jump during time-slot changes. - Keeps crypto up/down chart loading visible until the live chart has enough renderable points, avoiding a spinner → blank → chart transition. - Uses Polymarket event `priceToBeat` metadata as the target-line fallback for hourly/daily crypto up/down markets whose target price API can be unavailable. - Preserves the existing group item threshold fallback for crypto up/down markets that do not provide event `priceToBeat` metadata. - Improves crypto up/down chart rendering/loading behavior and bottom padding across larger chart heights/font scales. - Improves longer-duration countdown/reset copy for hourly/daily/weekly crypto up/down markets. - Keeps crypto up/down card routing behind the Predict up/down feature flag. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: N/A ## **Manual testing steps** ```gherkin Feature: Crypto up/down market experience Scenario: user views the crypto up/down series card while prices update Given the Predict up/down feature flag is enabled And a live crypto up/down market is visible in the Predict feed When live CLOB price updates are received for the Up and Down tokens Then the Up and Down CTA prices update from the live buy prices And the CTA prices do not briefly flicker to stale REST or static market prices Scenario: user opens crypto up/down market details and changes time slots Given the user is on a live crypto up/down market details screen And the market series includes a future time slot When user selects the future time slot Then the selected time slot updates And the chart remains anchored to the live market price stream And the Up and Down actions use the selected time slot market And the current price display remains stable while the target price loads Scenario: user waits for the crypto up/down chart to load Given the user is on a crypto up/down market details screen And live chart data has not produced at least two renderable points When the initial chart request finishes Then the chart loading state remains visible And the screen does not briefly show an empty chart area before the live line renders Scenario: user views hourly or daily crypto up/down target lines Given an hourly or daily crypto up/down market has Polymarket event price to beat metadata And the crypto target price API is unavailable When the market card or market details screen is rendered Then the target line uses the event price to beat metadata And the existing group item threshold fallback is still available if event metadata is missing Scenario: user views longer-duration crypto up/down markets Given a crypto up/down market has an hourly, daily, or weekly recurrence When the market card or time slot picker is rendered Then the countdown and reset copy use readable longer-duration formatting ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] 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 live pricing, chart anchoring, and controller fallbacks across Predict crypto up/down flows, but scope is largely behind the up/down feature flag with broad test coverage. > > **Overview** > This PR tightens the **gated** crypto up/down Predict experience (feed card, details, and routing) so prices, charts, and target lines stay stable during live updates and time-slot changes. > > **Pricing & live data:** Buy CTAs now share `getPredictBuyPrice` (live `bestAsk` → REST `entry.buy` → token price). `useLiveMarketPrices` caches recent updates so remounts do not flash stale REST/static prices. Chart loading stays active until at least two renderable points exist; current price can still propagate while loading. > > **Details screen:** The chart stays on the **live** series market while the picker only changes the selected slot (target line, Up/Down actions, share). Series markets are held in a ref during refetch so positions/chart height do not jump. Target/current summaries use `resolveCryptoTargetPrice` with skeletons when appropriate; chart bottom padding scales with height and font scale. > > **Target price & Polymarket:** Event `priceToBeat` is parsed onto markets; `PredictController` and UI fall back through fetched price → event metadata → `groupItemThreshold`. Hourly recurrence is supported in duration helpers. > > **UX polish:** Longer countdowns use `H:MM:SS` when ≥1 hour; reset copy uses readable hour/day/week strings. Up/down feed cards require `selectPredictUpDownEnabledFlag` in addition to `isCryptoUpDown`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 31cc179. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> [f24ef18](f24ef18) Co-authored-by: hunty <hunter.goodreau@consensys.net>
1 parent 090f7f5 commit f43f2a1

30 files changed

Lines changed: 1178 additions & 155 deletions

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import React from 'react';
2+
import { PixelRatio } from 'react-native';
23
import { render, screen, fireEvent } from '@testing-library/react-native';
34
import PredictCryptoUpDownChart, {
45
CRYPTO_UP_DOWN_FORMAT_TIME,
56
CRYPTO_UP_DOWN_FORMAT_VALUE,
7+
computeBottomPadding,
68
} from './PredictCryptoUpDownChart';
79
import { useCryptoUpDownChartData } from '../../hooks/useCryptoUpDownChartData';
810
import {
@@ -107,7 +109,11 @@ describe('PredictCryptoUpDownChart', () => {
107109
expect(chart.props.hideControls).toBe(true);
108110
expect(chart.props.badge).toBe(false);
109111
expect(chart.props.momentum).toBe(false);
110-
expect(chart.props.padding).toEqual({ top: 8, right: 64, bottom: 48 });
112+
expect(chart.props.padding).toEqual({
113+
top: 8,
114+
right: 64,
115+
bottom: computeBottomPadding(300, PixelRatio.getFontScale()),
116+
});
111117
expect(chart.props.formatValue).toBe(CRYPTO_UP_DOWN_FORMAT_VALUE);
112118
expect(chart.props.formatTime).toBe(CRYPTO_UP_DOWN_FORMAT_TIME);
113119
});
@@ -274,6 +280,28 @@ describe('PredictCryptoUpDownChart', () => {
274280
expect(onCurrentPriceChange).not.toHaveBeenCalled();
275281
});
276282

283+
it('reports current price while Liveline waits for renderable data', () => {
284+
const market = createMockMarket();
285+
const onCurrentPriceChange = jest.fn();
286+
287+
mockUseCryptoUpDownChartData.mockReturnValueOnce({
288+
data: [{ time: 1, value: 51000 }],
289+
value: 51000,
290+
loading: true,
291+
isLive: true,
292+
window: 300,
293+
});
294+
295+
render(
296+
<PredictCryptoUpDownChart
297+
market={market}
298+
onCurrentPriceChange={onCurrentPriceChange}
299+
/>,
300+
);
301+
302+
expect(onCurrentPriceChange).toHaveBeenCalledWith(51000);
303+
});
304+
277305
it('does not report placeholder current price without chart data', () => {
278306
const market = createMockMarket();
279307
const onCurrentPriceChange = jest.fn();
@@ -367,4 +395,22 @@ describe('PredictCryptoUpDownChart', () => {
367395
expect(formatTime(input)).toBe(expected);
368396
});
369397
});
398+
399+
describe('computeBottomPadding', () => {
400+
it.each([
401+
['small chart, default font', 280, 1.0, 64],
402+
['large chart, default font', 560, 1.0, 84],
403+
['small chart, 1.5x font', 280, 1.5, 64],
404+
['large chart, 1.5x font', 560, 1.5, 96],
405+
['large chart, 2x font', 560, 2.0, 108],
406+
['below-floor chart shrinks to floor', 100, 1.0, 64],
407+
])('%s -> %dpx', (_label, chartHeight, fontScale, expected) => {
408+
expect(computeBottomPadding(chartHeight, fontScale)).toBe(expected);
409+
});
410+
411+
it('never returns less than the minimum padding floor', () => {
412+
expect(computeBottomPadding(0, 1.0)).toBeGreaterThanOrEqual(64);
413+
expect(computeBottomPadding(50, 0.5)).toBeGreaterThanOrEqual(64);
414+
});
415+
});
370416
});

app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
11
import React, { useEffect, useState } from 'react';
2+
import { PixelRatio } from 'react-native';
23
import { Box } from '@metamask/design-system-react-native';
34
import { LivelineChart } from '../../../Charts/LivelineChart';
45
import { useCryptoUpDownChartData } from '../../hooks/useCryptoUpDownChartData';
56
import type { PredictCryptoUpDownChartProps } from './PredictCryptoUpDownChart.types';
67

8+
const MIN_BOTTOM_PADDING_PX = 64;
9+
const BOTTOM_PADDING_HEIGHT_RATIO = 0.15;
10+
const BOTTOM_PADDING_FONT_SCALE_BOOST_PX = 24;
11+
12+
export const computeBottomPadding = (
13+
chartHeight: number,
14+
fontScale: number,
15+
): number =>
16+
Math.max(
17+
MIN_BOTTOM_PADDING_PX,
18+
Math.round(
19+
chartHeight * BOTTOM_PADDING_HEIGHT_RATIO +
20+
Math.max(0, fontScale - 1) * BOTTOM_PADDING_FONT_SCALE_BOOST_PX,
21+
),
22+
);
23+
724
/**
825
* USD whole-dollar formatter body for `LivelineChart` axis/tooltip values,
926
* e.g. `1234567.89` -> `"$1,234,568"`. Serialised as a JS function body
@@ -47,12 +64,16 @@ const PredictCryptoUpDownChart: React.FC<PredictCryptoUpDownChartProps> = ({
4764
} = useCryptoUpDownChartData(market, targetPrice);
4865

4966
const chartHeight = explicitHeight ?? measuredHeight;
67+
const bottomPadding = computeBottomPadding(
68+
chartHeight,
69+
PixelRatio.getFontScale(),
70+
);
5071

5172
useEffect(() => {
52-
if (!loading && data.length > 0 && Number.isFinite(value)) {
73+
if (data.length > 0 && Number.isFinite(value)) {
5374
onCurrentPriceChange?.(value);
5475
}
55-
}, [data.length, loading, onCurrentPriceChange, value]);
76+
}, [data.length, onCurrentPriceChange, value]);
5677

5778
return (
5879
<Box
@@ -77,7 +98,7 @@ const PredictCryptoUpDownChart: React.FC<PredictCryptoUpDownChartProps> = ({
7798
hideControls
7899
badge={false}
79100
momentum={false}
80-
padding={{ top: 8, right: 64, bottom: 48 }}
101+
padding={{ top: 8, right: 64, bottom: bottomPadding }}
81102
referenceLine={
82103
targetPrice ? { value: targetPrice, label: 'Target' } : undefined
83104
}

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

Lines changed: 130 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface HeaderCompactStandardMockProps {
3636
interface TimeSlotPickerMockProps {
3737
onMarketSelected: (market: PredictMarket) => void;
3838
markets: PredictMarket[];
39+
selectedMarketId?: string;
3940
}
4041

4142
jest.mock('@metamask/design-system-twrnc-preset', () => ({
@@ -157,8 +158,15 @@ jest.mock('../TimeSlotPicker', () => {
157158
const { View, TouchableOpacity } = jest.requireActual('react-native');
158159
return {
159160
TimeSlotPicker: jest.fn(
160-
({ onMarketSelected, markets }: TimeSlotPickerMockProps) => (
161-
<View testID="mock-time-slot-picker">
161+
({
162+
onMarketSelected,
163+
markets,
164+
selectedMarketId,
165+
}: TimeSlotPickerMockProps) => (
166+
<View
167+
testID="mock-time-slot-picker"
168+
accessibilityLabel={`selected:${selectedMarketId ?? 'none'}`}
169+
>
162170
{markets.map((m) => (
163171
<TouchableOpacity
164172
key={m.id}
@@ -278,6 +286,18 @@ const getChartMarketId = () => {
278286
return label?.match(/^market:([^;]+)/)?.[1];
279287
};
280288

289+
const getChartTargetPrice = () => {
290+
const chart = screen.getByTestId('mock-predict-crypto-up-down-chart');
291+
const label = chart.props.accessibilityLabel as string | undefined;
292+
return label?.match(/;target:(.+)$/)?.[1];
293+
};
294+
295+
const getSelectedTimeSlotMarketId = () => {
296+
const picker = screen.getByTestId('mock-time-slot-picker');
297+
const label = picker.props.accessibilityLabel as string | undefined;
298+
return label?.match(/^selected:(.+)$/)?.[1];
299+
};
300+
281301
const getPositionsRowIds = () => {
282302
const positions = screen.queryByTestId(
283303
'mock-predict-crypto-up-down-positions',
@@ -534,6 +554,36 @@ describe('PredictCryptoUpDownDetails', () => {
534554
expect(screen.getByText('$78,000.00')).toBeOnTheScreen();
535555
});
536556

557+
it('uses market threshold as target price while fetched target is unavailable', () => {
558+
const market = createMockMarket({
559+
outcomes: [
560+
{
561+
id: 'outcome-1',
562+
providerId: 'polymarket',
563+
marketId: 'market-1',
564+
title: 'BTC Up or Down',
565+
description: '',
566+
image: '',
567+
status: 'open',
568+
tokens: [],
569+
volume: 0,
570+
groupItemTitle: 'BTC',
571+
groupItemThreshold: 77123,
572+
},
573+
],
574+
});
575+
mockUsePredictSeries.mockReturnValue({ data: [market] });
576+
mockUseCryptoTargetPrice.mockReturnValue({
577+
data: undefined,
578+
isFetching: true,
579+
});
580+
581+
render(<PredictCryptoUpDownDetails market={market} onBack={mockOnBack} />);
582+
583+
expect(screen.getByText('$77,123.00')).toBeOnTheScreen();
584+
expect(getChartTargetPrice()).toBe('77123');
585+
});
586+
537587
it('renders signed positive current price deltas with USD formatting', () => {
538588
const market = createMockMarket();
539589
mockChartCurrentPrice = 78010;
@@ -795,15 +845,58 @@ describe('PredictCryptoUpDownDetails', () => {
795845
}
796846
});
797847

798-
it('passes selected market to chart component and updates subtitle when a different time slot is selected', () => {
848+
it('updates the selected time slot when a different time slot is pressed', () => {
849+
const market = createMockMarket();
850+
851+
render(<PredictCryptoUpDownDetails market={market} onBack={mockOnBack} />);
852+
853+
expect(getSelectedTimeSlotMarketId()).toBe('market-1');
854+
855+
fireEvent.press(screen.getByTestId('mock-time-slot-market-2'));
856+
857+
expect(getSelectedTimeSlotMarketId()).toBe('market-2');
858+
});
859+
860+
it('anchors the chart to the live market and keeps it stable when the user selects a future time slot', () => {
861+
const now = Date.UTC(2026, 0, 1, 0, 0, 0);
862+
const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(now);
863+
try {
864+
const liveMarket = createMockMarket({
865+
id: 'live-market',
866+
endDate: new Date(now + 60_000).toISOString(),
867+
});
868+
const futureMarket = createMockMarket({
869+
id: 'future-market',
870+
endDate: new Date(now + 5 * 60_000).toISOString(),
871+
});
872+
mockUsePredictSeries.mockReturnValue({
873+
data: [liveMarket, futureMarket],
874+
});
875+
876+
render(
877+
<PredictCryptoUpDownDetails market={liveMarket} onBack={mockOnBack} />,
878+
);
879+
880+
expect(getChartMarketId()).toBe('live-market');
881+
expect(getSelectedTimeSlotMarketId()).toBe('live-market');
882+
883+
fireEvent.press(screen.getByTestId('mock-time-slot-future-market'));
884+
885+
expect(getChartMarketId()).toBe('live-market');
886+
expect(getSelectedTimeSlotMarketId()).toBe('future-market');
887+
} finally {
888+
dateNowSpy.mockRestore();
889+
}
890+
});
891+
892+
it('falls back to the selected market for the chart when the series has no live market', () => {
799893
const market = createMockMarket();
800894

801895
render(<PredictCryptoUpDownDetails market={market} onBack={mockOnBack} />);
802896

803897
expect(getChartMarketId()).toBe('market-1');
804898

805-
const timeSlot2 = screen.getByTestId('mock-time-slot-market-2');
806-
fireEvent.press(timeSlot2);
899+
fireEvent.press(screen.getByTestId('mock-time-slot-market-2'));
807900

808901
expect(getChartMarketId()).toBe('market-2');
809902
});
@@ -980,7 +1073,7 @@ describe('PredictCryptoUpDownDetails', () => {
9801073
scheduledCallbacks[0]();
9811074
});
9821075

983-
expect(getChartMarketId()).toBe('later-market');
1076+
expect(getSelectedTimeSlotMarketId()).toBe('later-market');
9841077
} finally {
9851078
clearTimeoutSpy.mockRestore();
9861079
setTimeoutSpy.mockRestore();
@@ -1083,5 +1176,36 @@ describe('PredictCryptoUpDownDetails', () => {
10831176
expect.objectContaining({ seriesId: 'btc-15m' }),
10841177
);
10851178
});
1179+
1180+
it('keeps prior seedMarkets stable when usePredictSeries transiently returns undefined after a time slot change', () => {
1181+
const liveMarket = createMockMarket({ id: 'market-1' });
1182+
const nextMarket = createMockMarket({
1183+
id: 'market-2',
1184+
endDate: '2026-04-09T19:50:00Z',
1185+
});
1186+
mockUsePredictSeries.mockReturnValue({
1187+
data: [liveMarket, nextMarket],
1188+
});
1189+
1190+
render(
1191+
<PredictCryptoUpDownDetails market={liveMarket} onBack={mockOnBack} />,
1192+
);
1193+
1194+
const getLastSeedMarketIds = () =>
1195+
(
1196+
mockUsePredictSeriesPositions.mock.calls.at(-1)?.[0].seedMarkets as {
1197+
id: string;
1198+
}[]
1199+
)
1200+
.map((m) => m.id)
1201+
.sort();
1202+
1203+
expect(getLastSeedMarketIds()).toEqual(['market-1', 'market-2']);
1204+
1205+
mockUsePredictSeries.mockReturnValue({ data: undefined });
1206+
fireEvent.press(screen.getByTestId('mock-time-slot-market-2'));
1207+
1208+
expect(getLastSeedMarketIds()).toEqual(['market-1', 'market-2']);
1209+
});
10861210
});
10871211
});

0 commit comments

Comments
 (0)