Skip to content

feat(predict): add crypto up/down details UI#30005

Merged
ghgoodreau merged 57 commits into
mainfrom
predict/crypto-updown-details-ui
May 19, 2026
Merged

feat(predict): add crypto up/down details UI#30005
ghgoodreau merged 57 commits into
mainfrom
predict/crypto-updown-details-ui

Conversation

@ghgoodreau

@ghgoodreau ghgoodreau commented May 11, 2026

Copy link
Copy Markdown
Contributor

Description

This PR adds the crypto up/down market details UI for Predict. It introduces the crypto up/down chart and details screen, wires crypto markets into the existing market details route, adds time-slot selection, price summary, target/current price display, and buy/claim actions.

It also includes focused UI test coverage and a small Bugbot cleanup so outcome status checks use the outcome status type rather than the market status enum.

TODO

  1. This PR is stack item 3/3 and builds on feat(predict): add crypto price data plumbing #30004, which builds on feat(predict): update liveline chart foundation #30003.
  2. Keep this PR as draft until the stacked PRs are validated and all required checks are passing.
  3. Add screenshots or recordings before marking ready for review if reviewers want visual evidence for the new details UI.

Changelog

CHANGELOG entry: Added crypto up/down market details with live charting in Predict

Related issues

Fixes: N/A - stacked Predict crypto up/down implementation split from #29436.

Manual testing steps

Feature: Crypto up/down market details

  Scenario: user views and interacts with a crypto up/down market
    Given the Predict feature is enabled and a crypto up/down market is available

    When user opens the crypto up/down market details screen
    Then user sees the live chart, current price, target price, and available time slots

    When user selects a different time slot
    Then the details screen updates to the selected market window

    When user taps an available Yes or No action
    Then the Predict buy flow opens for the selected outcome

Screenshots/Recordings

Before

N/A

After

Screenshot 2026-05-15 at 4 34 36 PM

Pre-merge author checklist

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 to import wallets with many accounts and tokens
  • I've instrumented key operations with Sentry traces for production performance metrics

For performance guidelines and tooling, see the Performance Guide.

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.

Note

Medium Risk
Adds a new crypto up/down details UI with time-window selection, live price/target handling, and claim/buy actions; correctness depends on timer-driven windowing and live price stream behavior. Also changes live chart data retention/timestamp handling, which could affect rendering and performance on device.

Overview
Adds a new PredictCryptoUpDownDetails flow that replaces the animated header layout, fetches and auto-advances among series time slots, shows a target vs current price summary (with signed deltas), and renders a new PredictCryptoUpDownChart wired to useCryptoUpDownChartData and useCryptoTargetPrice.

Wires crypto up/down markets into PredictMarketDetails with callbacks for buy/claim actions, and updates PredictMarketDetailsActions to optionally show fixed-decimal payout estimates.

Improves live chart data behavior by preserving millisecond sub-second precision, enforcing a fixed 30s live window with bounded retention, and handling repeated “current” timestamps; also standardizes open-outcome status checks via OPEN_PREDICT_OUTCOME_STATUS and hardens useGameDetailsTabs against out-of-bounds active tab state.

Reviewed by Cursor Bugbot for commit 4bd4cfd. Bugbot is set up for automated code reviews on this repo. Configure here.

ghgoodreau and others added 3 commits May 11, 2026 14:56
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ghgoodreau ghgoodreau self-assigned this May 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes.

@metamaskbotv2 metamaskbotv2 Bot added the team-swaps-and-bridge Swaps and Bridge team label May 11, 2026
@ghgoodreau ghgoodreau added team-predict Predict team and removed team-swaps-and-bridge Swaps and Bridge team labels May 11, 2026
Co-authored-by: Cursor <cursoragent@cursor.com>
@ghgoodreau ghgoodreau marked this pull request as ready for review May 11, 2026 20:16
@ghgoodreau ghgoodreau requested review from a team as code owners May 11, 2026 20:16
ghgoodreau and others added 6 commits May 11, 2026 16:21
Base automatically changed from predict/crypto-data-plumbing to main May 18, 2026 19:05

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e0dd2d1. Configure here.

cortisiko
cortisiko previously approved these changes May 19, 2026
@ghgoodreau ghgoodreau enabled auto-merge May 19, 2026 16:47

