From dbfff5f42856aaf3decc5676feb3dbd0e4252a14 Mon Sep 17 00:00:00 2001 From: Kylan Hurt <6249205+smilingkylan@users.noreply.github.com> Date: Thu, 6 Nov 2025 20:50:57 -0700 Subject: [PATCH 1/5] chore: Suppress CI error (#22280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The purpose of this task is to fix the `yarn audi:ci` issues that popped up for `react-native-community/cli` packages due to a recent vulnerability related to bash commands being executed from within that package --- > [!NOTE] > Adds `npmAuditIgnoreAdvisories: [1109627]` to `.yarnrc.yml` to temporarily suppress `@react-native-community/cli` audit failures and unblock CI. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 65be2654ee7077fe52e03571639067e3186deaa9. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Luis Taniça --- .yarnrc.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.yarnrc.yml b/.yarnrc.yml index 8a6d20d53c67..d99dd37287e9 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -7,6 +7,7 @@ enableScripts: false nodeLinker: node-modules npmAuditIgnoreAdvisories: + - 1109627 # TODO: Upgrade @react-native-community/cli to 17.0.1+ when ready. Suppressing for now to unblock CI. yarnPath: .yarn/releases/yarn-4.10.3.cjs From edde65326e4d40df604bede48884e4c34fcc6245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caain=C3=A3=20Jeronimo?= Date: Fri, 7 Nov 2025 01:32:00 -0300 Subject: [PATCH 2/5] chore(predict): change polymarket links to use webview (#22255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - change polymarket links to use webview ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **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. --- > [!NOTE] > Routes Polymarket Terms and Resolution links to the in-app Webview using navigation (with InteractionManager), replacing direct Linking usage, and adds tests. > > - **Predict UI**: > - `PredictConsentSheet.tsx`: Replace `Linking.openURL` with in-app Webview navigation via `useNavigation` and `InteractionManager.runAfterInteractions`; wrap description/`Learn more` in a `TouchableOpacity`. > - `PredictMarketDetails.tsx`: Add handler to navigate to Webview for Polymarket resolution details via `InteractionManager.runAfterInteractions`. > - **Tests**: > - `PredictConsentSheet.test.tsx`: Mock `InteractionManager.runAfterInteractions`; assert navigation to `Webview/SimpleWebview` with Polymarket TOS URL and title. > - `PredictMarketDetails.test.tsx`: Mock `InteractionManager.runAfterInteractions`; verify About tab "Polymarket" press navigates to `Webview/SimpleWebview` with resolution docs URL and title. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9f4e219566a70aff91ef186092eab10b35950754. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PredictConsentSheet.test.tsx | 106 +++++++++++++++++- .../PredictConsentSheet.tsx | 43 ++++--- .../PredictMarketDetails.test.tsx | 61 ++++++++++ .../PredictMarketDetails.tsx | 22 ++-- 4 files changed, 207 insertions(+), 25 deletions(-) diff --git a/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.test.tsx b/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.test.tsx index d232b2cd6a47..602bcf65f6ba 100644 --- a/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.test.tsx +++ b/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.test.tsx @@ -1,5 +1,6 @@ import React, { useRef, useEffect } from 'react'; import { render, fireEvent, act } from '@testing-library/react-native'; +import { InteractionManager } from 'react-native'; // Internal dependencies import PredictConsentSheet, { @@ -7,6 +8,25 @@ import PredictConsentSheet, { } from './PredictConsentSheet'; import { usePredictAgreement } from '../../hooks/usePredictAgreement'; +const mockNavigate = jest.fn(); +const runAfterInteractionsCallbacks: (() => void)[] = []; +const mockRunAfterInteractions = jest.spyOn( + InteractionManager, + 'runAfterInteractions', +); +const runAfterInteractionsMockImpl: typeof InteractionManager.runAfterInteractions = + (task) => { + if (typeof task === 'function') { + runAfterInteractionsCallbacks.push(task as () => void); + } + + return { + then: jest.fn(), + done: jest.fn(), + cancel: jest.fn(), + } as ReturnType; + }; + // Mock dependencies jest.mock('react-native/Libraries/Linking/Linking', () => ({ openURL: jest.fn(), @@ -36,7 +56,7 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ - navigate: jest.fn(), + navigate: mockNavigate, goBack: jest.fn(), }), })); @@ -184,6 +204,8 @@ describe('PredictConsentSheet', () => { beforeEach(() => { jest.clearAllMocks(); + runAfterInteractionsCallbacks.length = 0; + mockRunAfterInteractions.mockImplementation(runAfterInteractionsMockImpl); (usePredictAgreement as jest.Mock).mockReturnValue({ isAgreementAccepted: false, acceptAgreement: mockAcceptAgreement, @@ -191,7 +213,12 @@ describe('PredictConsentSheet', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); + mockRunAfterInteractions.mockReset(); + }); + + afterAll(() => { + mockRunAfterInteractions.mockRestore(); }); describe('visibility behavior', () => { @@ -526,4 +553,79 @@ describe('PredictConsentSheet', () => { expect(customAcceptAgreement).toHaveBeenCalledTimes(1); }); }); + + describe('Terms Link', () => { + it('renders learn more text', () => { + const TestComponent = () => { + const ref = useRef(null); + + useEffect(() => { + act(() => { + ref.current?.onOpenBottomSheet(); + }); + }, []); + + return ( + + ); + }; + + const { getByText } = render(); + + expect(getByText('Learn more')).toBeOnTheScreen(); + }); + + it('navigates to polymarket terms webview when description is pressed', () => { + const TestComponent = () => { + const ref = useRef(null); + + useEffect(() => { + act(() => { + ref.current?.onOpenBottomSheet(); + }); + }, []); + + return ( + + ); + }; + + const { getByText } = render(); + + const learnMoreText = getByText('Learn more'); + const touchableParent = learnMoreText.parent; + + expect(touchableParent).toBeTruthy(); + + act(() => { + if (touchableParent) { + fireEvent.press(touchableParent); + } + }); + + expect(mockRunAfterInteractions).toHaveBeenCalledTimes(1); + const callback = runAfterInteractionsCallbacks[0]; + expect(callback).toBeDefined(); + + callback?.(); + + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://polymarket.com/tos', + title: 'Terms and Conditions', + }, + }); + }); + }); }); diff --git a/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.tsx b/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.tsx index 646ad236f476..e4b548493827 100644 --- a/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.tsx +++ b/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.tsx @@ -4,8 +4,8 @@ import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import React, { forwardRef, useImperativeHandle } from 'react'; -import { Linking } from 'react-native'; +import React, { forwardRef, useCallback, useImperativeHandle } from 'react'; +import { InteractionManager, TouchableOpacity } from 'react-native'; import { strings } from '../../../../../../locales/i18n'; // Internal dependencies. @@ -19,6 +19,7 @@ import { usePredictBottomSheet, type PredictBottomSheetRef, } from '../../hooks/usePredictBottomSheet'; +import { useNavigation } from '@react-navigation/native'; interface PredictConsentSheetProps { providerId: string; @@ -33,6 +34,7 @@ const PredictConsentSheet = forwardRef< PredictConsentSheetProps >(({ providerId, onDismiss, onAgree }, ref) => { const tw = useTailwind(); + const navigation = useNavigation(); const { acceptAgreement } = usePredictAgreement({ providerId }); const { sheetRef, isVisible, closeSheet, handleSheetClosed, getRefHandlers } = usePredictBottomSheet({ onDismiss }); @@ -49,6 +51,18 @@ const PredictConsentSheet = forwardRef< useImperativeHandle(ref, getRefHandlers, [getRefHandlers]); + const handlePolymarketTerms = useCallback(() => { + InteractionManager.runAfterInteractions(() => { + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://polymarket.com/tos', + title: strings('predict.consent_sheet.title'), + }, + }); + }); + }, [navigation]); + if (!isVisible) { return null; } @@ -66,19 +80,18 @@ const PredictConsentSheet = forwardRef< - - {strings('predict.consent_sheet.description')}{' '} - - { - Linking.openURL('https://polymarket.com/tos'); - }} - suppressHighlighting - > - {strings('predict.consent_sheet.learn_more')} - + + + {strings('predict.consent_sheet.description')}{' '} + + {strings('predict.consent_sheet.learn_more')} + + + void)[] = []; +const mockRunAfterInteractions = jest.spyOn( + InteractionManager, + 'runAfterInteractions', +); +const runAfterInteractionsMockImpl: typeof InteractionManager.runAfterInteractions = + (task) => { + if (typeof task === 'function') { + runAfterInteractionsCallbacks.push(task as () => void); + } + + return { + then: jest.fn(), + done: jest.fn(), + cancel: jest.fn(), + } as ReturnType; + }; + jest.mock('../../../../../core/Engine', () => ({ context: { PredictController: { @@ -435,6 +454,8 @@ function setupPredictMarketDetailsTest( } = {}, ) { jest.clearAllMocks(); + runAfterInteractionsCallbacks.length = 0; + mockRunAfterInteractions.mockImplementation(runAfterInteractionsMockImpl); const mockNavigate = jest.fn(); const mockSetOptions = jest.fn(); @@ -527,6 +548,15 @@ function setupPredictMarketDetailsTest( } describe('PredictMarketDetails', () => { + afterEach(() => { + jest.clearAllMocks(); + mockRunAfterInteractions.mockReset(); + }); + + afterAll(() => { + mockRunAfterInteractions.mockRestore(); + }); + describe('Component Rendering', () => { it('renders the main screen container', () => { setupPredictMarketDetailsTest(); @@ -611,6 +641,37 @@ describe('PredictMarketDetails', () => { ).toBeOnTheScreen(); expect(screen.getByText('Polymarket')).toBeOnTheScreen(); }); + + it('navigates to polymarket resolution details when pressed', () => { + const { mockNavigate } = setupPredictMarketDetailsTest(); + + const aboutTab = screen.getByTestId( + 'predict-market-details-tab-bar-tab-1', + ); + fireEvent.press(aboutTab); + + const resolutionText = screen.getByText('Polymarket'); + + act(() => { + fireEvent.press(resolutionText); + }); + + expect(mockRunAfterInteractions).toHaveBeenCalledTimes(1); + const callback = runAfterInteractionsCallbacks[0]; + expect(callback).toBeDefined(); + + act(() => { + callback?.(); + }); + + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://docs.polymarket.com/polymarket-learn/markets/how-are-markets-resolved', + title: 'predict.market_details.resolution_details', + }, + }); + }); }); describe('Chart Rendering', () => { diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 8eeb48d971bc..793c86af9857 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -7,7 +7,7 @@ import { import React, { useMemo, useState, useEffect, useCallback } from 'react'; import { Image, - Linking, + InteractionManager, Pressable, RefreshControl, ScrollView, @@ -360,6 +360,18 @@ const PredictMarketDetails: React.FC = () => { setIsRefreshing(false); }, [loadPositions, refetchMarket, refetchPriceHistory]); + const handlePolymarketResolution = useCallback(() => { + InteractionManager.runAfterInteractions(() => { + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://docs.polymarket.com/polymarket-learn/markets/how-are-markets-resolved', + title: strings('predict.market_details.resolution_details'), + }, + }); + }); + }, [navigation]); + type TabKey = 'positions' | 'outcomes' | 'about'; const trackMarketDetailsOpened = useCallback( @@ -692,13 +704,7 @@ const PredictMarketDetails: React.FC = () => { alignItems={BoxAlignItems.Center} twClassName="gap-2" > - { - Linking.openURL( - 'https://docs.polymarket.com/polymarket-learn/markets/how-are-markets-resolved', - ); - }} - > + Date: Thu, 6 Nov 2025 22:47:35 -0600 Subject: [PATCH 3/5] fix(predict): Various chart UX fixes / improvements (#22220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Various market details chart fixes / improvements ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: NA ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/665e3b10-522e-4a1b-ad0b-7d6253a99f43 ## **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. --- > [!NOTE] > Refines PredictDetailsChart visuals and behavior (loading indicator, grid/labels/insets), updates date/percent formatting, and adds comprehensive utils tests. > > - **UI/Chart UX**: > - Replace loading placeholder text with `ActivityIndicator`; keep chart skeleton visible during loading. > - Tweak layout/spacing: adjust margins (`mb-6`→`mb-4/mb-3`), ensure axis label row min-height, and legend spacing (`mb-3`→`mb-2`). > - Update axis label style to `text-[11px]` and use alternative text color. > - Refine grid: shift lines (`x1` to `8`), move tick labels up (`y - 4`) and set color to `colors.text.alternative`. > - Adjust area gradient fade (`Stop offset` from `100%` to `95%`). > - **Chart behavior/constants** (`utils.ts`): > - Change `LINE_CURVE` alpha to `0.2`; update `CHART_CONTENT_INSET` to `{ top: 8, bottom: 4, left: 8, right: 48 }`. > - Revise label formatting: `ONE_WEEK` now `weekday AM/PM`; `MAX` uses 2‑digit year; support seconds/milliseconds. > - Add `formatTickValue` precision rules based on range (<1: 2dp, <10: 1dp, ≥10: 0dp). > - **Tests**: > - Update chart tests to check for `line-chart` during loading. > - Add comprehensive `utils.test.ts` covering constants, date label formatting, and tick value formatting edge cases. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2f1e80afddb689e230ff4013081456c04a262ada. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Luis Taniça --- .../PredictDetailsChart.test.tsx | 5 +- .../PredictDetailsChart.tsx | 44 +- .../components/ChartArea.tsx | 2 +- .../components/ChartGrid.tsx | 6 +- .../components/ChartLegend.tsx | 2 +- .../PredictDetailsChart/utils.test.ts | 455 ++++++++++++++++++ .../components/PredictDetailsChart/utils.ts | 20 +- 7 files changed, 490 insertions(+), 44 deletions(-) create mode 100644 app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx index be9ce7e2da8f..ec453a799b61 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx @@ -164,9 +164,10 @@ describe('PredictDetailsChart', () => { }); it('renders loading state when isLoading is true', () => { - const { getByText } = setupTest({ isLoading: true }); + const { getByTestId } = setupTest({ isLoading: true }); - expect(getByText('Loading price history...')).toBeOnTheScreen(); + const lineChart = getByTestId('line-chart'); + expect(lineChart).toBeOnTheScreen(); }); it('renders empty state when no data provided', () => { diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx index 4cbbcfede2cc..35cbf1fc41d0 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StyleSheet } from 'react-native'; +import { StyleSheet, ActivityIndicator } from 'react-native'; import { LineChart } from 'react-native-svg-charts'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { @@ -97,15 +97,6 @@ const PredictDetailsChart: React.FC = ({ const renderGraph = () => { if (isLoading || !hasData) { - const placeholderLabel = isLoading - ? 'Loading price history...' - : emptyLabel; - const placeholderAxisLabel = isLoading ? 'Loading...' : '\u00A0'; - const placeholderLabels = Array.from( - { length: 4 }, - () => placeholderAxisLabel, - ); - return ( {isMultipleSeries && ( @@ -125,26 +116,23 @@ const PredictDetailsChart: React.FC = ({ - - {placeholderLabel} - + {isLoading ? ( + + ) : ( + + {emptyLabel} + + )} - {placeholderLabels.map((label, index) => ( - - {label} - - ))} - + twClassName="px-4 pt-4 pb-0 min-h-[31px]" + /> ); } @@ -163,11 +151,11 @@ const PredictDetailsChart: React.FC = ({ ); return ( - + {isMultipleSeries && ( )} - + = ({ {axisLabels.map((point, index) => ( {point.label} diff --git a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartArea.tsx b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartArea.tsx index f29482b586dd..e85e9e859486 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartArea.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartArea.tsx @@ -37,7 +37,7 @@ const ChartArea: React.FC = ({ - + diff --git a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartGrid.tsx b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartGrid.tsx index 19b5490bf1c4..fbc787e6ca95 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartGrid.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartGrid.tsx @@ -28,7 +28,7 @@ const ChartGrid: React.FC = ({ {ticks.map((tick: number, index: number) => ( = ({ diff --git a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx index 765d94dd8f7a..b91b5e6de3f4 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx @@ -23,7 +23,7 @@ const ChartLegend: React.FC = ({ series, range }) => { {series.map((seriesItem, index) => { const lastPoint = seriesItem.data[seriesItem.data.length - 1]; diff --git a/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts b/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts new file mode 100644 index 000000000000..ae8fa6183490 --- /dev/null +++ b/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts @@ -0,0 +1,455 @@ +import { PredictPriceHistoryInterval } from '../../types'; +import { + DEFAULT_EMPTY_LABEL, + LINE_CURVE, + CHART_HEIGHT, + CHART_CONTENT_INSET, + MAX_SERIES, + formatPriceHistoryLabel, + formatTickValue, +} from './utils'; + +describe('PredictDetailsChart utils', () => { + describe('constants', () => { + it('exports empty string as default label', () => { + expect(DEFAULT_EMPTY_LABEL).toBe(''); + }); + + it('exports chart height as 192', () => { + expect(CHART_HEIGHT).toBe(192); + }); + + it('exports max series as 3', () => { + expect(MAX_SERIES).toBe(3); + }); + + it('exports chart content inset with expected values', () => { + expect(CHART_CONTENT_INSET).toEqual({ + top: 8, + bottom: 4, + left: 8, + right: 48, + }); + }); + + it('exports line curve as a function', () => { + expect(typeof LINE_CURVE).toBe('function'); + expect(LINE_CURVE.alpha).toBeDefined(); + }); + }); + + describe('formatPriceHistoryLabel', () => { + beforeEach(() => { + jest.useFakeTimers(); + // Set fixed timezone for consistent test results + jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('with seconds timestamp', () => { + const createSecondsTimestamp = (dateString: string): number => + Math.floor(new Date(dateString).getTime() / 1000); + + it('formats ONE_HOUR interval as time with hour and minute', () => { + const timestamp = createSecondsTimestamp('2024-01-15T14:30:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_HOUR, + ); + + expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/); + }); + + it('formats SIX_HOUR interval as time with hour and minute', () => { + const timestamp = createSecondsTimestamp('2024-01-15T09:15:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.SIX_HOUR, + ); + + expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/); + }); + + it('formats ONE_DAY interval as time with hour and minute', () => { + const timestamp = createSecondsTimestamp('2024-01-15T18:45:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_DAY, + ); + + expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/); + }); + + it('formats ONE_WEEK interval as weekday with AM period for morning hours', () => { + const timestamp = createSecondsTimestamp('2024-01-15T08:30:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_WEEK, + ); + + expect(result).toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s(AM|PM)$/); + expect(result).toContain('AM'); + }); + + it('formats ONE_WEEK interval as weekday with PM period for afternoon hours', () => { + const timestamp = createSecondsTimestamp('2024-01-15T20:00:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_WEEK, + ); + + expect(result).toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s(AM|PM)$/); + expect(result).toContain('PM'); + }); + + it('formats ONE_WEEK interval as weekday with PM period for noon', () => { + const timestamp = createSecondsTimestamp('2024-01-15T20:00:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_WEEK, + ); + + expect(result).toContain('PM'); + }); + + it('formats ONE_MONTH interval as month and day', () => { + const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_MONTH, + ); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{1,2}$/); + }); + + it('formats MAX interval as month and 2-digit year', () => { + const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.MAX, + ); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{2}$/); + }); + + it('formats unknown interval as month and 2-digit year', () => { + const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z'); + + const result = formatPriceHistoryLabel(timestamp, 'unknown-interval'); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{2}$/); + }); + }); + + describe('with milliseconds timestamp', () => { + it('formats milliseconds timestamp for ONE_HOUR interval', () => { + const timestamp = new Date('2024-01-15T14:30:00.000Z').getTime(); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_HOUR, + ); + + expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/); + }); + + it('formats milliseconds timestamp for ONE_WEEK interval', () => { + const timestamp = new Date('2024-01-15T14:30:00.000Z').getTime(); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_WEEK, + ); + + expect(result).toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s(AM|PM)$/); + }); + + it('formats milliseconds timestamp for ONE_MONTH interval', () => { + const timestamp = new Date('2024-01-15T12:00:00.000Z').getTime(); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_MONTH, + ); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{1,2}$/); + }); + + it('formats milliseconds timestamp for MAX interval', () => { + const timestamp = new Date('2024-01-15T12:00:00.000Z').getTime(); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.MAX, + ); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{2}$/); + }); + }); + + describe('edge cases', () => { + it('handles timestamp at midnight for ONE_WEEK interval with AM period', () => { + const timestamp = Math.floor( + new Date('2024-01-15T08:00:00.000Z').getTime() / 1000, + ); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_WEEK, + ); + + expect(result).toContain('AM'); + }); + + it('handles timestamp at 11:59 AM for ONE_WEEK interval with AM period', () => { + const timestamp = Math.floor( + new Date('2024-01-15T11:59:00.000Z').getTime() / 1000, + ); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_WEEK, + ); + + expect(result).toContain('AM'); + }); + + it('handles timestamp with zero seconds', () => { + const timestamp = 0; + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_MONTH, + ); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{1,2}$/); + }); + + it('handles large milliseconds timestamp', () => { + const timestamp = 9_999_999_999_999; + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.MAX, + ); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{2}$/); + }); + }); + + describe('parameterized interval tests', () => { + it.each([ + PredictPriceHistoryInterval.ONE_HOUR, + PredictPriceHistoryInterval.SIX_HOUR, + PredictPriceHistoryInterval.ONE_DAY, + ])( + 'formats %s interval with consistent time format', + (interval: PredictPriceHistoryInterval) => { + const timestamp = Math.floor( + new Date('2024-01-15T14:30:00.000Z').getTime() / 1000, + ); + + const result = formatPriceHistoryLabel(timestamp, interval); + + expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/); + }, + ); + }); + }); + + describe('formatTickValue', () => { + describe('with non-finite values', () => { + it('returns 0 string when value is NaN', () => { + const result = formatTickValue(NaN, 5); + + expect(result).toBe('0'); + }); + + it('returns 0 string when value is positive Infinity', () => { + const result = formatTickValue(Infinity, 5); + + expect(result).toBe('0'); + }); + + it('returns 0 string when value is negative Infinity', () => { + const result = formatTickValue(-Infinity, 5); + + expect(result).toBe('0'); + }); + }); + + describe('with range less than 1', () => { + it('returns value with 2 decimal places when range is 0.5', () => { + const result = formatTickValue(0.12345, 0.5); + + expect(result).toBe('0.12'); + }); + + it('returns value with 2 decimal places when range is 0.99', () => { + const result = formatTickValue(0.56789, 0.99); + + expect(result).toBe('0.57'); + }); + + it('returns value with 2 decimal places when range is 0', () => { + const result = formatTickValue(0.123, 0); + + expect(result).toBe('0.12'); + }); + + it('rounds up correctly for 2 decimal places', () => { + const result = formatTickValue(0.126, 0.5); + + expect(result).toBe('0.13'); + }); + }); + + describe('with range between 1 and 10', () => { + it('returns value with 1 decimal place when range is 5', () => { + const result = formatTickValue(3.456, 5); + + expect(result).toBe('3.5'); + }); + + it('returns value with 1 decimal place when range is 1', () => { + const result = formatTickValue(1.234, 1); + + expect(result).toBe('1.2'); + }); + + it('returns value with 1 decimal place when range is 9.99', () => { + const result = formatTickValue(7.89, 9.99); + + expect(result).toBe('7.9'); + }); + + it('rounds up correctly for 1 decimal place', () => { + const result = formatTickValue(3.46, 5); + + expect(result).toBe('3.5'); + }); + }); + + describe('with range of 10 or greater', () => { + it('returns value with 0 decimal places when range is 10', () => { + const result = formatTickValue(15.678, 10); + + expect(result).toBe('16'); + }); + + it('returns value with 0 decimal places when range is 100', () => { + const result = formatTickValue(42.3, 100); + + expect(result).toBe('42'); + }); + + it('returns value with 0 decimal places when range is 1000', () => { + const result = formatTickValue(999.9, 1000); + + expect(result).toBe('1000'); + }); + + it('rounds down correctly for 0 decimal places', () => { + const result = formatTickValue(15.4, 10); + + expect(result).toBe('15'); + }); + }); + + describe('edge cases', () => { + it('handles zero value with small range', () => { + const result = formatTickValue(0, 0.5); + + expect(result).toBe('0.00'); + }); + + it('handles zero value with large range', () => { + const result = formatTickValue(0, 100); + + expect(result).toBe('0'); + }); + + it('handles negative value with range less than 1', () => { + const result = formatTickValue(-0.456, 0.5); + + expect(result).toBe('-0.46'); + }); + + it('handles negative value with range between 1 and 10', () => { + const result = formatTickValue(-5.67, 5); + + expect(result).toBe('-5.7'); + }); + + it('handles negative value with range of 10 or greater', () => { + const result = formatTickValue(-42.8, 50); + + expect(result).toBe('-43'); + }); + + it('handles very small positive value', () => { + const result = formatTickValue(0.0001, 0.5); + + expect(result).toBe('0.00'); + }); + + it('handles very large value', () => { + const result = formatTickValue(999999.999, 1000000); + + expect(result).toBe('1000000'); + }); + }); + + describe('parameterized range tests', () => { + it.each([ + [0.5, '0.12'], + [0.8, '0.12'], + [0.99, '0.12'], + ])( + 'formats value with 2 decimals when range is %f', + (range: number, expected: string) => { + const result = formatTickValue(0.123, range); + + expect(result).toBe(expected); + }, + ); + + it.each([ + [1, '5.7'], + [5, '5.7'], + [9.5, '5.7'], + ])( + 'formats value with 1 decimal when range is %f', + (range: number, expected: string) => { + const result = formatTickValue(5.68, range); + + expect(result).toBe(expected); + }, + ); + + it.each([ + [10, '6'], + [50, '6'], + [1000, '6'], + ])( + 'formats value with 0 decimals when range is %f', + (range: number, expected: string) => { + const result = formatTickValue(5.68, range); + + expect(result).toBe(expected); + }, + ); + }); + }); +}); diff --git a/app/components/UI/Predict/components/PredictDetailsChart/utils.ts b/app/components/UI/Predict/components/PredictDetailsChart/utils.ts index cf36da6ff1d4..37f80c73b033 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/utils.ts +++ b/app/components/UI/Predict/components/PredictDetailsChart/utils.ts @@ -2,13 +2,13 @@ import { curveCatmullRom } from 'd3-shape'; import { PredictPriceHistoryInterval } from '../../types'; export const DEFAULT_EMPTY_LABEL = ''; -export const LINE_CURVE = curveCatmullRom.alpha(0.3); +export const LINE_CURVE = curveCatmullRom.alpha(0.2); export const CHART_HEIGHT = 192; export const CHART_CONTENT_INSET = { - top: 20, - bottom: 20, - left: 20, - right: 32, + top: 8, + bottom: 4, + left: 8, + right: 48, }; export const MAX_SERIES = 3; @@ -27,11 +27,13 @@ export const formatPriceHistoryLabel = ( hour: 'numeric', minute: '2-digit', }).format(date); - case PredictPriceHistoryInterval.ONE_WEEK: - return new Intl.DateTimeFormat('en-US', { + case PredictPriceHistoryInterval.ONE_WEEK: { + const weekday = new Intl.DateTimeFormat('en-US', { weekday: 'short', - hour: 'numeric', }).format(date); + const period = date.getHours() >= 12 ? 'PM' : 'AM'; + return `${weekday} ${period}`; + } case PredictPriceHistoryInterval.ONE_MONTH: return new Intl.DateTimeFormat('en-US', { month: 'short', @@ -41,7 +43,7 @@ export const formatPriceHistoryLabel = ( default: return new Intl.DateTimeFormat('en-US', { month: 'short', - year: 'numeric', + year: '2-digit', }).format(date); } }; From 721177c4c2a191a36a2abdfb25a79d6229ce99e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Thu, 6 Nov 2025 22:16:01 -0700 Subject: [PATCH 4/5] feat(predict): relay sell orders (#22290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR enables relaying of SELL orders through the MetaMask Predict API relay, similar to how BUY orders are already relayed. Previously, only BUY orders were sent through the relay to collect fees, while SELL orders went directly to the Polymarket CLOB endpoint. **Reason for change:** - SELL orders should also be relayed to enable consistent fee collection and monitoring across all order types - Provides better control and observability for all trading activity **Improvements:** - All orders (BUY and SELL) now route through the `CLOB_RELAYER` endpoint - Rate limiting now applies to both BUY and SELL orders based on the last BUY order timestamp - Simplified order submission logic by removing conditional endpoint selection - Updated `OrderResponse` type fields to be optional for better error handling - Renamed `BUY_ORDER_RATE_LIMIT_MS` to `ORDER_RATE_LIMIT_MS` to reflect that rate limiting applies to all orders ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: N/A ## **Manual testing steps** ```gherkin Feature: Polymarket SELL order relaying Scenario: user places a SELL order Given user has an open position in a Polymarket market And user is connected to Polygon mainnet And the Predict feature is enabled When user navigates to the position details And user initiates a SELL order And user confirms the transaction Then the order should be relayed through the MetaMask API endpoint And the order should be successfully submitted to Polymarket And fees should be collected appropriately Scenario: user is rate limited after placing orders Given user has placed a BUY order within the last 5 seconds When user attempts to preview a new order (BUY or SELL) Then the preview should show rateLimited: true And user should be prevented from placing another order too quickly ``` ## **Screenshots/Recordings** N/A - Backend/API changes only, no UI changes ### **Before** - BUY orders → CLOB_RELAYER endpoint (with fees) - SELL orders → Direct to Polymarket CLOB endpoint (no fees) - Rate limiting only applied to BUY order previews ### **After** - BUY orders → CLOB_RELAYER endpoint (with fees) - SELL orders → CLOB_RELAYER endpoint (no fees) - Rate limiting applies to all order previews after a BUY order ## **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. --- > [!NOTE] > Routes both BUY and SELL orders through the MetaMask Predict relayer, applies unified rate limiting, and updates response handling/types with corresponding tests. > > - **Order routing (utils/submitClobOrder)**: > - Always post to `CLOB_RELAYER` (`/order`), removing conditional CLOB endpoint usage. > - Send both underscore and dash variants of `POLY_*` headers; include `feeAuthorization` when provided. > - **Rate limiting**: > - Rename `BUY_ORDER_RATE_LIMIT_MS` to `ORDER_RATE_LIMIT_MS` and apply in `PolymarketProvider.isRateLimited`. > - `previewOrder` now rate-limits when a `signer` is present (affects SELL after a recent BUY). > - **Order handling**: > - `OrderResponse` fields (`errorMsg`, `makingAmount`, `orderID`, `status`, `takingAmount`, `transactionsHashes`) made optional. > - `placeOrder` checks top-level `success` and nested `response.success`; returns `errorMsg` when present. > - **Tests**: > - Update submit order tests to expect relayer URL, dual-format headers, and serialized `feeAuthorization`. > - Adjust expectations for `response` shape (includes `success: true`). > - Update rate limit test to expect SELL previews to be rate-limited after a BUY. > - Remove unused `errorCode` from 403 path expectations. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 33a2d3e3937563d55a98293e137570b8f1852f87. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../polymarket/PolymarketProvider.test.ts | 9 ++- .../polymarket/PolymarketProvider.ts | 15 +++- .../Predict/providers/polymarket/constants.ts | 2 +- .../UI/Predict/providers/polymarket/types.ts | 12 +-- .../providers/polymarket/utils.test.ts | 73 ++++++++++++++++--- .../UI/Predict/providers/polymarket/utils.ts | 37 ++++------ 6 files changed, 100 insertions(+), 48 deletions(-) diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 4785cdd6d4c6..80a4cdee44c4 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -823,6 +823,7 @@ describe('PolymarketProvider', () => { mockSubmitClobOrder.mockResolvedValue({ success: true, response: { + success: true, makingAmount: '1000000', orderID: 'order-123', status: 'success', @@ -1131,7 +1132,7 @@ describe('PolymarketProvider', () => { }); mockSubmitClobOrder.mockResolvedValue({ success: true, - response: { orderId: 'test-order' }, + response: { success: true, orderId: 'test-order' }, error: undefined, }); mockCreateApiKey.mockResolvedValue({ @@ -2599,7 +2600,7 @@ describe('PolymarketProvider', () => { }); }; - it('does not set rateLimited for SELL orders', async () => { + it('sets rateLimited for SELL orders after BUY order', async () => { setupPreviewOrderMock(); const { provider, mockSigner } = setupPlaceOrderTest(); @@ -2610,7 +2611,7 @@ describe('PolymarketProvider', () => { preview, }); - // Now try to preview a SELL order - should NOT be rate limited + // Now try to preview a SELL order - should also be rate limited const sellPreview = await provider.previewOrder({ marketId: 'market-1', outcomeId: 'outcome-1', @@ -2620,7 +2621,7 @@ describe('PolymarketProvider', () => { signer: mockSigner, }); - expect(sellPreview.rateLimited).toBeUndefined(); + expect(sellPreview.rateLimited).toBe(true); }); it('does not set rateLimited when signer is not provided', async () => { diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index cef3aff544ad..e1c65bdbeb33 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -44,7 +44,7 @@ import { } from '../types'; import { PREDICT_CONSTANTS } from '../../constants/errors'; import { - BUY_ORDER_RATE_LIMIT_MS, + ORDER_RATE_LIMIT_MS, FEE_COLLECTOR_ADDRESS, MATIC_CONTRACTS, POLYGON_MAINNET_CHAIN_ID, @@ -203,7 +203,7 @@ export class PolymarketProvider implements PredictProvider { return false; } const elapsed = Date.now() - lastTimestamp; - return elapsed < BUY_ORDER_RATE_LIMIT_MS; + return elapsed < ORDER_RATE_LIMIT_MS; } public async getMarkets(params?: GetMarketsParams): Promise { @@ -463,7 +463,7 @@ export class PolymarketProvider implements PredictProvider { ): Promise { const basePreview = await previewOrder(params); - if (params.side === Side.BUY && params.signer) { + if (params.signer) { if (this.isRateLimited(params.signer.address)) { return { ...basePreview, @@ -603,13 +603,20 @@ export class PolymarketProvider implements PredictProvider { feeAuthorization, }); - if (!response) { + if (!success) { return { success, error, } as OrderResult; } + if (!response.success) { + return { + success: false, + error: response.errorMsg, + } as OrderResult; + } + if (side === Side.BUY) { this.#lastBuyOrderTimestampByAddress.set(signer.address, Date.now()); } else if (positionId) { diff --git a/app/components/UI/Predict/providers/polymarket/constants.ts b/app/components/UI/Predict/providers/polymarket/constants.ts index aca52d8b7c1b..22ef176007ef 100644 --- a/app/components/UI/Predict/providers/polymarket/constants.ts +++ b/app/components/UI/Predict/providers/polymarket/constants.ts @@ -13,7 +13,7 @@ export const FEE_COLLECTOR_ADDRESS = */ export const SLIPPAGE = 0.015; // 1.5% -export const BUY_ORDER_RATE_LIMIT_MS = 5000; +export const ORDER_RATE_LIMIT_MS = 5000; export const POLYGON_MAINNET_CHAIN_ID = 137; export const POLYGON_MAINNET_CAIP_CHAIN_ID = diff --git a/app/components/UI/Predict/providers/polymarket/types.ts b/app/components/UI/Predict/providers/polymarket/types.ts index cbafaed97e06..5244352dc9ff 100644 --- a/app/components/UI/Predict/providers/polymarket/types.ts +++ b/app/components/UI/Predict/providers/polymarket/types.ts @@ -302,13 +302,13 @@ export interface L2HeaderArgs { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type OrderResponse = { - errorMsg: string; - makingAmount: string; - orderID: string; - status: string; + errorMsg?: string; + makingAmount?: string; + orderID?: string; + status?: string; success: boolean; - takingAmount: string; - transactionsHashes: string[]; + takingAmount?: string; + transactionsHashes?: string[]; }; export interface TickSizeResponse { diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index 2454811f942d..64bf92fb52dd 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -712,11 +712,25 @@ describe('polymarket utils', () => { response: mockOrderResponse, }); expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/order', + 'https://predict.api.cx.metamask.io/order', { method: 'POST', - headers: mockHeaders, - body: JSON.stringify(mockClobOrder), + headers: { + POLY_ADDRESS: mockAddress, + POLY_SIGNATURE: 'test-signature_', + POLY_TIMESTAMP: '1704067200', + POLY_API_KEY: 'test-api-key', + POLY_PASSPHRASE: 'test-passphrase', + 'POLY-ADDRESS': mockAddress, + 'POLY-SIGNATURE': 'test-signature_', + 'POLY-TIMESTAMP': '1704067200', + 'POLY-API-KEY': 'test-api-key', + 'POLY-PASSPHRASE': 'test-passphrase', + }, + body: JSON.stringify({ + ...mockClobOrder, + feeAuthorization: undefined, + }), }, ); }); @@ -781,12 +795,24 @@ describe('polymarket utils', () => { }); expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/order', + 'https://predict.api.cx.metamask.io/order', { method: 'POST', - headers: mockHeaders, + headers: { + POLY_ADDRESS: mockAddress, + POLY_SIGNATURE: 'test-signature_', + POLY_TIMESTAMP: '1704067200', + POLY_API_KEY: 'test-api-key', + POLY_PASSPHRASE: 'test-passphrase', + 'POLY-ADDRESS': mockAddress, + 'POLY-SIGNATURE': 'test-signature_', + 'POLY-TIMESTAMP': '1704067200', + 'POLY-API-KEY': 'test-api-key', + 'POLY-PASSPHRASE': 'test-passphrase', + }, body: JSON.stringify({ ...mockClobOrder, + feeAuthorization: undefined, }), }, ); @@ -820,25 +846,37 @@ describe('polymarket utils', () => { expect(parsedBody.feeAuthorization).toEqual(feeAuthorization); }); - it('uses CLOB endpoint when feeAuthorization is not provided for BUY orders', async () => { + it('uses CLOB_RELAYER endpoint when feeAuthorization is not provided for BUY orders', async () => { await submitClobOrder({ headers: mockHeaders, clobOrder: mockClobOrder, }); expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/order', + 'https://predict.api.cx.metamask.io/order', { method: 'POST', - headers: mockHeaders, + headers: { + POLY_ADDRESS: mockAddress, + POLY_SIGNATURE: 'test-signature_', + POLY_TIMESTAMP: '1704067200', + POLY_API_KEY: 'test-api-key', + POLY_PASSPHRASE: 'test-passphrase', + 'POLY-ADDRESS': mockAddress, + 'POLY-SIGNATURE': 'test-signature_', + 'POLY-TIMESTAMP': '1704067200', + 'POLY-API-KEY': 'test-api-key', + 'POLY-PASSPHRASE': 'test-passphrase', + }, body: JSON.stringify({ ...mockClobOrder, + feeAuthorization: undefined, }), }, ); }); - it('uses CLOB endpoint for SELL orders even with feeAuthorization', async () => { + it('uses CLOB_RELAYER endpoint for SELL orders with feeAuthorization', async () => { const sellClobOrder: ClobOrderObject = { ...mockClobOrder, order: { @@ -867,12 +905,24 @@ describe('polymarket utils', () => { }); expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/order', + 'https://predict.api.cx.metamask.io/order', { method: 'POST', - headers: mockHeaders, + headers: { + POLY_ADDRESS: mockAddress, + POLY_SIGNATURE: 'test-signature_', + POLY_TIMESTAMP: '1704067200', + POLY_API_KEY: 'test-api-key', + POLY_PASSPHRASE: 'test-passphrase', + 'POLY-ADDRESS': mockAddress, + 'POLY-SIGNATURE': 'test-signature_', + 'POLY-TIMESTAMP': '1704067200', + 'POLY-API-KEY': 'test-api-key', + 'POLY-PASSPHRASE': 'test-passphrase', + }, body: JSON.stringify({ ...sellClobOrder, + feeAuthorization, }), }, ); @@ -1894,7 +1944,6 @@ describe('polymarket utils', () => { expect(result).toEqual({ success: false, error: 'You are unable to access this provider.', - errorCode: 403, }); }); diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index e75c2ef8976d..f74aac379e72 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -15,6 +15,7 @@ import { type PredictMarket, type PredictPosition, PredictActivity, + Result, } from '../../types'; import { getRecurrence } from '../../utils/format'; import type { @@ -324,29 +325,24 @@ export const submitClobOrder = async ({ headers: ClobHeaders; clobOrder: ClobOrderObject; feeAuthorization?: SafeFeeAuthorization; -}) => { - const { CLOB_ENDPOINT, CLOB_RELAYER } = getPolymarketEndpoints(); - let url = `${CLOB_ENDPOINT}/order`; - let body: ClobOrderObject & { feeAuthorization?: SafeFeeAuthorization } = { +}): Promise> => { + const { CLOB_RELAYER } = getPolymarketEndpoints(); + const url = `${CLOB_RELAYER}/order`; + const body: ClobOrderObject & { feeAuthorization?: SafeFeeAuthorization } = { ...clobOrder, + feeAuthorization, }; - // If a feeAuthorization is provided, we need to use our clob - // relayer to submit the order and collect the fee. - if (clobOrder.order.side === Side.BUY && feeAuthorization) { - url = `${CLOB_RELAYER}/order`; - body = { ...body, feeAuthorization }; - // For our relayer, we need to replace the underscores with dashes - // since underscores are not standardly allowed in headers - headers = { - ...headers, - ...Object.entries(headers) - .map(([key, value]) => ({ - [key.replace(/_/g, '-')]: value, - })) - .reduce((acc, curr) => ({ ...acc, ...curr }), {}), - }; - } + // For our relayer, we need to replace the underscores with dashes + // since underscores are not standardly allowed in headers + headers = { + ...headers, + ...Object.entries(headers) + .map(([key, value]) => ({ + [key.replace(/_/g, '-')]: value, + })) + .reduce((acc, curr) => ({ ...acc, ...curr }), {}), + }; const response = await fetch(url, { method: 'POST', @@ -359,7 +355,6 @@ export const submitClobOrder = async ({ return { success: false, error: 'You are unable to access this provider.', - errorCode: response.status, }; } const responseData = await response.json(); From 3d1cea8600eb924286167ebf055c9adb554fe926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caain=C3=A3=20Jeronimo?= Date: Fri, 7 Nov 2025 02:26:00 -0300 Subject: [PATCH 5/5] chore(predict): cash out screen changes (#22272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - cash out changes ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **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. --- > [!NOTE] > Updates the Predict cash out preview to show per‑share price with fallback, localizes copy, refactors layout/styles, adds locale strings, and adjusts tests accordingly. > > - **Predict Sell Preview (UI/Logic)**: > - Localizes header/button text and replaces hardcoded labels with `strings('predict.cash_out')`. > - Displays per‑share price using `preview.sharePrice ?? position.price` and adds line under current value via `predict.at_price_per_share`. > - Shows initial position info (amount, outcome, avg entry price) via `predict.cashout_info`. > - Refactors layout to `Box` components, tweaks icon container, and simplifies details rendering. > - **State/Computation**: > - Adds `currentPrice` derived from `preview?.sharePrice ?? position?.price`; leverages `position.avgPrice` for display. > - **Styles**: > - `app/components/UI/Predict/components/PredictPosition/PredictPosition.styles.ts`: make `positionDetails` `flex: 1`; remove extra width/flex on `positionPnl`. > - `app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.styles.ts`: remove unused containers/fields; keep core layout and button styles. > - **Tests** (`PredictSellPreview.test.tsx`): > - Update assertions to new copy (“Cash out”, `$50.00 on Yes at 50¢`), and add test for undefined `preview.sharePrice` using fallback. > - **Locales** (`locales/languages/en.json`): > - Add `predict.at_price_per_share` and `predict.cashout_info`; ensure “Cash out” casing alignment. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fae7da89abfeb71b357389b03ab7342744ec5f30. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Luis Taniça --- .../PredictPosition/PredictPosition.styles.ts | 5 +-- .../PredictSellPreview.styles.ts | 17 ------- .../PredictSellPreview.test.tsx | 29 ++++++++++-- .../PredictSellPreview/PredictSellPreview.tsx | 45 +++++++++++-------- locales/languages/en.json | 2 + 5 files changed, 55 insertions(+), 43 deletions(-) diff --git a/app/components/UI/Predict/components/PredictPosition/PredictPosition.styles.ts b/app/components/UI/Predict/components/PredictPosition/PredictPosition.styles.ts index 12bfbe1f790c..e9127fcbb6ae 100644 --- a/app/components/UI/Predict/components/PredictPosition/PredictPosition.styles.ts +++ b/app/components/UI/Predict/components/PredictPosition/PredictPosition.styles.ts @@ -20,8 +20,7 @@ const styleSheet = () => flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', - width: '100%', - flex: 5, + flex: 1, }, positionImageContainer: { paddingTop: 4, @@ -36,8 +35,6 @@ const styleSheet = () => flexDirection: 'column', justifyContent: 'flex-end', alignItems: 'flex-end', - width: '100%', - flex: 2, }, marketEntry: { flexDirection: 'column', diff --git a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.styles.ts b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.styles.ts index ba5ac982e99e..c0a2f05a77ac 100644 --- a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.styles.ts +++ b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.styles.ts @@ -41,18 +41,6 @@ const styleSheet = (params: { theme: Theme }) => { paddingBottom: 0, gap: 16, }, - positionContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 16, - width: '100%', - }, - positionDetails: { - flexDirection: 'column', - gap: 4, - flex: 1, - minWidth: 0, - }, detailsLine: { flexDirection: 'row', gap: 16, @@ -62,11 +50,6 @@ const styleSheet = (params: { theme: Theme }) => { flex: 1, minWidth: 0, }, - detailsResolves: { - flex: 1, - minWidth: 0, - color: theme.colors.text.alternative, - }, detailsRight: { flexShrink: 0, color: theme.colors.text.alternative, diff --git a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx index 4fd09af6280d..2ca45e6be552 100644 --- a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx +++ b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx @@ -440,16 +440,16 @@ describe('PredictSellPreview', () => { describe('rendering', () => { it('renders cash out screen with position details', () => { - const { getByText, queryByText } = renderWithProvider( + const { getAllByText, getByText, queryByText } = renderWithProvider( , { state: initialState, }, ); - expect(getByText('Cash Out')).toBeOnTheScreen(); + expect(getAllByText('Cash out').length).toBeGreaterThan(0); expect(getByText('Will Bitcoin reach $150,000?')).toBeOnTheScreen(); - expect(getByText('$50.00 on Yes')).toBeOnTheScreen(); + expect(getByText('$50.00 on Yes at 50¢')).toBeOnTheScreen(); expect( queryByText('Funds will be added to your available balance'), @@ -512,6 +512,29 @@ describe('PredictSellPreview', () => { expect(mockFormatPercentage).toHaveBeenCalledWith(-20); }); + it('uses position price when preview sharePrice is undefined', () => { + mockPreview = { + marketId: 'market-1', + outcomeId: 'outcome-456', + outcomeTokenId: 'outcome-token-789', + timestamp: Date.now(), + side: 'SELL', + sharePrice: undefined as unknown as number, + maxAmountSpent: 100, + minAmountReceived: 60, + slippage: 0.005, + tickSize: 0.01, + minOrderSize: 1, + negRisk: false, + }; + + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + expect(getByText('At price: 50¢ per share')).toBeOnTheScreen(); + }); + it('renders position icon with correct source', () => { renderWithProvider(, { state: initialState, diff --git a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx index aa693492a80f..fea6185aea02 100644 --- a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx +++ b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx @@ -126,7 +126,8 @@ const PredictSellPreview = () => { }, [dispatch, result]); const currentValue = preview?.minAmountReceived ?? 0; - const { cashPnl, percentPnl } = position; + const currentPrice = preview?.sharePrice ?? position?.price; + const { cashPnl, percentPnl, avgPrice } = position; const signal = useMemo(() => (cashPnl >= 0 ? '+' : '-'), [cashPnl]); @@ -198,7 +199,9 @@ const PredictSellPreview = () => { return ( goBack()}> - Cash Out + + {strings('predict.cash_out')} + { {formatPrice(currentValue, { maximumDecimals: 2 })} + + {strings('predict.at_price_per_share', { + price: (currentPrice * 100).toFixed(0), + })} + 0 ? TextColor.Success : TextColor.Error} @@ -228,30 +239,26 @@ const PredictSellPreview = () => { {strings('predict.order.order_failed_generic')} )} - - + + - - - - {outcomeTitle} - + + + {outcomeTitle} - {formatPrice(initialValue, { maximumDecimals: 2 })} on{' '} - {outcomeSideText} + {strings('predict.cashout_info', { + amount: formatPrice(initialValue, { maximumDecimals: 2 }), + outcome: outcomeSideText, + initialPrice: (avgPrice * 100).toFixed(0), + })} - - + + {renderCashOutButton()} diff --git a/locales/languages/en.json b/locales/languages/en.json index d4f5aa56eda2..af5d3310a46d 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1771,6 +1771,8 @@ "sell_position": "Sell Position", "cash_out": "Cash out", "cash_out_info": "Funds will be added to your available balance", + "at_price_per_share": "At price: {{price}}¢ per share", + "cashout_info": "{{amount}} on {{outcome}} at {{initialPrice}}¢", "buy_yes": "Yes", "buy_no": "No", "outcomes": "outcomes",