@MarioAslau MarioAslau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @ghgoodreau found these issues. Requestion change for the High priority ones:

High-priority issues

H1. useCryptoUpDownChartData.isLive doesn't honour the new enabled option early

The hook adds new options (enabled, liveUpdatesEnabled, historicalWindow) and a single if (!enabled) short-circuit near the end, but the intermediate state — isLiveByEndDate, wsSymbol, useLiveCryptoPrices, the historical useQuery — is wired off Date.now() < liveEndDateMs regardless of enabled:

  const isLiveByEndDate =
    enabled && typeof liveEndDateMs === 'number'
      ? Date.now() < liveEndDateMs
      : false;
  const wsSymbol =
    enabled && shouldStreamLive && symbol ? `${symbol.toLowerCase()}/usd` : '';

  useLiveCryptoPrices(wsSymbol, handleLiveUpdate);

In the workspace this is partially guarded (enabled is now an AND in both spots), but the historicalQuery is still gated only on enabled && !!symbol && !!historyStartDate. At the PR head commit (e0dd2d1) the guard is missing in several places. Stack item #30342 plans to reuse this hook with enabled: false; if that lands without re-auditing, the hook will fire a network request and possibly a WS subscription for a disabled caller. Either add a single if (!enabled) return /* empty */ at the top of the hook body, or make every internal effect gate on enabled.

H2. formatValue passed as a stringified function body

          referenceLine={
            targetPrice ? { value: targetPrice, label: 'Target' } : undefined
          }
          formatValue="const sign = v < 0 ? '-' : ''; const parts = Math.abs(v).toFixed(2).split('.'); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); return sign + '$' + parts.join('.')"

LivelineChart accepts this string and presumably runs it as a Skia / Reanimated worklet via new Function('v', source). There is no test that actually executes this string — the chart test only asserts the literal string is forwarded — and any drift in quoting, escaping, or the regex (\B, (?=(\d{3})+(?!\d))) breaks the chart's tooltip/axis labels at runtime, visible only on device. The same formatter is also duplicated logic that already exists in formatCurrencyValue. Two remediation options:

  • Change LivelineChart to accept (v: number) => string and pass formatCurrencyValue directly.
  • Keep the string contract but add a LivelineChart test that runs new Function('v', formatValue)(1234567.89) and asserts the output ("$1,234,567.89"), so the format string is regression-protected.

Medium-priority issues

M1. PredictCryptoUpDownDetails uses raw (non-CLOB-augmented) prices

PredictMarketDetails runs useOpenOutcomes to overlay live CLOB prices on the resolved market:

  const { closedOutcomes, openOutcomes, yesPercentage } = useOpenOutcomes({
    market,
    isMarketFetching: isResolvedMarketFetching,
  });

…but the new child screen does not receive those augmented outcomes. It recomputes its own from the usePredictSeries payload via the local helpers and then drives PredictMarketDetailsActions from that:

const getOpenOutcomes = (market: PredictMarket): PredictOutcome[] =>
  market.outcomes.filter(
    (outcome) => outcome.status === OPEN_PREDICT_OUTCOME_STATUS,
  );
  const selectedOpenOutcomes = useMemo(
    () => getOpenOutcomes(selectedMarket),
    [selectedMarket],
  );
  const selectedYesPercentage = useMemo(
    () => getYesPercentage(selectedMarket, selectedOpenOutcomes),
    [selectedMarket, selectedOpenOutcomes],
  );

Concrete effect: the Yes/No percentage on the buy buttons (and the payout estimate built from token?.price) is derived from whatever the series endpoint last returned, which may be several seconds stale relative to the CLOB live prices already in memory upstream. Either thread openOutcomes / yesPercentage down from PredictMarketDetails, or call useOpenOutcomes inside PredictCryptoUpDownDetails with selectedMarket. (The follow-up branch predict/up-down-resuable-hook has already done the latter — landing that consolidation here avoids the merge churn.)

M2. Analytics context still tied to outer market, not selectedMarket

PredictMarketDetails.handleBuyPress was updated to accept and forward selectedMarket into openBuySheet, but the surrounding analytics envelope still closes over the outer market:

  const handleBuyPress = (
    token: PredictOutcomeToken,
    selectedMarket: typeof market = market,
  ) => {
    if (!selectedMarket) {
      return;
    }
    executeGuardedAction(
      () => {
        ...
        openBuySheet({
          market: selectedMarket,
          ...
          entryPoint:
            entryPoint || PredictEventValues.ENTRY_POINT.PREDICT_MARKET_DETAILS,
          ...(transactionActiveAbTests?.length && { transactionActiveAbTests }),
        });
      },
      {
        attemptedAction: PredictEventValues.ATTEMPTED_ACTION.PREDICT,
      },
    );
  };

transactionActiveAbTests and entryPoint are computed once for the route market; executeGuardedAction and usePredictActionGuard track the same market. After the user picks a different time slot, the buy sheet opens for the right market but the surrounding telemetry/A-B-test buckets identify the route market. For analytics-only this is a soft-medium; for A/B tests, the variation can affect what the buy sheet itself shows. Worth either re-deriving transactionActiveAbTests from selectedMarket.id, or asserting in a test that "switching time slot + buy" tags the slot's market id.

M3. Header bar is empty after refactor (Bugbot still applies)

The PR swaps HeaderStandardAnimated (scroll-driven title) for HeaderCompactStandard, but stops passing title / subtitle:

      <HeaderCompactStandard
        onBack={onBack}
        backButtonProps={{
          testID: PredictCryptoUpDownDetailsSelectorsIDs.BACK_BUTTON,
        }}
        endButtonIconProps={[
          {
            iconName: IconName.Share,
            ...
          },
        ]}
        testID={PredictCryptoUpDownDetailsSelectorsIDs.HEADER}
      />

HeaderCompactStandard renders its centre content from title / subtitle:

  const renderContent = () => {
    if (children) {
      return children;
    }
    if (title) {
      return (
        <Box alignItems={BoxAlignItems.Center}>
          ...
          {subtitle && (
            ...
          )}
        </Box>
      );
    }
    return null;
  };

So today the header bar is just and . As soon as the user scrolls past the TitleSubpage block, they lose the identifying context (which crypto? which slot?). Either pass title / subtitle (matching the old scrolled-past behaviour) or document that design wants a chrome-less header.

M4. 30s live window is now hardcoded for every recurrence

const LIVE_CHART_WINDOW_SECS = 30;
const LIVE_CHART_RETENTION_SECS = LIVE_CHART_WINDOW_SECS * 2;
const LIVE_CHART_MAX_POINTS = LIVE_CHART_RETENTION_SECS * 60;

…and at the live return:

  if (isLive || hasFrozenLiveData || hasExpiredLiveData) {
    return {
      data: chartData,
      ...
      window: LIVE_CHART_WINDOW_SECS,
    };
  }

The new test "uses the 30-second live window for longer recurrence markets" pins this behaviour for 4h markets. The hook also merges historical data into chartData, but the chart only renders a 30-second-wide x-axis, so historical context for hourly/4h markets is collapsed off the right edge of the chart. The PR description states this is intentional (the "live" feel), but it deserves either a follow-up ticket to revisit per-recurrence windows before hourly/4h markets are surfaced, or a brief comment in the constants block explaining the trade-off.

M5. Buy fallback can dispatch a token with a mismatched parent outcome

        const selectedOpenOutcomes =
          selectedMarket.id === market?.id
            ? openOutcomes
            : selectedMarket.outcomes.filter(
                (outcome) => outcome.status === OPEN_PREDICT_OUTCOME_STATUS,
              );
        const matchingOutcome =
          selectedMarket.outcomes.find((o) =>
            o.tokens.some((marketToken) => marketToken.id === token.id),
          ) ??
          selectedOpenOutcomes[0] ??
          selectedMarket.outcomes?.[0];

If the lookup misses (e.g. the series payload doesn't carry tokens for the just-selected slot), the fallback selectedOpenOutcomes[0] ?? selectedMarket.outcomes?.[0] opens the buy sheet with the first outcome, regardless of which side the user actually tapped. For the crypto up/down flow (single-outcome market with two tokens) the .find always succeeds, so this never fires today — but the helper is shared with sports flows, and a future series-shape change would silently route Yes-presses to the No outcome and vice-versa. Either:

  • Return early when there is no match: if (!matchingOutcome) return;
  • Or assert in the fallback path that the chosen outcome.tokens contains token.id, and skip if not.

M6. OPEN_PREDICT_OUTCOME_STATUS filtering duplicated in four places

The constant is correctly hoisted into types/index.ts:

export const OPEN_PREDICT_OUTCOME_STATUS: PredictOutcome['status'] = 'open';

…but the filter expression is re-implemented in:

  • app/components/UI/Predict/views/PredictMarketDetails/hooks/useChartData.ts (chartOpenOutcomes)
  • app/components/UI/Predict/views/PredictMarketDetails/hooks/useOpenOutcomes.ts (openOutcomesBase)
  • app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx (M5 above)
  • app/components/UI/Predict/components/PredictCryptoUpDownDetails/PredictCryptoUpDownDetails.tsx (local getOpenOutcomes)

Extract a getOpenOutcomes(market) selector next to the constant. Future additions ('live', 'paused', etc.) then only touch one file. Bugbot already flagged a slice of this; the broader pattern matters more than the constant placement.

M7. Payout estimate hard-codes a $100 stake

const LONG_OUTCOME_LABEL_THRESHOLD = 12;
const TALL_ACTION_BUTTON_MIN_HEIGHT = 48;
const DEFAULT_PAYOUT_INVESTMENT_AMOUNT = 100;
const MIN_PAYOUT_ESTIMATE_PRICE = 0.01;
const MAX_PAYOUT_ESTIMATE_PRICE = 0.99;
const formatPayoutEstimate = (
  price: number | undefined,
  investmentAmount = DEFAULT_PAYOUT_INVESTMENT_AMOUNT,
) => {
  ...
  return `${investmentAmountDisplay} -> ${payoutAmountDisplay}`;
};

The estimate always reads "$100.00 -> $X" regardless of the user's wallet balance or any preview state. Users with a $5 or $5,000 stake will see a number that has no relationship to what they'll actually receive. Two acceptable directions:

  • Make the literal string speak in those terms: 'For $100, win up to $153.85' (i18n key with {{amount}} interpolations).
  • Thread the actual preview/balance amount in through PredictPreviewSheetContext or a new prop.

The tests pin the exact string '$100.00 -> $153.85', so a rename will need test updates.

M8. useGameDetailsTabs has two parallel out-of-bounds fixes

  const handleTabPress = useCallback((tabIndex: number) => {
    setActiveTab(tabIndex);
  }, []);

  const showTabBar = enabled && hasPositions;
  const resolvedActiveTab = activeTab >= tabs.length ? 0 : activeTab;

  useEffect(() => {
    if (activeTab >= tabs.length) {
      setActiveTab(0);
    }
  }, [activeTab, tabs.length]);

Both the synchronous resolvedActiveTab and the useEffect correct the same invariant. The effect will run after the synchronous fix has already returned 0, then trigger a re-render. They will always agree, but it's two sources of truth for one rule. Pick one:

  • Drop the effect and let resolvedActiveTab be the canonical exposed value (it already is — the return statement uses activeTab: resolvedActiveTab). The internal activeTab state may temporarily store an out-of-range index, but no consumer can see it.
  • Drop resolvedActiveTab and accept one render of a stale index in exchange for keeping internal state coherent.

Locking either pattern with a test ("user is on tab 1, tabs shrinks to 1 entry, exposed activeTab is 0 and handleTabPress correctly addresses the remaining tab") prevents the next refactor from re-introducing the bug.

… regression test

Extract the inline LivelineChart formatValue string into an exported CRYPTO_UP_DOWN_FORMAT_VALUE constant and add a regression test that reconstructs the formatter via new Function('v', ...) and asserts outputs across positive, negative, zero, sub-dollar, thousands and millions. Functions cannot cross the RN to WebView JSON bridge (see LivelineChart.types.ts), so the string contract has to stay; this locks its exact output so drift fails CI instead of only showing as broken tooltip/axis labels on device.
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Smart E2E Test Selection

  • Selected E2E tags: SmokePredictions, SmokeWalletPlatform, SmokeConfirmations
  • Selected Performance tags: @PerformancePredict
  • Risk Level: medium
  • AI Confidence: 90%
click to see 🤖 AI reasoning details

E2E Test Selection:
All 20 changed files are exclusively within the app/components/UI/Predict/ directory. The changes introduce new Crypto Up/Down prediction market functionality including: a new PredictCryptoUpDownChart component with live price data via WebSocket, a new PredictCryptoUpDownDetails view, a TimeSlotPicker for series markets, new hooks (useCryptoUpDownChartData, useGameDetailsTabs, useChartData, useOpenOutcomes), utility functions (cryptoUpDown.ts), updated types, and new test IDs. The PredictMarketDetails.tsx now conditionally renders PredictCryptoUpDownDetails for crypto up/down markets, and PredictMarketDetailsActions has a new showPayoutEstimate prop. SmokePredictions is the primary tag as all changes are in the Predictions feature. Per the SmokePredictions tag description, SmokeWalletPlatform must also be selected (Predictions is a section inside Trending tab). SmokeConfirmations must also be selected (opening/closing positions are on-chain transactions). No other feature areas are affected - no changes to Engine, controllers, navigation, or other shared components.

Performance Test Selection:
The @PerformancePredict tag is directly relevant because tests/performance/login/predict/predict-market-details.spec.ts measures market details performance, and PredictMarketDetails.tsx has been significantly modified with new conditional rendering paths, new chart components with live WebSocket data, and new hooks. The addition of live price data streaming (useCryptoUpDownChartData with WebSocket), animated TimeSlotPicker, and the PredictCryptoUpDownChart component could impact rendering performance in the market details view.

View GitHub Actions results

@sonarqubecloud

Copy link
Copy Markdown

@ghgoodreau ghgoodreau requested a review from MarioAslau May 19, 2026 18:37
@ghgoodreau ghgoodreau dismissed MarioAslau’s stale review May 19, 2026 18:38

fixed the relevant high priority issue, the PR stacked on top of this also expands upon this work with a lot of improvements.

@MarioAslau MarioAslau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PredictCryptoUpDownDetails drives PredictMarketDetailsActions from raw (non-CLOB-augmented) outcomes

PredictMarketDetails runs useOpenOutcomes to overlay live CLOB prices on the resolved market:

const { closedOutcomes, openOutcomes, yesPercentage } = useOpenOutcomes({
  market,
  isMarketFetching,
});

…but the new child screen recomputes its own outcomes locally from the series payload:

const getOpenOutcomes = (market: PredictMarket): PredictOutcome[] =>
  market.outcomes.filter(
    (outcome) => outcome.status === OPEN_PREDICT_OUTCOME_STATUS,
  );
...
const getYesPercentage = (
  market: PredictMarket,
  openOutcomes: PredictOutcome[],
) => {
  const firstOpenOutcome = openOutcomes[0];
  const firstTokenPrice = firstOpenOutcome?.tokens?.[0]?.price;

  if (typeof firstTokenPrice === 'number') {
    return Math.round(firstTokenPrice * 100);
  }
  ...
};

…and feeds these (selectedOpenOutcomes, selectedYesPercentage) into PredictMarketDetailsActions (lines 547–549). Concrete effect: the Yes/No percentage shown on the buy buttons (and the new payout estimate built from token?.price) is whatever the usePredictSeries endpoint last returned — which may be several seconds stale relative to the CLOB live prices already in memory upstream. Either thread openOutcomes / yesPercentage down from PredictMarketDetails, or call useOpenOutcomes inside PredictCryptoUpDownDetails with selectedMarket. The follow-up branch predict/up-down-resuable-hook has already done the latter — landing that consolidation here avoids merge churn.

@MarioAslau MarioAslau self-requested a review May 19, 2026 19:31

@MarioAslau MarioAslau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@ghgoodreau ghgoodreau added this pull request to the merge queue May 19, 2026
Merged via the queue into main with commit 3ee5c69 May 19, 2026
139 of 144 checks passed
@ghgoodreau ghgoodreau deleted the predict/crypto-updown-details-ui branch May 19, 2026 19:57
@github-actions github-actions Bot locked and limited conversation to collaborators May 19, 2026
@metamaskbotv2 metamaskbotv2 Bot added the release-7.79.0 Issue or pull request that will be included in release 7.79.0 label May 19, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

release-7.79.0 Issue or pull request that will be included in release 7.79.0 size-XL team-predict Predict team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants