From 9ebed6c4513180423847bcc51cc665889971d739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Mon, 30 Mar 2026 19:36:53 +0200 Subject: [PATCH 1/7] chore: adds the initial setup for leaderboard feature (#28053) ## **Description** Adds the remote flag aiSocialLeaderboardEnabled and selector. When enabled, the homepage shows a Top Traders section (header + empty horizontal carousel placeholder) and registers the Top Traders as a stack screen with back/search and placeholder "Top traders". No leaderboard data or API yet this, is just the initial setup for the feature. ## **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] > **Low Risk** > Low risk: changes are additive, feature-flag gated, and primarily UI/navigation scaffolding with tests and strings; no data fetching or critical flows are modified. > > **Overview** > **Adds initial Social Leaderboard scaffolding behind a remote flag.** Introduces a version-gated `aiSocialLeaderboardEnabled` selector and registry entry, and conditionally registers a new `Routes.SOCIAL_LEADERBOARD.VIEW` stack screen (`TopTradersView`). > > **Homepage gains a new flag-controlled section.** Adds `TopTradersSection` (header + empty horizontal carousel placeholder), includes it in homepage section ordering/refresh and analytics section naming, and updates tests/localization to cover the new route, flag behavior, and section index/total calculations. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5e5c1da3f120c5be53dca27e6c2fafd3bc512999. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/Nav/Main/MainNavigator.js | 12 +++ .../Nav/Main/MainNavigator.test.tsx | 56 +++++++++++ .../Views/Homepage/Homepage.test.tsx | 53 +++++++++++ app/components/Views/Homepage/Homepage.tsx | 15 +++ .../TopTraders/TopTradersSection.test.tsx | 94 +++++++++++++++++++ .../Sections/TopTraders/TopTradersSection.tsx | 87 +++++++++++++++++ .../Homepage/Sections/TopTraders/index.ts | 1 + .../Homepage/hooks/useHomeViewedEvent.ts | 1 + .../TopTradersView/TopTradersView.test.tsx | 53 +++++++++++ .../TopTradersView/TopTradersView.testIds.ts | 5 + .../TopTradersView/TopTradersView.tsx | 77 +++++++++++++++ .../SocialLeaderboard/TopTradersView/index.ts | 1 + .../Views/SocialLeaderboard/index.ts | 1 + app/constants/navigation/Routes.ts | 4 + .../socialLeaderboard/index.test.ts | 63 +++++++++++++ .../socialLeaderboard/index.ts | 16 ++++ locales/languages/en.json | 6 ++ tests/feature-flags/feature-flag-registry.ts | 11 +++ 18 files changed, 556 insertions(+) create mode 100644 app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.test.tsx create mode 100644 app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx create mode 100644 app/components/Views/Homepage/Sections/TopTraders/index.ts create mode 100644 app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.testIds.ts create mode 100644 app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx create mode 100644 app/components/Views/SocialLeaderboard/TopTradersView/index.ts create mode 100644 app/components/Views/SocialLeaderboard/index.ts create mode 100644 app/selectors/featureFlagController/socialLeaderboard/index.test.ts create mode 100644 app/selectors/featureFlagController/socialLeaderboard/index.ts diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 0d1092f4961..f2f0f3c897e 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -117,6 +117,8 @@ import { selectMarketInsightsEnabled, } from '../../UI/MarketInsights'; import { selectMarketInsightsPerpsEnabled } from '../../../selectors/featureFlagController/marketInsights'; +import { TopTradersView } from '../../Views/SocialLeaderboard'; +import { selectSocialLeaderboardEnabled } from '../../../selectors/featureFlagController/socialLeaderboard'; import PerpsPositionTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView'; import PerpsOrderTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView'; import PerpsFundingTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView'; @@ -942,6 +944,9 @@ const MainNavigator = () => { const isMarketInsightsPerpsEnabled = useSelector( selectMarketInsightsPerpsEnabled, ); + const isSocialLeaderboardEnabled = useSelector( + selectSocialLeaderboardEnabled, + ); return ( { options={{ headerShown: false, ...slideFromRightAnimation }} /> )} + {isSocialLeaderboardEnabled && ( + + )} <> ({ + getVersion: jest.fn(() => '7.72.0'), +})); + jest.mock('@react-navigation/stack', () => ({ createStackNavigator: jest.fn().mockReturnValue({ Navigator: 'Navigator', @@ -145,4 +149,56 @@ describe('MainNavigator', () => { 'FeatureFlagOverride', ); }); + + it('includes TopTradersView screen when Social Leaderboard remote flag is enabled', () => { + const stateWithSocialLeaderboard = { + ...initialRootState, + engine: { + ...initialRootState.engine, + backgroundState: { + ...initialRootState.engine.backgroundState, + RemoteFeatureFlagController: { + ...initialRootState.engine.backgroundState + .RemoteFeatureFlagController, + remoteFeatureFlags: { + ...initialRootState.engine.backgroundState + .RemoteFeatureFlagController.remoteFeatureFlags, + aiSocialLeaderboardEnabled: { + enabled: true, + minimumVersion: '0.0.1', + }, + }, + }, + }, + }, + }; + + const container = renderWithProvider(, { + state: stateWithSocialLeaderboard, + }); + + interface ScreenChild { + name: string; + component: { name: string }; + } + const screenProps: ScreenChild[] = container.root.children + .filter( + (child): child is ReactTestInstance => + typeof child === 'object' && + 'type' in child && + 'props' in child && + child.type?.toString() === 'Screen', + ) + .map((child) => ({ + name: child.props.name, + component: child.props.component, + })); + + const topTradersScreen = screenProps?.find( + (screen) => screen?.name === Routes.SOCIAL_LEADERBOARD.VIEW, + ); + + expect(topTradersScreen).toBeDefined(); + expect(topTradersScreen?.component.name).toBe('TopTradersView'); + }); }); diff --git a/app/components/Views/Homepage/Homepage.test.tsx b/app/components/Views/Homepage/Homepage.test.tsx index f7a8b7c3449..e7341481f55 100644 --- a/app/components/Views/Homepage/Homepage.test.tsx +++ b/app/components/Views/Homepage/Homepage.test.tsx @@ -161,6 +161,14 @@ jest.mock( }), ); +jest.mock('../../../selectors/featureFlagController/whatsHappening', () => ({ + selectWhatsHappeningEnabled: jest.fn(() => false), +})); + +jest.mock('../../../selectors/featureFlagController/socialLeaderboard', () => ({ + selectSocialLeaderboardEnabled: jest.fn(() => false), +})); + /** Shape of first argument to useHomeViewedEvent (for asserting in tests). */ interface UseHomeViewedEventParamsSnapshot { sectionName?: string; @@ -178,6 +186,7 @@ jest.mock('./hooks/useHomeViewedEvent', () => ({ HomeSectionNames: { CASH: 'cash', TOKENS: 'tokens', + TOP_TRADERS: 'top_traders', WHATS_HAPPENING: 'whats_happening', PERPS: 'perps', DEFI: 'defi', @@ -269,6 +278,12 @@ describe('Homepage', () => { '../../../selectors/featureFlagController/assetsDefiPositions', ) .selectAssetsDefiPositionsEnabled.mockReturnValue(true); + jest + .requireMock('../../../selectors/featureFlagController/whatsHappening') + .selectWhatsHappeningEnabled.mockReturnValue(false); + jest + .requireMock('../../../selectors/featureFlagController/socialLeaderboard') + .selectSocialLeaderboardEnabled.mockReturnValue(false); }); it('calls enableAllPopularNetworks when Homepage is focused (useFocusEffect)', () => { @@ -391,6 +406,44 @@ describe('Homepage', () => { }); }); + describe("section indices — Social Leaderboard and What's Happening enabled", () => { + beforeEach(() => { + jest + .requireMock('../../../selectors/featureFlagController/whatsHappening') + .selectWhatsHappeningEnabled.mockReturnValue(true); + jest + .requireMock( + '../../../selectors/featureFlagController/socialLeaderboard', + ) + .selectSocialLeaderboardEnabled.mockReturnValue(true); + }); + + it('passes correct sectionIndex including top_traders and shifts following sections', () => { + renderWithProvider(, { state: stateWithPreferences }); + + const calls = getUseHomeViewedEventCalls(); + const callBySectionName = (name: string) => + calls.find((c) => c[0]?.sectionName === name)?.[0]; + + expect(callBySectionName('tokens')?.sectionIndex).toBe(0); + expect(callBySectionName('top_traders')?.sectionIndex).toBe(1); + expect(callBySectionName('perps')?.sectionIndex).toBe(2); + expect(callBySectionName('predict')?.sectionIndex).toBe(3); + expect(callBySectionName('whats_happening')?.sectionIndex).toBe(4); + expect(callBySectionName('defi')?.sectionIndex).toBe(5); + expect(callBySectionName('nfts')?.sectionIndex).toBe(6); + }); + + it("passes totalSectionsLoaded=7 when leaderboard and What's Happening flags are on", () => { + renderWithProvider(, { state: stateWithPreferences }); + + const calls = getUseHomeViewedEventCalls(); + calls.forEach((call) => { + expect(call[0]?.totalSectionsLoaded).toBe(7); + }); + }); + }); + describe('section indices — Cash section enabled', () => { beforeEach(() => { jest diff --git a/app/components/Views/Homepage/Homepage.tsx b/app/components/Views/Homepage/Homepage.tsx index ab320538fe7..9916f1a5b05 100644 --- a/app/components/Views/Homepage/Homepage.tsx +++ b/app/components/Views/Homepage/Homepage.tsx @@ -13,6 +13,7 @@ import TokensSection from './Sections/Tokens'; import WhatsHappeningSection from './Sections/WhatsHappening'; import PerpsSection from './Sections/Perpetuals'; import PredictionsSection from './Sections/Predictions'; +import TopTradersSection from './Sections/TopTraders'; import DeFiSection from './Sections/DeFi'; import NFTsSection from './Sections/NFTs'; import { SectionRefreshHandle } from './types'; @@ -21,6 +22,7 @@ import { selectPerpsEnabledFlag } from '../../UI/Perps'; import { selectPredictEnabledFlag } from '../../UI/Predict/selectors/featureFlags'; import { selectAssetsDefiPositionsEnabled } from '../../../selectors/featureFlagController/assetsDefiPositions'; import { selectWhatsHappeningEnabled } from '../../../selectors/featureFlagController/whatsHappening'; +import { selectSocialLeaderboardEnabled } from '../../../selectors/featureFlagController/socialLeaderboard'; import { selectIsMusdConversionFlowEnabledFlag } from '../../UI/Earn/selectors/featureFlags'; import { useMusdConversionEligibility } from '../../UI/Earn/hooks/useMusdConversionEligibility'; import { HomeSectionNames, HomeSectionName } from './hooks/useHomeViewedEvent'; @@ -38,6 +40,7 @@ const Homepage = forwardRef((_, ref) => { const whatsHappeningSectionRef = useRef(null); const perpsSectionRef = useRef(null); const predictionsSectionRef = useRef(null); + const topTradersSectionRef = useRef(null); const defiSectionRef = useRef(null); const nftsSectionRef = useRef(null); @@ -45,6 +48,7 @@ const Homepage = forwardRef((_, ref) => { const isPredictEnabled = useSelector(selectPredictEnabledFlag); const isDeFiEnabled = useSelector(selectAssetsDefiPositionsEnabled); const isWhatsHappeningEnabled = useSelector(selectWhatsHappeningEnabled); + const isTopTradersEnabled = useSelector(selectSocialLeaderboardEnabled); const isMusdConversionEnabled = useSelector( selectIsMusdConversionFlowEnabledFlag, ); @@ -72,6 +76,10 @@ const Homepage = forwardRef((_, ref) => { [ { name: HomeSectionNames.CASH, enabled: isCashSectionEnabled }, { name: HomeSectionNames.TOKENS, enabled: true }, + { + name: HomeSectionNames.TOP_TRADERS, + enabled: isTopTradersEnabled, + }, { name: HomeSectionNames.PERPS, enabled: isPerpsEnabled }, { name: HomeSectionNames.PREDICT, enabled: isPredictEnabled }, { @@ -86,6 +94,7 @@ const Homepage = forwardRef((_, ref) => { isWhatsHappeningEnabled, isPerpsEnabled, isPredictEnabled, + isTopTradersEnabled, isDeFiEnabled, ], ); @@ -106,6 +115,7 @@ const Homepage = forwardRef((_, ref) => { whatsHappeningSectionRef.current?.refresh(), perpsSectionRef.current?.refresh(), predictionsSectionRef.current?.refresh(), + topTradersSectionRef.current?.refresh(), defiSectionRef.current?.refresh(), nftsSectionRef.current?.refresh(), ]); @@ -129,6 +139,11 @@ const Homepage = forwardRef((_, ref) => { sectionIndex={getSectionIndex(HomeSectionNames.TOKENS)} totalSectionsLoaded={totalSectionsLoaded} /> + { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ navigate: mockNavigate }), + }; +}); + +jest.mock( + '../../../../../selectors/featureFlagController/socialLeaderboard', + () => ({ + selectSocialLeaderboardEnabled: jest.fn(() => true), + }), +); + +jest.mock('../../hooks/useHomeViewedEvent', () => ({ + __esModule: true, + default: jest.fn(() => ({ onLayout: jest.fn() })), + HomeSectionNames: { + CASH: 'cash', + TOKENS: 'tokens', + WHATS_HAPPENING: 'whats_happening', + PERPS: 'perps', + DEFI: 'defi', + PREDICT: 'predict', + NFTS: 'nfts', + TOP_TRADERS: 'top_traders', + }, +})); + +const mockSelectSocialLeaderboardEnabled = jest.requireMock( + '../../../../../selectors/featureFlagController/socialLeaderboard', +).selectSocialLeaderboardEnabled; + +const defaultProps = { sectionIndex: 1, totalSectionsLoaded: 3 }; + +describe('TopTradersSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSelectSocialLeaderboardEnabled.mockImplementation(() => true); + }); + + it('returns null when the feature flag is disabled', () => { + mockSelectSocialLeaderboardEnabled.mockImplementation(() => false); + renderWithProvider(); + expect(screen.queryByTestId('homepage-top-traders-carousel')).toBeNull(); + }); + + it('renders the carousel when the feature flag is enabled', () => { + renderWithProvider(); + expect( + screen.getByTestId('homepage-top-traders-carousel'), + ).toBeOnTheScreen(); + }); + + it('navigates to the Top Traders view when the section header is pressed', () => { + renderWithProvider(); + fireEvent.press(screen.getByText('Top Traders')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.SOCIAL_LEADERBOARD.VIEW); + }); + + it('exposes refresh via ref and resolves when called', async () => { + const ref = createRef(); + renderWithProvider(); + + expect(ref.current).not.toBeNull(); + await expect(ref.current?.refresh()).resolves.toBeUndefined(); + }); + + it('invokes onLayout from useHomeViewedEvent when the section root lays out', () => { + const mockOnLayout = jest.fn(); + const mockUseHomeViewedEvent = jest.requireMock( + '../../hooks/useHomeViewedEvent', + ).default as jest.Mock; + mockUseHomeViewedEvent.mockReturnValueOnce({ onLayout: mockOnLayout }); + + renderWithProvider(); + const root = screen.getByTestId('homepage-top-traders-section-root'); + fireEvent(root, 'layout', { + nativeEvent: { layout: { x: 0, y: 0, width: 100, height: 200 } }, + }); + + expect(mockOnLayout).toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx b/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx new file mode 100644 index 00000000000..1f038664658 --- /dev/null +++ b/app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx @@ -0,0 +1,87 @@ +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useRef, +} from 'react'; +import { ScrollView, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { Box } from '@metamask/design-system-react-native'; +import SectionHeader from '../../../../../component-library/components-temp/SectionHeader'; +import { SectionRefreshHandle } from '../../types'; +import { selectSocialLeaderboardEnabled } from '../../../../../selectors/featureFlagController/socialLeaderboard'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import useHomeViewedEvent, { + HomeSectionNames, +} from '../../hooks/useHomeViewedEvent'; + +interface TopTradersSectionProps { + sectionIndex: number; + totalSectionsLoaded: number; +} + +/** + * TopTradersSection — Social leaderboard section on the homepage. + * + * Shows a horizontal carousel of top-performing traders. + * Currently renders an empty placeholder carousel while the data layer is being built. + */ +const TopTradersSection = forwardRef< + SectionRefreshHandle, + TopTradersSectionProps +>(({ sectionIndex, totalSectionsLoaded }, ref) => { + const sectionViewRef = useRef(null); + const tw = useTailwind(); + const navigation = useNavigation(); + const isEnabled = useSelector(selectSocialLeaderboardEnabled); + const title = strings('homepage.sections.top_traders'); + + useImperativeHandle( + ref, + () => ({ + refresh: async () => undefined, + }), + [], + ); + + const { onLayout } = useHomeViewedEvent({ + sectionRef: sectionViewRef, + isLoading: false, + sectionName: HomeSectionNames.TOP_TRADERS, + sectionIndex, + totalSectionsLoaded, + isEmpty: true, + itemCount: 0, + }); + + const handleViewAll = useCallback(() => { + navigation.navigate(Routes.SOCIAL_LEADERBOARD.VIEW as never); + }, [navigation]); + + if (!isEnabled) { + return null; + } + + return ( + + + + + + + ); +}); + +export default TopTradersSection; diff --git a/app/components/Views/Homepage/Sections/TopTraders/index.ts b/app/components/Views/Homepage/Sections/TopTraders/index.ts new file mode 100644 index 00000000000..db462db9b9d --- /dev/null +++ b/app/components/Views/Homepage/Sections/TopTraders/index.ts @@ -0,0 +1 @@ +export { default } from './TopTradersSection'; diff --git a/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts b/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts index 6e8cd814180..077be4d3bb7 100644 --- a/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts +++ b/app/components/Views/Homepage/hooks/useHomeViewedEvent.ts @@ -12,6 +12,7 @@ export const HomeSectionNames = { DEFI: 'defi', PREDICT: 'predict', NFTS: 'nfts', + TOP_TRADERS: 'top_traders', } as const; export type HomeSectionName = diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx new file mode 100644 index 00000000000..12392ace7cb --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import TopTradersView from './TopTradersView'; +import { TopTradersViewSelectorsIDs } from './TopTradersView.testIds'; + +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ goBack: mockGoBack }), + }; +}); + +describe('TopTradersView', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the container', () => { + renderWithProvider(); + expect( + screen.getByTestId(TopTradersViewSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + }); + + it('renders the Top Traders title', () => { + renderWithProvider(); + expect(screen.getByText('Top Traders')).toBeOnTheScreen(); + }); + + it('renders the search button', () => { + renderWithProvider(); + expect( + screen.getByTestId(TopTradersViewSelectorsIDs.SEARCH_BUTTON), + ).toBeOnTheScreen(); + }); + + it('calls goBack when the back button is pressed', () => { + renderWithProvider(); + fireEvent.press(screen.getByTestId(TopTradersViewSelectorsIDs.BACK_BUTTON)); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('handles search button press without error', () => { + renderWithProvider(); + fireEvent.press( + screen.getByTestId(TopTradersViewSelectorsIDs.SEARCH_BUTTON), + ); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.testIds.ts b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.testIds.ts new file mode 100644 index 00000000000..aa6248d4273 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.testIds.ts @@ -0,0 +1,5 @@ +export const TopTradersViewSelectorsIDs = { + CONTAINER: 'top-traders-view-container', + BACK_BUTTON: 'top-traders-view-back-button', + SEARCH_BUTTON: 'top-traders-view-search-button', +}; diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx new file mode 100644 index 00000000000..9a5536287b8 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx @@ -0,0 +1,77 @@ +import React, { useCallback } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + Text, + TextVariant, + ButtonIcon, + ButtonIconSize, + IconName, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + TextColor, + FontWeight, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../locales/i18n'; +import { TopTradersViewSelectorsIDs } from './TopTradersView.testIds'; + +/** + * TopTradersView — Social leaderboard detail screen. + * + * Displays top-performing traders across the platform. + * Currently an empty scaffold; content will be added once the data layer is ready. + */ +const TopTradersView = () => { + const navigation = useNavigation(); + const tw = useTailwind(); + + const handleBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleSearchPress = useCallback(() => { + // Search UI will be wired when the leaderboard data layer ships. + }, []); + + return ( + + + + + + + + + {strings('social_leaderboard.top_traders_view.title')} + + + + ); +}; + +export default TopTradersView; diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/index.ts b/app/components/Views/SocialLeaderboard/TopTradersView/index.ts new file mode 100644 index 00000000000..7f3494e0d3e --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TopTradersView/index.ts @@ -0,0 +1 @@ +export { default } from './TopTradersView'; diff --git a/app/components/Views/SocialLeaderboard/index.ts b/app/components/Views/SocialLeaderboard/index.ts new file mode 100644 index 00000000000..2a56f6cad74 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/index.ts @@ -0,0 +1 @@ +export { default as TopTradersView } from './TopTradersView'; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index fc561b30564..6e33be4dfbf 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -337,6 +337,10 @@ const Routes = { ROOT: 'MarketInsights', VIEW: 'MarketInsightsView', }, + SOCIAL_LEADERBOARD: { + ROOT: 'SocialLeaderboard', + VIEW: 'TopTradersView', + }, PREDICT: { ROOT: 'Predict', MARKET_LIST: 'PredictMarketList', diff --git a/app/selectors/featureFlagController/socialLeaderboard/index.test.ts b/app/selectors/featureFlagController/socialLeaderboard/index.test.ts new file mode 100644 index 00000000000..2f98581f924 --- /dev/null +++ b/app/selectors/featureFlagController/socialLeaderboard/index.test.ts @@ -0,0 +1,63 @@ +import { selectSocialLeaderboardEnabled } from '.'; +// eslint-disable-next-line import-x/no-namespace +import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('7.72.0'), +})); + +describe('selectSocialLeaderboardEnabled', () => { + let mockHasMinimumRequiredVersion: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockHasMinimumRequiredVersion = jest.spyOn( + remoteFeatureFlagModule, + 'hasMinimumRequiredVersion', + ); + mockHasMinimumRequiredVersion.mockReturnValue(true); + }); + + afterEach(() => { + mockHasMinimumRequiredVersion?.mockRestore(); + }); + + it('returns true when remote flag is enabled and version requirement is met', () => { + const result = selectSocialLeaderboardEnabled.resultFunc({ + aiSocialLeaderboardEnabled: { enabled: true, minimumVersion: '7.72.0' }, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is disabled', () => { + const result = selectSocialLeaderboardEnabled.resultFunc({ + aiSocialLeaderboardEnabled: { enabled: false, minimumVersion: '7.72.0' }, + }); + + expect(result).toBe(false); + }); + + it('returns false when version requirement is not met', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + const result = selectSocialLeaderboardEnabled.resultFunc({ + aiSocialLeaderboardEnabled: { enabled: true, minimumVersion: '99.0.0' }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote flag is absent', () => { + const result = selectSocialLeaderboardEnabled.resultFunc({}); + + expect(result).toBe(false); + }); + + it('returns false when remote flag has an invalid shape', () => { + const result = selectSocialLeaderboardEnabled.resultFunc({ + aiSocialLeaderboardEnabled: { enabled: 'invalid', minimumVersion: 123 }, + }); + + expect(result).toBe(false); + }); +}); diff --git a/app/selectors/featureFlagController/socialLeaderboard/index.ts b/app/selectors/featureFlagController/socialLeaderboard/index.ts new file mode 100644 index 00000000000..821940ca158 --- /dev/null +++ b/app/selectors/featureFlagController/socialLeaderboard/index.ts @@ -0,0 +1,16 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; +import { + VersionGatedFeatureFlag, + validatedVersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; + +export const selectSocialLeaderboardEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const remoteFlag = + remoteFeatureFlags?.aiSocialLeaderboardEnabled as unknown as VersionGatedFeatureFlag; + + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; + }, +); diff --git a/locales/languages/en.json b/locales/languages/en.json index 107c3f6ba42..b3fd7405bcb 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1011,6 +1011,11 @@ "see_more": "See more" } }, + "social_leaderboard": { + "top_traders_view": { + "title": "Top Traders" + } + }, "perps": { "basic_functionality_disabled_title": "Perps is not available", "title": "Perps", @@ -8271,6 +8276,7 @@ "perpetuals": "Perpetuals", "predictions": "Predictions", "whats_happening": "What's happening", + "top_traders": "Top Traders", "whats_happening_categories": { "geopolitical": "Geopolitical", "macro": "Macro", diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index 8294f9e98a0..8e4177ba1c2 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -82,6 +82,17 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + aiSocialLeaderboardEnabled: { + name: 'aiSocialLeaderboardEnabled', + type: FeatureFlagType.Remote, + inProd: false, + productionDefault: { + enabled: false, + minimumVersion: '7.72.0', + }, + status: FeatureFlagStatus.Active, + }, + aiSocialMarketAnalysisEnabled: { name: 'aiSocialMarketAnalysisEnabled', type: FeatureFlagType.Remote, From daf549909f20ce7da41d61d843f2121c8443b0ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caain=C3=A3=20Jeronimo?= Date: Mon, 30 Mar 2026 14:47:51 -0300 Subject: [PATCH 2/7] refactor(predict): migrate buy flow to single-route architecture with controller state machine (#27761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The Predict buy/order flow previously navigated between separate screens for preview, token selection, and deposit confirmation. This caused visible screen flicker and complex navigation state synchronization that led to multiple UX bugs (header flicker, input delay, stale deposit amounts, broken back-swipe behavior). This PR migrates the entire buy flow to a **single-route architecture** where all order states (preview, token selection, deposit, order placement) are managed by `PredictController` and rendered inline within `PredictBuyWithAnyToken`. Key changes: - **Controller state machine**: Added `ActiveOrderState` enum (`PREVIEW → PAY_WITH_ANY_TOKEN → DEPOSITING → PLACE_ORDER → PLACING_ORDER → SUCCESS`) with dedicated controller methods for each transition (`initializeOrder`, `onConfirmOrder`, `onDepositOrder`, `onDepositOrderSuccess`, `onDepositOrderFailed`, `onOrderError`, `onOrderCancelled`, `onPlaceOrderEnd`, `onBuyPaymentTokenChange`). - **Headless confirmation component**: `PredictPayWithAnyTokenInfo` is now a headless component that syncs deposit amounts and payment tokens via effects, rather than living on a separate navigation screen. - **Removed navigation-driven flows**: Eliminated `usePredictBuyBackSwipe`, `usePredictPayWithAnyToken`, `usePredictOrderTracking`, `usePredictPayWithAnyTokenTracking`, and redirect/confirmation logic that drove screen transitions. - **Simplified hooks**: Streamlined `usePredictBuyPreviewActions` (reacts to `activeOrder.state` changes via effects), `usePredictPaymentToken`, `usePredictActiveOrder`, `usePredictBuyInputState`, and `usePredictBuyConditions`. - **Feature flag**: Gated pay-with-any-token logic behind a feature flag. - **Transaction event handling**: `PredictController` now handles `predictDepositAndOrder` transaction status events directly (confirmed → place order, failed → retry, rejected → cancel). Net result: **~800 lines removed**, eliminated screen flicker, and centralized order lifecycle in the controller instead of distributed across navigation hooks. ## **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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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] > **Low Risk** > Mostly documentation and test-mock adjustments, plus a new error code mapping; low likelihood of runtime impact aside from any new code paths relying on the added error constant/i18n key. > > **Overview** > Updates `Predict/README.md` with detailed documentation of the new `PredictBuyWithAnyToken` single-route buy flow, including its controller-driven active order state machine, component breakdown, and hook responsibilities. > > Adjusts several Predict component tests to mock `usePredictActiveOrder` as returning only `activeOrder` (removing now-unused mocked methods), and extends `PREDICT_ERROR_CODES`/`getPredictErrorMessages` with `PREVIEW_NOT_AVAILABLE`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8c2e6fc6dbee3c324702e3c8eca0b11d630b213b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Matthew Walsh --- app/components/UI/Predict/README.md | 109 + .../PredictMarketMultiple.test.tsx | 3 - .../PredictMarketOutcome.test.tsx | 3 - .../PredictMarketSingle.test.tsx | 3 - .../PredictSportCardFooter.test.tsx | 3 - app/components/UI/Predict/constants/errors.ts | 4 + .../controllers/PredictController.test.ts | 6717 +++++++++++------ .../Predict/controllers/PredictController.ts | 462 +- .../hooks/usePredictActiveOrder.test.ts | 401 +- .../UI/Predict/hooks/usePredictActiveOrder.ts | 112 +- .../usePredictBalanceTokenFilter.test.ts | 33 + .../hooks/usePredictBalanceTokenFilter.ts | 28 +- .../UI/Predict/hooks/usePredictClaim.test.ts | 2 +- .../hooks/usePredictNavigation.test.ts | 66 +- .../UI/Predict/hooks/usePredictNavigation.ts | 24 +- .../hooks/usePredictOrderPreview.test.ts | 56 +- .../Predict/hooks/usePredictOrderPreview.ts | 5 +- .../hooks/usePredictPayWithAnyToken.test.ts | 121 - .../hooks/usePredictPayWithAnyToken.ts | 102 - .../hooks/usePredictPaymentToken.test.ts | 290 +- .../Predict/hooks/usePredictPaymentToken.ts | 103 +- .../hooks/usePredictPlaceOrder.test.ts | 25 +- .../UI/Predict/hooks/usePredictPlaceOrder.ts | 98 +- .../usePredictToastRegistrations.test.tsx | 167 + .../hooks/usePredictToastRegistrations.tsx | 57 +- .../Predict/hooks/usePredictTrading.test.ts | 180 +- .../UI/Predict/hooks/usePredictTrading.ts | 6 +- .../polymarket/PolymarketProvider.test.ts | 1 + .../selectors/featureFlags/index.test.ts | 121 +- .../selectors/predictController/index.test.ts | 57 + .../selectors/predictController/index.ts | 6 +- app/components/UI/Predict/types/flags.ts | 1 + app/components/UI/Predict/types/index.ts | 6 +- app/components/UI/Predict/types/navigation.ts | 6 - .../Predict/utils/predictErrorHandler.test.ts | 219 + .../UI/Predict/utils/predictErrorHandler.ts | 78 +- .../PredictBuyWithAnyToken.test.tsx | 410 + .../PredictBuyWithAnyToken.tsx | 159 +- .../PredictBuyAmountSection.test.tsx | 87 +- .../PredictBuyAmountSection.tsx | 8 +- .../PredictBuyBottomContent.test.tsx | 118 +- .../PredictBuyBottomContent.tsx | 11 - .../PredictBuyError/PredictBuyError.test.tsx | 54 + .../PredictBuyError/PredictBuyError.tsx | 32 + .../components/PredictBuyError/index.ts | 1 + .../PredictBuyMinimumError.test.tsx | 108 - .../PredictBuyMinimumError.tsx | 48 - .../PredictBuyMinimumError/index.ts | 1 - .../PredictBuyPreviewHeader.test.tsx | 114 +- .../PredictBuyPreviewHeader.tsx | 16 +- .../PredictPayWithAnyTokenInfo.test.tsx | 112 +- .../PredictPayWithAnyTokenInfo.tsx | 63 +- .../PredictPayWithRow.test.tsx | 5 +- .../PredictPayWithRow/PredictPayWithRow.tsx | 14 +- .../hooks/usePredictBuyActions.test.ts | 474 ++ .../hooks/usePredictBuyActions.ts | 183 + .../usePredictBuyAvailableBalance.test.ts | 70 +- .../hooks/usePredictBuyAvailableBalance.ts | 14 +- .../hooks/usePredictBuyBackSwipe.test.ts | 164 - .../hooks/usePredictBuyBackSwipe.ts | 35 - .../hooks/usePredictBuyConditions.test.ts | 362 +- .../hooks/usePredictBuyConditions.ts | 171 +- .../hooks/usePredictBuyError.test.ts | 383 + .../hooks/usePredictBuyError.ts | 136 + .../hooks/usePredictBuyInfo.test.ts | 271 +- .../hooks/usePredictBuyInfo.ts | 101 +- .../hooks/usePredictBuyInputState.test.ts | 99 +- .../hooks/usePredictBuyInputState.ts | 51 +- .../hooks/usePredictBuyPreviewActions.test.ts | 632 -- .../hooks/usePredictBuyPreviewActions.ts | 249 - .../hooks/usePredictOrderTracking.test.ts | 234 - .../hooks/usePredictOrderTracking.ts | 26 - .../usePredictPayWithAnyTokenTracking.test.ts | 343 - .../usePredictPayWithAnyTokenTracking.ts | 116 - .../PredictMarketDetails.test.tsx | 1 - .../predict-controller/index.test.ts | 1 + app/util/test/initial-background-state.json | 2 +- locales/languages/en.json | 5 +- 78 files changed, 8938 insertions(+), 6221 deletions(-) delete mode 100644 app/components/UI/Predict/hooks/usePredictPayWithAnyToken.test.ts delete mode 100644 app/components/UI/Predict/hooks/usePredictPayWithAnyToken.ts create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.test.tsx create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.tsx create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/index.ts delete mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.test.tsx delete mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.tsx delete mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/index.ts create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.test.ts create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.ts delete mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.test.ts delete mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.ts create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.test.ts create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.ts delete mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.test.ts delete mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.ts delete mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.test.ts delete mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.ts delete mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.test.ts delete mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.ts diff --git a/app/components/UI/Predict/README.md b/app/components/UI/Predict/README.md index 22222353c18..5c910234880 100644 --- a/app/components/UI/Predict/README.md +++ b/app/components/UI/Predict/README.md @@ -60,6 +60,7 @@ The Predict feature enables users to participate in prediction markets within Me │ ├── format.ts # Price, percentage, and volume formatting │ └── orders.ts # Order ID generation utilities ├── /views # Main screen components +│ ├── /PredictBuyWithAnyToken # Buy/order flow (single-route architecture) │ ├── /PredictCashOut # Cash out/redeem positions screen │ ├── /PredictMarketDetails # Individual market details screen │ ├── /PredictMarketList # Market listing screen @@ -137,6 +138,114 @@ Component input → Hook state → Validation → Controller action | Price history | `usePredictPriceHistory` | Price history fetching with pagination, search, infinite scroll, and retry logic | | Order notifications | `usePredictOrders` | Automatic toast notifications, status tracking | +## PredictBuyWithAnyToken + +The buy/order flow lives in `views/PredictBuyWithAnyToken/`. This is the primary screen where users place prediction market orders. Everything — direct orders, deposit-and-order flows, and pay-with-any-token flows — happens on a **single route** without navigation redirects. + +### Single-Route Architecture + +All order states (preview, token selection, deposit, order placement) are managed by `PredictController` and rendered inline within `PredictBuyWithAnyToken`. The confirmation transaction (`PredictPayWithAnyTokenInfo`) is mounted as a headless component that syncs deposit amounts and payment tokens via effects, rather than living on a separate navigation screen. When an external payment token is selected, `initPayWithAnyToken()` fires on the initial `transitionEnd` event to prepare the deposit-and-order batch in the background. + +Flow logic (deposit → order chaining, error handling, state transitions) lives in `PredictController` — hooks react to `activeOrder.state` changes via effects rather than driving transitions themselves. + +### Components + +| Component | Description | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `PredictBuyActionButton` | Main CTA button with loading/disabled states tied to order lifecycle | +| `PredictBuyAmountSection` | Keypad and amount input for entering bet size; disables input interaction while order is placing | +| `PredictBuyBottomContent` | Bottom area layout (fee summary, action button) | +| `PredictBuyError` | Generic error display for all buy-flow errors (minimum bet, insufficient balance, order failures, insufficient pay token balance) | +| `PredictBuyPreviewHeader` | Header showing market/outcome info with `outcomeToken` prop for direct token resolution (falls back to route param token, not first token) | +| `PredictFeeSummary` | Breakdown of MetaMask fee, provider fee, deposit fee, and total | +| `PredictPayWithAnyTokenInfo` | Headless component that syncs deposit amount and payment token to the confirmation transaction; renders only when `transactionMeta` exists | +| `PredictPayWithRow` | Payment token selector row — always visible (Predict balance or external tokens); falls back to Predict balance when payToken is null | + +### Hooks + +| Hook | Description | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `usePredictBuyActions` | Orchestrates buy flow lifecycle: analytics tracking on mount, `transitionEnd` initialization for `initPayWithAnyToken()`, back-navigation cleanup, confirm/deposit/order logic, and SUCCESS → dismiss. Returns `handleConfirm` and `placeOrder` | +| `usePredictBuyConditions` | Derives boolean flags (`canPlaceBet`, `isBelowMinimum`, `isInsufficientBalance`, `isInsufficientPayTokenBalance`, `isRateLimited`, `maxBetAmount`, `isPayFeesLoading`, `isBalancePulsing`, etc.) from order and preview state. Includes stale-quote detection to bridge `TransactionPayController` timing gaps | +| `usePredictBuyError` | Derives error messages from active order state, preview errors, minimum bet violations, insufficient balance, and insufficient pay token balance. Detects order-not-filled errors for the retry flow | +| `usePredictBuyInfo` | Computes display values (`toWin`, `metamaskFee`, `providerFee`, `depositFee`, `depositAmount`, `total`, `rewardsFeeAmount`) from preview, `TransactionPayController` totals, and Predict balance | +| `usePredictBuyInputState` | Manages keypad input value, user-change tracking, input focus state, and `isConfirming` flag. Clears active order errors on user input change | +| `usePredictBuyAvailableBalance` | Resolves the available balance as a raw number — Predict balance when using balance, or Predict balance + external token balance when using an external token | + +## Active Order Lifecycle + +The `activeBuyOrder` in `PredictControllerState` tracks the full lifecycle of a single buy order from preview to completion. Only one order can be active at a time. All state transitions are owned by `PredictController` methods — hooks react to state changes via effects rather than driving transitions themselves. + +When the active order enters `PAY_WITH_ANY_TOKEN` and `placeOrder()` is called, the controller stores the preview and analytics in an in-memory `pendingOrderPreviews` map keyed by `transactionId`. After the deposit transaction confirms, `handleTransactionSideEffects()` looks up the stored preview and automatically calls `placeOrder()` to complete the order. + +### State Shape + +```typescript +activeBuyOrder: { + transactionId?: string; // Transaction ID linking deposit to order (for deposit-and-order flow) + state: ActiveOrderState; // Current lifecycle state + error?: string; // Error message from failed operations +} | null; +``` + +### ActiveOrderState + +```typescript +enum ActiveOrderState { + PREVIEW = 'preview', // User is editing amount on the keypad + PAY_WITH_ANY_TOKEN = 'pay_with_any_token', // External token selected, deposit-and-order tx prepared in background + DEPOSITING = 'depositing', // Deposit transaction in progress (set by placeOrder when state is PAY_WITH_ANY_TOKEN) + PLACING_ORDER = 'placing_order', // Order submission in flight + SUCCESS = 'success', // Order completed, about to dismiss +} +``` + +### State Machine + +```mermaid +stateDiagram-v2 + [*] --> PREVIEW: initPayWithAnyToken() + + PREVIEW --> PAY_WITH_ANY_TOKEN: selectPaymentToken(external token) + PAY_WITH_ANY_TOKEN --> PREVIEW: selectPaymentToken(balance token) + + PREVIEW --> PLACING_ORDER: placeOrder() [balance selected] + PAY_WITH_ANY_TOKEN --> DEPOSITING: placeOrder() [external token selected] + + DEPOSITING --> PLACING_ORDER: handleTransactionSideEffects(depositAndOrder confirmed) → placeOrder() + DEPOSITING --> PREVIEW: handleTransactionSideEffects(depositAndOrder failed) [sets error, retries initPayWithAnyToken] + + PLACING_ORDER --> SUCCESS: placeOrder() succeeds + PLACING_ORDER --> PREVIEW: placeOrder() fails [sets error, clears payment token, retries initPayWithAnyToken] + + SUCCESS --> [*]: onPlaceOrderEnd() + navigation pop +``` + +Notes: + +- Back navigation or approval rejection triggers `onPlaceOrderEnd()`, which clears the active order, payment token, and deposit preview. +- Deposit failure resets to `PREVIEW`, stores the error on `activeBuyOrder.error`, clears `transactionId`, and automatically retries `initPayWithAnyToken()`. +- Order failure resets to `PREVIEW`, stores the error, clears `selectedPaymentToken`, and if a `transactionId` was present, clears it and retries `initPayWithAnyToken()`. +- The `transitionEnd` listener in `usePredictBuyActions` triggers `initPayWithAnyToken()` once on initial mount to prepare the deposit-and-order batch when an external token is selected. +- Transaction status events (`TransactionController:transactionStatusUpdated`) for `predictDepositAndOrder` are handled by `handleTransactionSideEffects()` in the controller, which chains deposit confirmation into `placeOrder()` automatically using the preview stored in `pendingOrderPreviews`. +- When `placeOrder()` is called while the active order state is `PAY_WITH_ANY_TOKEN`, it transitions to `DEPOSITING`, stores the preview in `pendingOrderPreviews[transactionId]`, and returns early. The actual order placement happens when the deposit transaction confirms. +- On successful order placement, foreground flows invalidate order-related queries in Predict hooks, while background flows publish `PredictController:transactionStatusChanged` events that trigger app-level query invalidation and toast notifications. +- State transitions are gated behind the `predictWithAnyToken` feature flag — when disabled, `placeOrder()` behaves as a direct order without active order state management. + +### Controller Methods (State Transitions) + +| Method | Transition | Notes | +| --------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `initPayWithAnyToken()` | Sets `transactionId` on active order | Prepares deposit-and-order batch via provider; initializes to `PREVIEW` if no active order exists; guards against duplicates | +| `selectPaymentToken()` | `PREVIEW ↔ PAY_WITH_ANY_TOKEN` | Toggles between balance and external token; sets/clears `selectedPaymentToken` and clears error | +| `placeOrder()` | `PAY_WITH_ANY_TOKEN -> DEPOSITING` | When external token selected: stores preview in `pendingOrderPreviews`, transitions to `DEPOSITING`, returns early | +| `placeOrder()` | `PREVIEW -> PLACING_ORDER` | When balance selected: submits order directly to provider | +| `placeOrder()` | `PLACING_ORDER -> SUCCESS` | On successful order completion; optimistically updates balance; foreground hooks invalidate queries, and background flows publish an order confirmed event | +| `placeOrder()` | `PLACING_ORDER -> PREVIEW` | On order failure; stores error, clears payment token; if `transactionId` present, clears it and retries `initPayWithAnyToken()` | +| `onPlaceOrderEnd()` | `-> null` | Clears active order, payment token, and deposit preview | +| `clearOrderError()` | (no state change) | Removes error from active order | +| `setSelectedPaymentToken()` | (no state change) | Directly sets or clears the selected payment token in state | + ## Core Types and Utilities ### Key Types (`/types/index.ts`) diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx index 55f2b0bc4ea..76d2285c8b5 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx @@ -29,9 +29,6 @@ jest.mock('@react-navigation/native', () => { jest.mock('../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ activeOrder: null, - updateActiveOrder: jest.fn(), - initializeActiveOrder: jest.fn(), - clearActiveOrder: jest.fn(), }), })); diff --git a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx index 30bbcea0a3b..97e86a5a927 100644 --- a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx @@ -29,10 +29,7 @@ jest.mock('@react-navigation/native', () => { jest.mock('../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ - initializeActiveOrder: jest.fn(), activeOrder: null, - updateActiveOrder: jest.fn(), - clearActiveOrder: jest.fn(), }), })); diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx index a2105c1d29d..2846bc1f31e 100644 --- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx @@ -35,10 +35,7 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ - initializeActiveOrder: jest.fn(), activeOrder: null, - updateActiveOrder: jest.fn(), - clearActiveOrder: jest.fn(), }), })); diff --git a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx index 58bd12ed60f..06dda832e79 100644 --- a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx +++ b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx @@ -30,10 +30,7 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ - initializeActiveOrder: jest.fn(), activeOrder: null, - updateActiveOrder: jest.fn(), - clearActiveOrder: jest.fn(), }), })); diff --git a/app/components/UI/Predict/constants/errors.ts b/app/components/UI/Predict/constants/errors.ts index acfde59c463..e6f8b1a16f5 100644 --- a/app/components/UI/Predict/constants/errors.ts +++ b/app/components/UI/Predict/constants/errors.ts @@ -30,6 +30,7 @@ export const PREDICT_ERROR_CODES = { WITHDRAW_FAILED: 'PREDICT_WITHDRAW_FAILED', BUY_ORDER_NOT_FULLY_FILLED: 'PREDICT_BUY_ORDER_NOT_FULLY_FILLED', SELL_ORDER_NOT_FULLY_FILLED: 'PREDICT_SELL_ORDER_NOT_FULLY_FILLED', + PREVIEW_NOT_AVAILABLE: 'PREDICT_PREVIEW_NOT_AVAILABLE', } as const; export const getPredictErrorMessages = () => @@ -67,4 +68,7 @@ export const getPredictErrorMessages = () => [PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED]: strings( 'predict.error_messages.sell_order_not_fully_filled', ), + [PREDICT_ERROR_CODES.PREVIEW_NOT_AVAILABLE]: strings( + 'predict.error_messages.preview_not_available', + ), }) as const; diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index 07a81ee7c8b..5f87b48d957 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -1,22 +1,23 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { - MOCK_ANY_NAMESPACE, Messenger, type MessengerActions, type MessengerEvents, + MOCK_ANY_NAMESPACE, type MockAnyNamespace, } from '@metamask/messenger'; -import type { NetworkState } from '@metamask/network-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { NetworkState } from '@metamask/network-controller'; import { - TransactionStatus, type TransactionMeta, + TransactionStatus, TransactionType, } from '@metamask/transaction-controller'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; +import { analytics } from '../../../../util/analytics/analytics'; import { addTransaction, addTransactionBatch, @@ -25,6 +26,7 @@ import { PolymarketProvider } from '../providers/polymarket/PolymarketProvider'; import { ActiveOrderState, type OrderPreview, + type PlaceOrderParams, PredictBalance, PredictClaimStatus, PredictPosition, @@ -38,8 +40,9 @@ import { PredictControllerMessenger, type PredictControllerState, } from './PredictController'; -import { analytics } from '../../../../util/analytics/analytics'; +import type { PredictFeatureFlags } from '../types/flags'; +import { PREDICT_ERROR_CODES } from '../constants/errors'; import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants'; // Mock the PolymarketProvider and its dependencies jest.mock('../providers/polymarket/PolymarketProvider'); @@ -69,10 +72,35 @@ const DEFAULT_REMOTE_FEATURE_FLAG_STATE = { minimumVersion: '0.0.0', highlights: [], }, + predictWithAnyToken: { + enabled: false, + minimumVersion: '0.0.0', + }, }, cacheTimestamp: Date.now(), }; +const REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN = { + ...DEFAULT_REMOTE_FEATURE_FLAG_STATE, + remoteFeatureFlags: { + ...DEFAULT_REMOTE_FEATURE_FLAG_STATE.remoteFeatureFlags, + predictWithAnyToken: { + enabled: true, + minimumVersion: '0.0.0', + }, + }, +}; + +const REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN_OVERRIDE = { + ...DEFAULT_REMOTE_FEATURE_FLAG_STATE, + localOverrides: { + predictWithAnyToken: { + enabled: true, + minimumVersion: '0.0.0', + }, + }, +}; + const DEFAULT_NETWORK_CLIENT = { blockTracker: { checkForLatestBlock: jest.fn().mockResolvedValue(undefined), @@ -109,6 +137,16 @@ jest.mock('../../../../util/analytics/analytics', () => ({ }, })); +const mockInvalidateQueries = jest.fn(); +jest.mock('../../../../core/ReactQueryService', () => ({ + __esModule: true, + default: { + queryClient: { + invalidateQueries: (...args: unknown[]) => mockInvalidateQueries(...args), + }, + }, +})); + type AllPredictControllerMessengerActions = MessengerActions; @@ -132,6 +170,17 @@ function getRootMessenger(): RootMessenger { }); } +const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; + +function setActiveOrderForTest( + controller: PredictController, + order: PredictControllerState['activeBuyOrder'], +) { + controller.updateStateForTesting((state) => { + state.activeBuyOrder = order; + }); +} + describe('PredictController', () => { let mockPolymarketProvider: jest.Mocked; @@ -219,6 +268,26 @@ describe('PredictController', () => { prepareWithdrawConfirmation: jest.fn(), } as unknown as jest.Mocked; + // Default safe mocks for async fire-and-forget methods + // (prevents unhandled rejections when payWithAnyTokenConfirmation is + // triggered by onBuyPaymentTokenChange but the async chain completes + // after mock cleanup) + mockPolymarketProvider.prepareDeposit.mockResolvedValue({ + transactions: [ + { + params: { + to: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as `0x${string}`, + data: '0xa9059cbb' as `0x${string}`, + }, + type: TransactionType.predictDeposit, + }, + ], + chainId: '0x89', + }); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'default-batch', + }); + // Mock the PolymarketProvider constructor ( PolymarketProvider as unknown as jest.MockedClass< @@ -447,6 +516,31 @@ describe('PredictController', () => { }); }); + describe('feature flag resolution', () => { + it('uses local overrides for predictWithAnyToken', () => { + withController( + ({ controller }) => { + expect( + ( + controller as unknown as { + resolveFeatureFlags: () => PredictFeatureFlags; + } + ).resolveFeatureFlags().predictWithAnyTokenEnabled, + ).toBe(true); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue( + REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN_OVERRIDE, + ), + }, + }, + ); + }); + }); + describe('markets and positions', () => { it('get markets successfully', async () => { const mockMarkets = [ @@ -974,142 +1068,460 @@ describe('PredictController', () => { }); }); - it('handle place order errors', async () => { - await withController(async ({ controller }) => { - // Mock the provider to throw an error - mockPolymarketProvider.placeOrder.mockImplementation(() => - Promise.reject(new Error('Order placement failed')), - ); + it('does not invalidate queries directly on successful buy order when predictWithAnyToken is enabled', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; + await withController( + async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + mockInvalidateQueries.mockClear(); - const preview = createMockOrderPreview({ side: Side.SELL }); + const preview = createMockOrderPreview({ side: Side.BUY }); - await expect( - controller.placeOrder({ - preview, - }), - ).rejects.toThrow('Order placement failed'); + await controller.placeOrder({ preview }); - expect(controller.state.lastError).toBe('Order placement failed'); - }); + expect(mockInvalidateQueries).not.toHaveBeenCalled(); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); }); - it('throws provider error when placeOrder returns success false', async () => { - await withController(async ({ controller }) => { - mockPolymarketProvider.placeOrder.mockResolvedValue({ - success: false, - error: 'Order rejected by provider', - } as any); + it('does not invalidate queries on successful sell order even when predictWithAnyToken is enabled', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '200', + receivedAmount: '100', + }, + }; + await withController( + async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + mockInvalidateQueries.mockClear(); - const preview = createMockOrderPreview({ side: Side.BUY }); + const preview = createMockOrderPreview({ side: Side.SELL }); - await expect( - controller.placeOrder({ - preview, - }), - ).rejects.toThrow('Order rejected by provider'); - }); + await controller.placeOrder({ preview }); + + expect(mockInvalidateQueries).not.toHaveBeenCalled(); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); }); - it('updates state with lastError when place order fails', async () => { + it('does not invalidate queries on successful buy order when predictWithAnyToken is disabled', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; await withController(async ({ controller }) => { - mockPolymarketProvider.placeOrder.mockImplementation(() => - Promise.reject(new Error('Network error')), - ); + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + mockInvalidateQueries.mockClear(); const preview = createMockOrderPreview({ side: Side.BUY }); - await expect( - controller.placeOrder({ - preview, - }), - ).rejects.toThrow('Network error'); + await controller.placeOrder({ preview }); - expect(controller.state.lastError).toBe('Network error'); - expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0); + expect(mockInvalidateQueries).not.toHaveBeenCalled(); }); }); - it('logs error details when place order fails', async () => { - await withController(async ({ controller }) => { - mockPolymarketProvider.placeOrder.mockImplementation(() => - Promise.reject(new Error('Provider error')), - ); + it('does not invalidate queries when buy order fails', async () => { + await withController( + async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order placement failed'), + ); + mockInvalidateQueries.mockClear(); - const preview = createMockOrderPreview({ side: Side.SELL }); - const params = { - preview, - }; + const preview = createMockOrderPreview({ side: Side.BUY }); - await expect(controller.placeOrder(params)).rejects.toThrow( - 'Provider error', - ); + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order placement failed', + ); - expect(DevLogger.log).toHaveBeenCalledWith( - 'PredictController: Place order failed', - expect.objectContaining({ - error: 'Provider error', - timestamp: expect.any(String), - params, - }), - ); - }); + expect(mockInvalidateQueries).not.toHaveBeenCalled(); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); }); - it('handle non-Error objects thrown by placeOrder', async () => { - await withController(async ({ controller }) => { - // Mock the provider to throw a non-Error object - mockPolymarketProvider.placeOrder.mockImplementation(() => - Promise.reject('String error'), - ); - - const preview = createMockOrderPreview({ side: Side.SELL }); + it('publishes order confirmed event on successful buy order when predictWithAnyToken is enabled', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; + await withController( + async ({ controller, messenger }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); - await expect( - controller.placeOrder({ - preview, - }), - ).rejects.toThrow('PREDICT_PLACE_ORDER_FAILED'); + const preview = createMockOrderPreview({ side: Side.BUY }); - expect(controller.state.lastError).toBe('PREDICT_PLACE_ORDER_FAILED'); - }); - }); + await controller.placeOrder({ preview }); - it('pass signer with signPersonalMessage to placeOrder', async () => { - const mockTxMeta = { id: 'tx-signer-sell' } as any; - await withController(async ({ controller }) => { - mockPolymarketProvider.placeOrder.mockResolvedValue({ - success: true as const, - response: { - id: 'sell-order-signer', - spentAmount: '100', - receivedAmount: '200', + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'order', + status: 'confirmed', + }), + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, - }); + }, + ); + }); - (addTransaction as jest.Mock).mockResolvedValue({ - transactionMeta: mockTxMeta, - }); + it('does not publish order confirmed event when there is an active buy order', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; + await withController( + async ({ controller, messenger }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + setActiveOrderForTest(controller, { + state: ActiveOrderState.PLACING_ORDER, + }); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); - const preview = createMockOrderPreview({ - outcomeId: 'outcome-1', - outcomeTokenId: 'outcome-token-1', - side: Side.SELL, - }); + const preview = createMockOrderPreview({ side: Side.BUY }); - await controller.placeOrder({ - preview, - }); + await controller.placeOrder({ preview }); - // Verify that signPersonalMessage is included in the signer object - expect(mockPolymarketProvider.placeOrder).toHaveBeenCalledWith( - expect.objectContaining({ - signer: expect.objectContaining({ - signPersonalMessage: expect.any(Function), - signTypedMessage: expect.any(Function), - }), - }), - ); - }); + expect(handler).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'order' }), + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); + }); + + it('does not publish order event on successful sell order even when predictWithAnyToken is enabled', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '200', + receivedAmount: '100', + }, + }; + await withController( + async ({ controller, messenger }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + + const preview = createMockOrderPreview({ side: Side.SELL }); + + await controller.placeOrder({ preview }); + + expect(handler).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'order' }), + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); + }); + + it('does not publish order event on successful buy order when predictWithAnyToken is disabled', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; + await withController(async ({ controller, messenger }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await controller.placeOrder({ preview }); + + expect(handler).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'order' }), + ); + }); + }); + + it('publishes order failed event when buy order fails and there is no active buy order', async () => { + await withController( + async ({ controller, messenger }) => { + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order placement failed'), + ); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order placement failed', + ); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'order', + status: 'failed', + }), + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); + }); + + it('does not publish order failed event when buy order fails and there is an active buy order', async () => { + await withController( + async ({ controller, messenger }) => { + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order placement failed'), + ); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + setActiveOrderForTest(controller, { + state: ActiveOrderState.PLACING_ORDER, + }); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order placement failed', + ); + + expect(handler).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'order' }), + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); + }); + + it('handle place order errors', async () => { + await withController(async ({ controller }) => { + // Mock the provider to throw an error + mockPolymarketProvider.placeOrder.mockImplementation(() => + Promise.reject(new Error('Order placement failed')), + ); + + const preview = createMockOrderPreview({ side: Side.SELL }); + + await expect( + controller.placeOrder({ + preview, + }), + ).rejects.toThrow('Order placement failed'); + + expect(controller.state.lastError).toBe('Order placement failed'); + }); + }); + + it('throws provider error when placeOrder returns success false', async () => { + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: false, + error: 'Order rejected by provider', + } as any); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect( + controller.placeOrder({ + preview, + }), + ).rejects.toThrow('Order rejected by provider'); + }); + }); + + it('updates state with lastError when place order fails', async () => { + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockImplementation(() => + Promise.reject(new Error('Network error')), + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect( + controller.placeOrder({ + preview, + }), + ).rejects.toThrow('Network error'); + + expect(controller.state.lastError).toBe('Network error'); + expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0); + }); + }); + + it('logs error details when place order fails', async () => { + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockImplementation(() => + Promise.reject(new Error('Provider error')), + ); + + const preview = createMockOrderPreview({ side: Side.SELL }); + const params = { + preview, + }; + + await expect(controller.placeOrder(params)).rejects.toThrow( + 'Provider error', + ); + + expect(DevLogger.log).toHaveBeenCalledWith( + 'PredictController: Place order failed', + expect.objectContaining({ + error: 'Provider error', + timestamp: expect.any(String), + params, + }), + ); + }); + }); + + it('handle non-Error objects thrown by placeOrder', async () => { + await withController(async ({ controller }) => { + // Mock the provider to throw a non-Error object + mockPolymarketProvider.placeOrder.mockImplementation(() => + Promise.reject('String error'), + ); + + const preview = createMockOrderPreview({ side: Side.SELL }); + + await expect( + controller.placeOrder({ + preview, + }), + ).rejects.toThrow('PREDICT_PLACE_ORDER_FAILED'); + + expect(controller.state.lastError).toBe('PREDICT_PLACE_ORDER_FAILED'); + }); + }); + + it('pass signer with signPersonalMessage to placeOrder', async () => { + const mockTxMeta = { id: 'tx-signer-sell' } as any; + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: true as const, + response: { + id: 'sell-order-signer', + spentAmount: '100', + receivedAmount: '200', + }, + }); + + (addTransaction as jest.Mock).mockResolvedValue({ + transactionMeta: mockTxMeta, + }); + + const preview = createMockOrderPreview({ + outcomeId: 'outcome-1', + outcomeTokenId: 'outcome-token-1', + side: Side.SELL, + }); + + await controller.placeOrder({ + preview, + }); + + // Verify that signPersonalMessage is included in the signer object + expect(mockPolymarketProvider.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + signer: expect.objectContaining({ + signPersonalMessage: expect.any(Function), + signTypedMessage: expect.any(Function), + }), + }), + ); + }); }); it('skips analytics tracking when analyticsProperties not provided', async () => { @@ -3545,29 +3957,15 @@ describe('PredictController', () => { }); describe('activeOrder and selectedPaymentToken management', () => { - it('setActiveOrder updates state with provided order', () => { - withController(({ controller }) => { - const order: PredictControllerState['activeOrder'] = { - amount: 50, - state: ActiveOrderState.PREVIEW, - }; - - controller.setActiveOrder(order); - - expect(controller.state.activeOrder).toEqual(order); - }); - }); - - it('clearActiveOrder sets activeOrder to null', () => { + it('clearActiveOrder removes activeOrder for the address', () => { withController(({ controller }) => { - controller.setActiveOrder({ - amount: 50, + setActiveOrderForTest(controller, { state: ActiveOrderState.PREVIEW, }); controller.clearActiveOrder(); - expect(controller.state.activeOrder).toBeNull(); + expect(controller.state.activeBuyOrder).toBeNull(); }); }); @@ -3600,492 +3998,578 @@ describe('PredictController', () => { }); }); - describe('payWithAnyTokenConfirmation', () => { - it('uses predict deposit transaction when setup transactions are present', async () => { - const setupTransaction = { - params: { - to: '0x1000000000000000000000000000000000000001' as `0x${string}`, - data: '0x095ea7b3000000000000000000000000' as `0x${string}`, - }, - type: TransactionType.contractInteraction, - }; - const depositTransaction = { - params: { - to: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as `0x${string}`, - data: '0xa9059cbb000000000000000000000000' as `0x${string}`, - }, - type: TransactionType.predictDeposit, - }; - - mockPolymarketProvider.prepareDeposit.mockResolvedValue({ - transactions: [setupTransaction, depositTransaction], + describe('selectPaymentToken', () => { + const createAssetToken = ( + overrides: Partial<{ address: string; chainId: string; symbol: string }>, + ) => + ({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', chainId: '0x89', - }); + symbol: 'USDC.e', + decimals: 6, + image: '', + name: 'USDC.e', + balance: '0', + logo: undefined, + isETH: false, + ...overrides, + }) as any; - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: 'tx-pay-with-any-token', - }); + it('does nothing when token is null', () => { + withController(({ controller }) => { + const existingToken = { + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }; + controller.setSelectedPaymentToken(existingToken); - await withController(async ({ controller }) => { - const result = await controller.payWithAnyTokenConfirmation(); + controller.selectPaymentToken(null); - expect(result).toEqual({ - success: true, - response: { - batchId: 'tx-pay-with-any-token', - }, + expect(controller.state.selectedPaymentToken).toEqual(existingToken); + }); + }); + + it('sets selectedPaymentToken to null for balance placeholder address', () => { + withController(({ controller }) => { + controller.setSelectedPaymentToken({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', }); - expect(addTransactionBatch).toHaveBeenCalledWith( - expect.objectContaining({ - from: '0x1234567890123456789012345678901234567890', - transactions: expect.arrayContaining([ - expect.objectContaining({ - type: 'predictDepositAndOrder', - }), - ]), + controller.selectPaymentToken( + createAssetToken({ + address: '0x0000000000000000000000000000000000000001', }), ); + + expect(controller.state.selectedPaymentToken).toBeNull(); }); }); - it('processes batch when no predict deposit transaction type is present', async () => { - mockPolymarketProvider.prepareDeposit.mockResolvedValue({ - transactions: [ - { - params: { - to: '0x1000000000000000000000000000000000000001' as `0x${string}`, - data: '0x095ea7b3000000000000000000000000' as `0x${string}`, - }, - type: TransactionType.contractInteraction, - }, - ], - chainId: '0x89', - }); + it('sets selectedPaymentToken for external token', () => { + withController(({ controller }) => { + controller.selectPaymentToken( + createAssetToken({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + chainId: '0x89', + symbol: 'USDC.e', + }), + ); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: 'batch-no-deposit', + expect(controller.state.selectedPaymentToken).toEqual({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + chainId: '0x89', + symbol: 'USDC.e', + }); }); + }); - await withController(async ({ controller }) => { - const result = await controller.payWithAnyTokenConfirmation(); - - expect(result).toEqual({ - success: true, - response: { - batchId: 'batch-no-deposit', - }, + it('clears error and transitions PAY_WITH_ANY_TOKEN to PREVIEW for balance token', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + transactionId: 'batch-123', + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + error: 'previous error', }); - expect(addTransactionBatch).toHaveBeenCalled(); + controller.selectPaymentToken( + createAssetToken({ + address: '0x0000000000000000000000000000000000000001', + }), + ); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect(controller.state.activeBuyOrder?.transactionId).toBe( + 'batch-123', + ); + expect(controller.state.activeBuyOrder?.error).toBeUndefined(); }); }); - }); - - describe('transactionStatusChanged event', () => { - const accountAddress = '0x1234567890123456789012345678901234567890'; - - const createPredictTransactionMeta = ({ - nestedType, - status, - batchId, - from, - }: { - nestedType: TransactionType; - status: TransactionStatus; - batchId?: string; - from?: string; - }) => - ({ - id: 'tx-1', - status, - batchId, - txParams: { - from: from ?? accountAddress, - to: '0x0000000000000000000000000000000000000001', - value: '0x0', - data: '0x', - }, - nestedTransactions: [ - { - type: nestedType, - }, - ], - }) as any; - it('publishes event for predict deposit transaction with approved status', () => { - withController(({ controller, messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.approved, - batchId: 'batch-1', - from: accountAddress.toUpperCase(), + it('clears error and transitions PREVIEW to PAY_WITH_ANY_TOKEN for external token', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + error: 'old error', }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, + controller.selectPaymentToken( + createAssetToken({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + }), ); - controller.updateStateForTesting((state) => { - state.pendingDeposits = { - [accountAddress]: 'batch-1', - }; - }); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, + ); + expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + expect(mockPolymarketProvider.prepareDeposit).not.toHaveBeenCalled(); + expect(addTransactionBatch).not.toHaveBeenCalled(); + }); + }); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + it('does not change state when in PAY_WITH_ANY_TOKEN and external token selected', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + }); - expect(transactionStatusChangedHandler).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'deposit', - status: 'approved', - senderAddress: accountAddress, - transactionId: 'tx-1', + controller.selectPaymentToken( + createAssetToken({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', }), ); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, + ); }); }); - it('publishes event for deposit when pendingDeposits has placeholder before batchId is assigned', () => { - withController(({ controller, messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.approved, - from: accountAddress, + it('does not change state when in PREVIEW and balance token selected', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, + controller.selectPaymentToken( + createAssetToken({ + address: '0x0000000000000000000000000000000000000001', + }), ); - controller.updateStateForTesting((state) => { - state.pendingDeposits = { - [accountAddress]: 'pending', - }; - }); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + }); + }); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + it('still sets selectedPaymentToken when activeOrder is null', () => { + withController(({ controller }) => { + expect(controller.state.activeBuyOrder).toBeNull(); - expect(transactionStatusChangedHandler).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'deposit', - status: 'approved', - senderAddress: accountAddress, - transactionId: 'tx-1', + controller.selectPaymentToken( + createAssetToken({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + chainId: '0x89', + symbol: 'USDC.e', }), ); + + expect(controller.state.selectedPaymentToken).toEqual({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + chainId: '0x89', + symbol: 'USDC.e', + }); }); }); + }); - it('publishes event for predict claim transaction with confirmed status', () => { - withController(({ controller, messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const claimablePositions = [ - createMockPosition({ - id: 'position-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 25, - }), - createMockPosition({ - id: 'position-lost', - status: PredictPositionStatus.LOST, - currentValue: 999, - cashPnl: -999, - }), - ]; - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictClaim, - status: TransactionStatus.confirmed, + describe('clearOrderError', () => { + it('clears error from activeOrder', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + error: 'some error', }); - controller.updateStateForTesting((state) => { - state.claimablePositions = { - [accountAddress]: claimablePositions, - }; + controller.clearOrderError(); + + expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + }); + }); + + it('does nothing when activeOrder has no error', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, - ); + controller.clearOrderError(); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + }); + }); - expect(transactionStatusChangedHandler).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'claim', - status: 'confirmed', - senderAddress: accountAddress, - transactionId: 'tx-1', - amount: 100, - }), - ); + it('does not throw when activeOrder is null', () => { + withController(({ controller }) => { + expect(controller.state.activeBuyOrder).toBeNull(); + + expect(() => controller.clearOrderError()).not.toThrow(); }); }); - it('clears only sender pending deposit when selected account differs', () => { - withController(({ controller, messenger }) => { - const selectedAddress = accountAddress; - const senderAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.failed, - batchId: 'batch-sender', - from: senderAddress, + it('preserves other activeOrder properties', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + error: 'some error', }); - controller.updateStateForTesting((state) => { - state.pendingDeposits = { - [selectedAddress]: 'batch-selected', - [senderAddress]: 'batch-sender', + controller.clearOrderError(); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + }); + }); + }); + + describe('activeBuyOrder and pendingOrderPreviews', () => { + function getPendingOrderPreviews(controller: PredictController): { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: PlaceOrderParams['analyticsProperties']; + }; + } { + return ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: PlaceOrderParams['analyticsProperties']; + }; }; + } + ).pendingOrderPreviews; + } + + it('clearActiveOrder sets activeBuyOrder to null', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + controller.clearActiveOrder(); - expect(controller.state.pendingDeposits[selectedAddress]).toBe( - 'batch-selected', + expect(controller.state.activeBuyOrder).toBeNull(); + }); + }); + + it('clearOrderError clears error on activeBuyOrder', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + error: 'some error', + }); + + controller.clearOrderError(); + + expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, ); - expect(controller.state.pendingDeposits[senderAddress]).toBeUndefined(); }); }); - it('confirms claim for sender account when selected account differs', () => { - withController(({ controller, messenger }) => { - const selectedAddress = accountAddress; - const senderAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - const senderClaimablePositions = [ - createMockPosition({ - id: 'position-sender', - status: PredictPositionStatus.WON, - currentValue: 50, - cashPnl: 10, - }), - ]; - const selectedClaimablePositions = [ - createMockPosition({ - id: 'position-selected', - status: PredictPositionStatus.WON, - currentValue: 20, - cashPnl: 5, - }), - ]; - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictClaim, - status: TransactionStatus.confirmed, - from: senderAddress, - }); - - mockPolymarketProvider.confirmClaim = jest.fn(); - - controller.updateStateForTesting((state) => { - state.claimablePositions = { - [selectedAddress]: selectedClaimablePositions, - [senderAddress]: senderClaimablePositions, - }; + it('onPlaceOrderEnd sets activeBuyOrder to null and does not clear pendingOrderPreviews', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.SUCCESS, }); + getPendingOrderPreviews(controller)['tx-123'] = { + preview: createMockOrderPreview(), + signerAddress: MOCK_ADDRESS, + }; - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + controller.onPlaceOrderEnd(); - expect(mockPolymarketProvider.confirmClaim).toHaveBeenCalledWith({ - positions: senderClaimablePositions, - signer: expect.objectContaining({ address: senderAddress }), - }); - expect(controller.state.claimablePositions[selectedAddress]).toEqual( - selectedClaimablePositions, - ); - expect(controller.state.claimablePositions[senderAddress]).toEqual([]); + expect(controller.state.activeBuyOrder).toBeNull(); + expect(getPendingOrderPreviews(controller)['tx-123']).toBeDefined(); }); }); - it('publishes event for predict withdraw transaction with failed status', () => { - withController(({ messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictWithdraw, - status: TransactionStatus.failed, - }); + it('placeOrder in PAY_WITH_ANY_TOKEN stores preview in pendingOrderPreviews keyed by transactionId', async () => { + await withController( + async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + transactionId: 'tx-100', + }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, - ); + const preview = createMockOrderPreview({ side: Side.BUY }); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + await controller.placeOrder({ + analyticsProperties: { marketId: 'market-1' }, + preview, + transactionId: 'tx-100', + }); - expect(transactionStatusChangedHandler).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'withdraw', - status: 'failed', - senderAddress: accountAddress, - transactionId: 'tx-1', - }), - ); - }); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.DEPOSITING, + ); + expect( + getPendingOrderPreviews(controller)['tx-100']?.preview, + ).toEqual(preview); + expect( + getPendingOrderPreviews(controller)['tx-100']?.signerAddress, + ).toBe(MOCK_ADDRESS); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); }); - it('does not publish event for non-predict transactions', () => { - withController(({ messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.simpleSend, - status: TransactionStatus.confirmed, + it('selectPaymentToken transitions activeBuyOrder state', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + error: 'old error', }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, - ); - - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, + controller.selectPaymentToken({ + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + chainId: '0x89', + symbol: 'USDC.e', } as any); - expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, + ); }); }); - it('does not publish event for deposit with wrong batchId', () => { - withController(({ controller, messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.confirmed, - batchId: 'batch-not-pending', - }); + it('isCurrentActiveBuyOrder returns false when activeBuyOrder has no transactionId and a transactionId is provided', async () => { + await withController( + async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, - ); + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: true, + response: { + id: 'order-check', + spentAmount: '100', + receivedAmount: '200', + }, + }); - controller.updateStateForTesting((state) => { - state.pendingDeposits = { - [accountAddress]: 'batch-expected', - }; - }); + const preview = createMockOrderPreview({ side: Side.BUY }); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any); + await controller.placeOrder({ + preview, + transactionId: 'tx-1', + }); - expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); - }); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), + }, + }, + ); }); + }); - it('does not publish event when nested transactions are missing', () => { - withController(({ messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = { - ...createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.approved, - }), - nestedTransactions: undefined, - }; - - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, - ); + describe('payWithAnyTokenConfirmation', () => { + it('initializes an order when there is no active order', async () => { + await withController(async ({ controller }) => { + controller.setSelectedPaymentToken({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as { transactionMeta: TransactionMeta }); + const result = await controller.initPayWithAnyToken(); - expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: true, + response: { + batchId: 'default-batch', + }, + }); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect(controller.state.selectedPaymentToken).toBeNull(); + expect(mockPolymarketProvider.prepareDeposit).toHaveBeenCalled(); + expect(addTransactionBatch).toHaveBeenCalled(); }); }); - it('does not publish event when transaction status cannot be mapped', () => { - withController(({ controller, messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictClaim, - status: TransactionStatus.confirmed, + it('reuses existing activeBuyOrder when already present', async () => { + await withController(async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + transactionId: 'existing-tx', }); - transactionMeta.status = 'unapproved' as TransactionStatus; + const result = await controller.initPayWithAnyToken(); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, + expect(result.success).toBe(true); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, ); + }); + }); - controller.updateStateForTesting((state) => { - state.pendingClaims = { - [accountAddress]: 'claim-batch-1', - }; - }); + it('uses predict deposit transaction when setup transactions are present', async () => { + const setupTransaction = { + params: { + to: '0x1000000000000000000000000000000000000001' as `0x${string}`, + data: '0x095ea7b3000000000000000000000000' as `0x${string}`, + }, + type: TransactionType.contractInteraction, + }; + const depositTransaction = { + params: { + to: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as `0x${string}`, + data: '0xa9059cbb000000000000000000000000' as `0x${string}`, + }, + type: TransactionType.predictDeposit, + }; - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as { transactionMeta: TransactionMeta }); + mockPolymarketProvider.prepareDeposit.mockResolvedValue({ + transactions: [setupTransaction, depositTransaction], + chainId: '0x89', + }); - expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'tx-pay-with-any-token', }); - }); - it('does not publish event for deposit when no pending state exists', () => { - withController(({ messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.approved, - batchId: 'batch-1', + await withController(async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - messenger.subscribe( - 'PredictController:transactionStatusChanged', - transactionStatusChangedHandler, - ); + const result = await controller.initPayWithAnyToken(); - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as { transactionMeta: TransactionMeta }); + expect(result).toEqual({ + success: true, + response: { + batchId: 'tx-pay-with-any-token', + }, + }); - expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); + expect(addTransactionBatch).toHaveBeenCalledWith( + expect.objectContaining({ + from: '0x1234567890123456789012345678901234567890', + transactions: expect.arrayContaining([ + expect.objectContaining({ + type: 'predictDepositAndOrder', + }), + ]), + }), + ); }); }); - it('publishes event for pending deposit when sender address is missing', () => { - withController(({ controller, messenger }) => { - const transactionStatusChangedHandler = jest.fn(); - const fallbackAddress = '0xAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCd'; - const transactionMeta = { - ...createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.approved, - }), - txParams: { - to: '0x0000000000000000000000000000000000000001', - value: '0x0', - data: '0x', + it('processes batch when no predict deposit transaction type is present', async () => { + mockPolymarketProvider.prepareDeposit.mockResolvedValue({ + transactions: [ + { + params: { + to: '0x1000000000000000000000000000000000000001' as `0x${string}`, + data: '0x095ea7b3000000000000000000000000' as `0x${string}`, + }, + type: TransactionType.contractInteraction, }, - }; + ], + chainId: '0x89', + }); - jest - .spyOn( - controller as unknown as { getEvmAccountAddress: () => string }, - 'getEvmAccountAddress', - ) - .mockReturnValue(fallbackAddress); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'batch-no-deposit', + }); + + await withController(async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); + + const result = await controller.initPayWithAnyToken(); + + expect(result).toEqual({ + success: true, + response: { + batchId: 'batch-no-deposit', + }, + }); + + expect(addTransactionBatch).toHaveBeenCalled(); + }); + }); + + it('clears error on activeBuyOrder after successful batch creation', async () => { + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.activeBuyOrder = { + state: ActiveOrderState.PREVIEW, + error: 'previous-error', + }; + }); + + const result = await controller.initPayWithAnyToken(); + + expect(result).toEqual({ + success: true, + response: { + batchId: 'default-batch', + }, + }); + expect(controller.state.activeBuyOrder?.error).toBeUndefined(); + }); + }); + }); + + describe('transactionStatusChanged event', () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + + const createPredictTransactionMeta = ({ + nestedType, + status, + batchId, + from, + }: { + nestedType: TransactionType; + status: TransactionStatus; + batchId?: string; + from?: string; + }) => + ({ + id: 'tx-1', + status, + batchId, + txParams: { + from: from ?? accountAddress, + to: '0x0000000000000000000000000000000000000001', + value: '0x0', + data: '0x', + }, + nestedTransactions: [ + { + type: nestedType, + }, + ], + }) as any; + + it('publishes event for predict deposit transaction with approved status', () => { + withController(({ controller, messenger }) => { + const transactionStatusChangedHandler = jest.fn(); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.approved, + batchId: 'batch-1', + from: accountAddress.toUpperCase(), + }); messenger.subscribe( 'PredictController:transactionStatusChanged', @@ -4094,2417 +4578,4164 @@ describe('PredictController', () => { controller.updateStateForTesting((state) => { state.pendingDeposits = { - [accountAddress]: 'pending', + [accountAddress]: 'batch-1', }; }); messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); expect(transactionStatusChangedHandler).toHaveBeenCalledWith( expect.objectContaining({ type: 'deposit', status: 'approved', - senderAddress: fallbackAddress.toLowerCase(), + senderAddress: accountAddress, + transactionId: 'tx-1', }), ); }); }); - it('clears pending deposit when deposit transaction is rejected', () => { + it('publishes event for deposit when pendingDeposits has placeholder before batchId is assigned', () => { withController(({ controller, messenger }) => { + const transactionStatusChangedHandler = jest.fn(); const transactionMeta = createPredictTransactionMeta({ nestedType: TransactionType.predictDeposit, - status: TransactionStatus.rejected, - batchId: 'batch-1', + status: TransactionStatus.approved, + from: accountAddress, }); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); + controller.updateStateForTesting((state) => { state.pendingDeposits = { - [accountAddress]: 'batch-1', + [accountAddress]: 'pending', }; }); messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); - expect(controller.state.pendingDeposits[accountAddress]).toBe( - undefined, + expect(transactionStatusChangedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'deposit', + status: 'approved', + senderAddress: accountAddress, + transactionId: 'tx-1', + }), ); }); }); - it('clears pending claim when claim transaction is confirmed', () => { + it('publishes event for predict claim transaction with confirmed status', () => { withController(({ controller, messenger }) => { - // Arrange + const transactionStatusChangedHandler = jest.fn(); + const claimablePositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 25, + }), + createMockPosition({ + id: 'position-lost', + + status: PredictPositionStatus.LOST, + currentValue: 999, + cashPnl: -999, + }), + ]; const transactionMeta = createPredictTransactionMeta({ nestedType: TransactionType.predictClaim, status: TransactionStatus.confirmed, - batchId: 'claim-batch-1', }); controller.updateStateForTesting((state) => { - state.pendingClaims = { - [accountAddress]: 'claim-batch-1', + state.claimablePositions = { + [accountAddress]: claimablePositions, }; }); - // Act + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); + messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); - // Assert - expect(controller.state.pendingClaims[accountAddress]).toBeUndefined(); + expect(transactionStatusChangedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'claim', + status: 'confirmed', + senderAddress: accountAddress, + transactionId: 'tx-1', + amount: 100, + }), + ); }); }); - it('clears pending claim when claim transaction is failed', () => { + it('clears only sender pending deposit when selected account differs', () => { withController(({ controller, messenger }) => { - // Arrange + const selectedAddress = accountAddress; + const senderAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictClaim, + nestedType: TransactionType.predictDeposit, status: TransactionStatus.failed, - batchId: 'claim-batch-1', + batchId: 'batch-sender', + from: senderAddress, }); controller.updateStateForTesting((state) => { - state.pendingClaims = { - [accountAddress]: 'claim-batch-1', + state.pendingDeposits = { + [selectedAddress]: 'batch-selected', + [senderAddress]: 'batch-sender', }; }); - // Act messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); - // Assert - expect(controller.state.pendingClaims[accountAddress]).toBeUndefined(); + expect(controller.state.pendingDeposits[selectedAddress]).toBe( + 'batch-selected', + ); + expect(controller.state.pendingDeposits[senderAddress]).toBeUndefined(); }); }); - it('clears pending claim when claim transaction is rejected', () => { + it('confirms claim for sender account when selected account differs', () => { withController(({ controller, messenger }) => { - // Arrange + const selectedAddress = accountAddress; + const senderAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const senderClaimablePositions = [ + createMockPosition({ + id: 'position-sender', + status: PredictPositionStatus.WON, + currentValue: 50, + cashPnl: 10, + }), + ]; + const selectedClaimablePositions = [ + createMockPosition({ + id: 'position-selected', + status: PredictPositionStatus.WON, + currentValue: 20, + cashPnl: 5, + }), + ]; const transactionMeta = createPredictTransactionMeta({ nestedType: TransactionType.predictClaim, - status: TransactionStatus.rejected, - batchId: 'claim-batch-1', + status: TransactionStatus.confirmed, + from: senderAddress, }); + mockPolymarketProvider.confirmClaim = jest.fn(); + controller.updateStateForTesting((state) => { - state.pendingClaims = { - [accountAddress]: 'claim-batch-1', + state.claimablePositions = { + [selectedAddress]: selectedClaimablePositions, + [senderAddress]: senderClaimablePositions, }; }); - // Act messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); - // Assert - expect(controller.state.pendingClaims[accountAddress]).toBeUndefined(); + expect(mockPolymarketProvider.confirmClaim).toHaveBeenCalledWith({ + positions: senderClaimablePositions, + signer: expect.objectContaining({ address: senderAddress }), + }); + expect(controller.state.claimablePositions[selectedAddress]).toEqual( + selectedClaimablePositions, + ); + expect(controller.state.claimablePositions[senderAddress]).toEqual([]); }); }); - it('clears withdraw transaction when withdraw transaction is confirmed', () => { - withController(({ controller, messenger }) => { + it('publishes event for predict withdraw transaction with failed status', () => { + withController(({ messenger }) => { + const transactionStatusChangedHandler = jest.fn(); const transactionMeta = createPredictTransactionMeta({ nestedType: TransactionType.predictWithdraw, - status: TransactionStatus.confirmed, + status: TransactionStatus.failed, }); - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - amount: 42, - chainId: 137, - transactionId: 'withdraw-1', - status: PredictWithdrawStatus.PENDING, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: accountAddress, - }; + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as any); + + expect(transactionStatusChangedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'withdraw', + status: 'failed', + senderAddress: accountAddress, + transactionId: 'tx-1', + }), + ); + }); + }); + + it('does not publish event for non-predict transactions', () => { + withController(({ messenger }) => { + const transactionStatusChangedHandler = jest.fn(); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.simpleSend, + status: TransactionStatus.confirmed, }); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); + messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); - expect(controller.state.withdrawTransaction).toBeNull(); + expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); }); }); - it('keeps withdraw transaction when withdraw transaction is approved', () => { + it('does not publish event for deposit with wrong batchId', () => { withController(({ controller, messenger }) => { + const transactionStatusChangedHandler = jest.fn(); const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictWithdraw, - status: TransactionStatus.approved, + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, + batchId: 'batch-not-pending', }); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); + controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - amount: 64, - chainId: 137, - transactionId: 'withdraw-2', - status: PredictWithdrawStatus.PENDING, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: accountAddress, + state.pendingDeposits = { + [accountAddress]: 'batch-expected', }; }); messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, - } as { transactionMeta: TransactionMeta }); + } as any); - expect(controller.state.withdrawTransaction).toEqual( - expect.objectContaining({ - transactionId: 'withdraw-2', + expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); + }); + }); + + it('does not publish event when nested transactions are missing', () => { + withController(({ messenger }) => { + const transactionStatusChangedHandler = jest.fn(); + const transactionMeta = { + ...createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.approved, }), + nestedTransactions: undefined, + }; + + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, ); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); + + expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); }); }); - it('does not refresh balance when transaction status is approved', () => { + it('does not publish event when transaction status cannot be mapped', () => { withController(({ controller, messenger }) => { + const transactionStatusChangedHandler = jest.fn(); const transactionMeta = createPredictTransactionMeta({ nestedType: TransactionType.predictClaim, - status: TransactionStatus.approved, + status: TransactionStatus.confirmed, }); - const getBalanceSpy = jest - .spyOn(controller, 'getBalance') - .mockResolvedValue(0); + transactionMeta.status = 'unapproved' as TransactionStatus; + + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); + + controller.updateStateForTesting((state) => { + state.pendingClaims = { + [accountAddress]: 'claim-batch-1', + }; + }); messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta, } as { transactionMeta: TransactionMeta }); - expect(getBalanceSpy).not.toHaveBeenCalled(); + expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); }); }); - it('continues publishing when balance refresh rejects for confirmed transaction', () => { - withController(({ controller, messenger }) => { + it('does not publish event for deposit when no pending state exists', () => { + withController(({ messenger }) => { const transactionStatusChangedHandler = jest.fn(); const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictClaim, - status: TransactionStatus.confirmed, + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.approved, + batchId: 'batch-1', }); - jest - .spyOn(controller, 'getBalance') - .mockRejectedValue(new Error('balance refresh failed')); - messenger.subscribe( 'PredictController:transactionStatusChanged', transactionStatusChangedHandler, ); - expect(() => - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as { transactionMeta: TransactionMeta }), - ).not.toThrow(); + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); - expect(transactionStatusChangedHandler).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'claim', - status: 'confirmed', - }), - ); + expect(transactionStatusChangedHandler).not.toHaveBeenCalled(); }); }); - it('publishes event even when side effects throw', () => { + it('publishes event for pending deposit when sender address is missing', () => { withController(({ controller, messenger }) => { const transactionStatusChangedHandler = jest.fn(); - const transactionMeta = createPredictTransactionMeta({ - nestedType: TransactionType.predictClaim, - status: TransactionStatus.confirmed, - }); + const fallbackAddress = '0xAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCd'; + const transactionMeta = { + ...createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.approved, + }), + txParams: { + to: '0x0000000000000000000000000000000000000001', + value: '0x0', + data: '0x', + }, + }; jest .spyOn( - controller as unknown as { - handleTransactionSideEffects: () => void; - }, - 'handleTransactionSideEffects', + controller as unknown as { getEvmAccountAddress: () => string }, + 'getEvmAccountAddress', ) - .mockImplementation(() => { - throw new Error('Side effects failed'); - }); + .mockReturnValue(fallbackAddress); messenger.subscribe( 'PredictController:transactionStatusChanged', transactionStatusChangedHandler, ); - expect(() => - messenger.publish('TransactionController:transactionStatusUpdated', { - transactionMeta, - } as any), - ).not.toThrow(); + controller.updateStateForTesting((state) => { + state.pendingDeposits = { + [accountAddress]: 'pending', + }; + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); expect(transactionStatusChangedHandler).toHaveBeenCalledWith( expect.objectContaining({ - type: 'claim', - status: 'confirmed', - senderAddress: accountAddress, - transactionId: 'tx-1', + type: 'deposit', + status: 'approved', + senderAddress: fallbackAddress.toLowerCase(), }), ); }); }); - it('returns undefined amount for deposit when metamaskPay values are not numeric', () => { - withController(({ controller }) => { - const getTransactionAmount = ( - controller as unknown as { - getTransactionAmount: (args: { - type: 'deposit' | 'claim' | 'withdraw'; - status: 'approved' | 'confirmed' | 'failed' | 'rejected'; - transactionMeta: TransactionMeta; - address: string; - }) => number | undefined; - } - ).getTransactionAmount.bind(controller); + it('clears pending deposit when deposit transaction is rejected', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.rejected, + batchId: 'batch-1', + }); - const amount = getTransactionAmount({ - type: 'deposit', - status: 'confirmed', - transactionMeta: { - ...createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.confirmed, - }), - metamaskPay: { - totalFiat: '$abc', - bridgeFeeFiat: '$1', - networkFeeFiat: '$1', - }, - }, - address: accountAddress, + controller.updateStateForTesting((state) => { + state.pendingDeposits = { + [accountAddress]: 'batch-1', + }; }); - expect(amount).toBeUndefined(); + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); + + expect(controller.state.pendingDeposits[accountAddress]).toBe( + undefined, + ); }); }); - it('returns undefined amount for confirmed withdraw when state and receiving are not numeric', () => { - withController(({ controller }) => { - const getTransactionAmount = ( - controller as unknown as { - getTransactionAmount: (args: { - type: 'deposit' | 'claim' | 'withdraw'; - status: 'approved' | 'confirmed' | 'failed' | 'rejected'; - transactionMeta: TransactionMeta; - address: string; - }) => number | undefined; - } - ).getTransactionAmount.bind(controller); + it('clears preview activeOrder when deposit-and-order transaction is rejected after switching back to balance', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.rejected, + batchId: 'batch-1', + }); - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - amount: Number.NaN, - chainId: 137, - transactionId: 'tx-1', - status: PredictWithdrawStatus.PENDING, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: accountAddress, - }; + setActiveOrderForTest(controller, { + transactionId: 'tx-1', + state: ActiveOrderState.PREVIEW, }); - const amount = getTransactionAmount({ - type: 'withdraw', - status: 'confirmed', + messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta: { - ...createPredictTransactionMeta({ - nestedType: TransactionType.predictWithdraw, - status: TransactionStatus.confirmed, - }), - assetsFiatValues: { - receiving: 'not-a-number', - }, + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], }, - address: accountAddress, - }); + } as { transactionMeta: TransactionMeta }); - expect(amount).toBeUndefined(); + expect(controller.state.activeBuyOrder).toBeNull(); }); }); - it('returns zero amount when deposit fees exceed total amount', () => { - withController(({ controller }) => { - const getTransactionAmount = ( - controller as unknown as { - getTransactionAmount: (args: { - type: 'deposit' | 'claim' | 'withdraw'; - status: 'approved' | 'confirmed' | 'failed' | 'rejected'; - transactionMeta: TransactionMeta; - address: string; - }) => number | undefined; - } - ).getTransactionAmount.bind(controller); + it('clears activeOrder when deposit-and-order transaction is rejected from preview while an external token is still selected', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.rejected, + batchId: 'batch-1', + }); - const amount = getTransactionAmount({ - type: 'deposit', - status: 'confirmed', + setActiveOrderForTest(controller, { + transactionId: 'tx-1', + state: ActiveOrderState.PREVIEW, + }); + controller.setSelectedPaymentToken({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta: { - ...createPredictTransactionMeta({ - nestedType: TransactionType.predictDeposit, - status: TransactionStatus.confirmed, - }), - metamaskPay: { - totalFiat: 50, - bridgeFeeFiat: 30, - networkFeeFiat: 30, - }, + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], }, - address: accountAddress, - }); + } as { transactionMeta: TransactionMeta }); - expect(amount).toBe(0); + expect(controller.state.activeBuyOrder).toBeNull(); + expect(controller.state.selectedPaymentToken).toBeNull(); }); }); - it('returns receiving amount for confirmed withdraw when state amount is missing', () => { - withController(({ controller }) => { - const getTransactionAmount = ( - controller as unknown as { - getTransactionAmount: (args: { - type: 'deposit' | 'claim' | 'withdraw'; - status: 'approved' | 'confirmed' | 'failed' | 'rejected'; - transactionMeta: TransactionMeta; - address: string; - }) => number | undefined; - } - ).getTransactionAmount.bind(controller); + it('clears activeOrder when deposit-and-order transaction is rejected outside preview', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.rejected, + batchId: 'batch-1', + }); - controller.updateStateForTesting((state) => { - state.withdrawTransaction = null; + setActiveOrderForTest(controller, { + transactionId: 'tx-1', + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, }); - const amount = getTransactionAmount({ - type: 'withdraw', - status: 'confirmed', + messenger.publish('TransactionController:transactionStatusUpdated', { transactionMeta: { - ...createPredictTransactionMeta({ - nestedType: TransactionType.predictWithdraw, - status: TransactionStatus.confirmed, - }), - assetsFiatValues: { - receiving: '77.25', - }, + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], }, - address: accountAddress, - }); + } as { transactionMeta: TransactionMeta }); - expect(amount).toBe(77.25); + expect(controller.state.activeBuyOrder).toBeNull(); }); }); - it('returns undefined amount for approved withdraw transaction', () => { - withController(({ controller }) => { - const getTransactionAmount = ( - controller as unknown as { - getTransactionAmount: (args: { - type: 'deposit' | 'claim' | 'withdraw'; - status: 'approved' | 'confirmed' | 'failed' | 'rejected'; - transactionMeta: TransactionMeta; - address: string; - }) => number | undefined; - } - ).getTransactionAmount.bind(controller); + it('clears pending claim when claim transaction is confirmed', () => { + withController(({ controller, messenger }) => { + // Arrange + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictClaim, + status: TransactionStatus.confirmed, + batchId: 'claim-batch-1', + }); - const amount = getTransactionAmount({ - type: 'withdraw', - status: 'approved', - transactionMeta: createPredictTransactionMeta({ - nestedType: TransactionType.predictWithdraw, - status: TransactionStatus.approved, - }), - address: accountAddress, + controller.updateStateForTesting((state) => { + state.pendingClaims = { + [accountAddress]: 'claim-batch-1', + }; }); - expect(amount).toBeUndefined(); + // Act + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); + + // Assert + expect(controller.state.pendingClaims[accountAddress]).toBeUndefined(); }); }); - it('maps transaction statuses to predict transaction event statuses', () => { - withController(({ controller }) => { - const mapStatus = ( - controller as any - ).mapTransactionStatusToPredictTransactionEventStatus.bind(controller); + it('clears pending claim when claim transaction is failed', () => { + withController(({ controller, messenger }) => { + // Arrange + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictClaim, + status: TransactionStatus.failed, + batchId: 'claim-batch-1', + }); - expect(mapStatus(TransactionStatus.approved)).toBe('approved'); - expect(mapStatus(TransactionStatus.submitted)).toBeNull(); - expect(mapStatus(TransactionStatus.confirmed)).toBe('confirmed'); - expect(mapStatus(TransactionStatus.failed)).toBe('failed'); - expect(mapStatus(TransactionStatus.rejected)).toBe('rejected'); - }); - }); + controller.updateStateForTesting((state) => { + state.pendingClaims = { + [accountAddress]: 'claim-batch-1', + }; + }); - it('maps transaction types to predict transaction event types', () => { - withController(({ controller }) => { - const mapType = ( - controller as any - ).mapTransactionTypeToPredictTransactionEventType.bind(controller); + // Act + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); - expect(mapType(TransactionType.predictDeposit)).toBe('deposit'); - expect(mapType(TransactionType.predictClaim)).toBe('claim'); - expect(mapType(TransactionType.predictWithdraw)).toBe('withdraw'); - expect(mapType(TransactionType.swap)).toBeNull(); + // Assert + expect(controller.state.pendingClaims[accountAddress]).toBeUndefined(); }); }); - }); - - describe('getAccountState', () => { - it('successfully retrieve account state', async () => { - // Given a valid account state - const mockAccountState = { - address: '0xProxyAddress' as `0x${string}`, - isDeployed: true, - hasAllowances: true, - balance: 100.5, - }; - - mockPolymarketProvider.getAccountState.mockResolvedValue( - mockAccountState, - ); - - await withController(async ({ controller }) => { - // When calling getAccountState - const result = await controller.getAccountState({}); - // Then it should return the account state - expect(result).toEqual(mockAccountState); + it('clears pending claim when claim transaction is rejected', () => { + withController(({ controller, messenger }) => { + // Arrange + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictClaim, + status: TransactionStatus.rejected, + batchId: 'claim-batch-1', + }); - // And provider should be called with correct owner address - expect(mockPolymarketProvider.getAccountState).toHaveBeenCalledWith({ - ownerAddress: '0x1234567890123456789012345678901234567890', + controller.updateStateForTesting((state) => { + state.pendingClaims = { + [accountAddress]: 'claim-batch-1', + }; }); - }); - }); - it('throws provider errors when account state lookup fails', async () => { - mockPolymarketProvider.getAccountState.mockRejectedValue( - new Error('account state unavailable'), - ); + // Act + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); - await withController(async ({ controller }) => { - await expect(controller.getAccountState({})).rejects.toThrow( - 'account state unavailable', - ); + // Assert + expect(controller.state.pendingClaims[accountAddress]).toBeUndefined(); }); }); - }); - describe('getBalance', () => { - it('get balance successfully with default address', async () => { - // Given - const mockBalance = 1000; - mockPolymarketProvider.getBalance.mockResolvedValue(mockBalance); - - await withController(async ({ controller }) => { - // When calling getBalance - const result = await controller.getBalance({}); + it('clears withdraw transaction when withdraw transaction is confirmed', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictWithdraw, + status: TransactionStatus.confirmed, + }); - // Then it should return the balance - expect(result).toBe(mockBalance); - - // And provider should be called with default address - expect(mockPolymarketProvider.getBalance).toHaveBeenCalledWith({ - address: '0x1234567890123456789012345678901234567890', - }); - }); - }); - - it('get balance successfully with custom address', async () => { - // Given - const mockBalance = 500; - mockPolymarketProvider.getBalance.mockResolvedValue(mockBalance); - - await withController(async ({ controller }) => { - // When calling getBalance with custom address - const result = await controller.getBalance({ - address: '0x9876543210987654321098765432109876543210', - }); - - // Then it should return the balance - expect(result).toBe(mockBalance); - - // And provider should be called with custom address - expect(mockPolymarketProvider.getBalance).toHaveBeenCalledWith({ - address: '0x9876543210987654321098765432109876543210', + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + amount: 42, + chainId: 137, + transactionId: 'withdraw-1', + status: PredictWithdrawStatus.PENDING, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: accountAddress, + }; }); - }); - }); - it('handle error when getBalance throws', async () => { - // Given - mockPolymarketProvider.getBalance.mockRejectedValue( - new Error('Balance fetch failed'), - ); + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); - await withController(async ({ controller }) => { - // When calling getBalance - // Then it should throw an error - await expect(controller.getBalance({})).rejects.toThrow( - 'Balance fetch failed', - ); + expect(controller.state.withdrawTransaction).toBeNull(); }); }); - }); - - describe('previewOrder', () => { - it('previews order successfully', async () => { - const mockOrderPreview = createMockOrderPreview({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - }); - mockPolymarketProvider.previewOrder.mockResolvedValue(mockOrderPreview); + it('keeps withdraw transaction when withdraw transaction is approved', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictWithdraw, + status: TransactionStatus.approved, + }); - await withController(async ({ controller }) => { - const result = await controller.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 100, + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + amount: 64, + chainId: 137, + transactionId: 'withdraw-2', + status: PredictWithdrawStatus.PENDING, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: accountAddress, + }; }); - expect(result).toEqual(mockOrderPreview); - expect(mockPolymarketProvider.previewOrder).toHaveBeenCalledWith( + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); + + expect(controller.state.withdrawTransaction).toEqual( expect.objectContaining({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 100, - signer: expect.objectContaining({ - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: expect.any(Function), - signPersonalMessage: expect.any(Function), - }), + transactionId: 'withdraw-2', }), ); - - const signer = mockPolymarketProvider.previewOrder.mock.calls[0][0] - .signer as { - signTypedMessage: ( - params: unknown, - version: unknown, - ) => Promise; - signPersonalMessage: (params: unknown) => Promise; - }; - await signer.signTypedMessage({} as never, 'V4' as never); - await signer.signPersonalMessage({} as never); }); }); - it('handles preview errors', async () => { - mockPolymarketProvider.previewOrder.mockRejectedValue( - new Error('Preview failed'), - ); + it('does not refresh balance when transaction status is approved', () => { + withController(({ controller, messenger }) => { + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictClaim, + status: TransactionStatus.approved, + }); - await withController(async ({ controller }) => { - await expect( - controller.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 100, - }), - ).rejects.toThrow('Preview failed'); - }); - }); + const getBalanceSpy = jest + .spyOn(controller, 'getBalance') + .mockResolvedValue(0); - it('handles synchronous preview errors thrown by provider', async () => { - mockPolymarketProvider.previewOrder.mockImplementation(() => { - throw new Error('Preview failed synchronously'); - }); + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }); - await withController(async ({ controller }) => { - await expect( - controller.previewOrder({ - marketId: 'market-1', - outcomeId: 'outcome-1', - outcomeTokenId: 'token-1', - side: Side.BUY, - size: 100, - }), - ).rejects.toThrow('Preview failed synchronously'); + expect(getBalanceSpy).not.toHaveBeenCalled(); }); }); - }); - - describe('prepareWithdraw', () => { - const mockWithdrawResponse = { - chainId: '0x89' as `0x${string}`, - transaction: { - params: { - to: '0xWithdrawAddress' as `0x${string}`, - data: '0xwithdrawdata' as `0x${string}`, - }, - }, - predictAddress: '0xPredictAddress' as `0x${string}`, - }; - - it('successfully prepare withdraw transaction', async () => { - const mockBatchId = 'withdraw-batch-1'; - - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: mockBatchId, - }); - await withController(async ({ controller }) => { - const result = await controller.prepareWithdraw({}); - - expect(result.success).toBe(true); - expect(result.response).toBe(mockBatchId); - expect(controller.state.withdrawTransaction).toEqual({ - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredictAddress', - transactionId: mockBatchId, - amount: 0, + it('continues publishing when balance refresh rejects for confirmed transaction', () => { + withController(({ controller, messenger }) => { + const transactionStatusChangedHandler = jest.fn(); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictClaim, + status: TransactionStatus.confirmed, }); - }); - }); - it('updates state with lastError when prepare withdraw fails', async () => { - mockPolymarketProvider.prepareWithdraw.mockRejectedValue( - new Error('Provider error'), - ); + jest + .spyOn(controller, 'getBalance') + .mockRejectedValue(new Error('balance refresh failed')); - await withController(async ({ controller }) => { - await expect(controller.prepareWithdraw({})).rejects.toThrow( - 'Provider error', + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, ); - expect(controller.state.lastError).toBe('Provider error'); - expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0); - expect(controller.state.withdrawTransaction).toBeNull(); - }); - }); - - it('logs error details when prepare withdraw fails', async () => { - mockPolymarketProvider.prepareWithdraw.mockRejectedValue( - new Error('Network error'), - ); - - await withController(async ({ controller }) => { - await expect(controller.prepareWithdraw({})).rejects.toThrow( - 'Network error', - ); + expect(() => + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as { transactionMeta: TransactionMeta }), + ).not.toThrow(); - expect(DevLogger.log).toHaveBeenCalledWith( - 'PredictController: Prepare withdraw failed', + expect(transactionStatusChangedHandler).toHaveBeenCalledWith( expect.objectContaining({ - error: 'Network error', - timestamp: expect.any(String), + type: 'claim', + status: 'confirmed', }), ); }); }); - it('call provider prepareWithdraw with correct signer', async () => { - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: 'batch-test', - }); + it('publishes event even when side effects throw', () => { + withController(({ controller, messenger }) => { + const transactionStatusChangedHandler = jest.fn(); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictClaim, + status: TransactionStatus.confirmed, + }); - await withController(async ({ controller }) => { - await controller.prepareWithdraw({}); - - expect(mockPolymarketProvider.prepareWithdraw).toHaveBeenCalledWith({ - signer: expect.objectContaining({ - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: expect.any(Function), - signPersonalMessage: expect.any(Function), - }), - }); - }); - }); + jest + .spyOn( + controller as unknown as { + handleTransactionSideEffects: () => void; + }, + 'handleTransactionSideEffects', + ) + .mockImplementation(() => { + throw new Error('Side effects failed'); + }); - it('call addTransactionBatch with correct parameters', async () => { - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: 'batch-tx', - }); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + transactionStatusChangedHandler, + ); - await withController(async ({ controller }) => { - await controller.prepareWithdraw({}); + expect(() => + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta, + } as any), + ).not.toThrow(); - expect(addTransactionBatch).toHaveBeenCalledWith( + expect(transactionStatusChangedHandler).toHaveBeenCalledWith( expect.objectContaining({ - from: '0x1234567890123456789012345678901234567890', - origin: 'metamask', - networkClientId: expect.any(String), - disableHook: true, - disableSequential: true, - requireApproval: true, - transactions: [mockWithdrawResponse.transaction], + type: 'claim', + status: 'confirmed', + senderAddress: accountAddress, + transactionId: 'tx-1', }), ); }); }); - it('update transaction ID when batch ID is returned', async () => { - const mockBatchId = 'tx-batch-update'; + it('returns undefined amount for deposit when metamaskPay values are not numeric', () => { + withController(({ controller }) => { + const getTransactionAmount = ( + controller as unknown as { + getTransactionAmount: (args: { + type: 'deposit' | 'claim' | 'withdraw'; + status: 'approved' | 'confirmed' | 'failed' | 'rejected'; + transactionMeta: TransactionMeta; + address: string; + }) => number | undefined; + } + ).getTransactionAmount.bind(controller); - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: mockBatchId, + const amount = getTransactionAmount({ + type: 'deposit', + status: 'confirmed', + transactionMeta: { + ...createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, + }), + metamaskPay: { + totalFiat: '$abc', + bridgeFeeFiat: '$1', + networkFeeFiat: '$1', + }, + }, + address: accountAddress, + }); + + expect(amount).toBeUndefined(); }); + }); - await withController(async ({ controller }) => { - await controller.prepareWithdraw({}); + it('returns undefined amount for confirmed withdraw when state and receiving are not numeric', () => { + withController(({ controller }) => { + const getTransactionAmount = ( + controller as unknown as { + getTransactionAmount: (args: { + type: 'deposit' | 'claim' | 'withdraw'; + status: 'approved' | 'confirmed' | 'failed' | 'rejected'; + transactionMeta: TransactionMeta; + address: string; + }) => number | undefined; + } + ).getTransactionAmount.bind(controller); - expect(controller.state.withdrawTransaction?.transactionId).toBe( - mockBatchId, - ); + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + amount: Number.NaN, + chainId: 137, + transactionId: 'tx-1', + status: PredictWithdrawStatus.PENDING, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: accountAddress, + }; + }); + + const amount = getTransactionAmount({ + type: 'withdraw', + status: 'confirmed', + transactionMeta: { + ...createPredictTransactionMeta({ + nestedType: TransactionType.predictWithdraw, + status: TransactionStatus.confirmed, + }), + assetsFiatValues: { + receiving: 'not-a-number', + }, + }, + address: accountAddress, + }); + + expect(amount).toBeUndefined(); }); }); - it('returns error when addTransactionBatch fails', async () => { - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - (addTransactionBatch as jest.Mock).mockRejectedValue( - new Error('Transaction batch submission failed'), - ); + it('returns zero amount when deposit fees exceed total amount', () => { + withController(({ controller }) => { + const getTransactionAmount = ( + controller as unknown as { + getTransactionAmount: (args: { + type: 'deposit' | 'claim' | 'withdraw'; + status: 'approved' | 'confirmed' | 'failed' | 'rejected'; + transactionMeta: TransactionMeta; + address: string; + }) => number | undefined; + } + ).getTransactionAmount.bind(controller); - await withController(async ({ controller }) => { - await expect(controller.prepareWithdraw({})).rejects.toThrow( - 'Transaction batch submission failed', - ); + const amount = getTransactionAmount({ + type: 'deposit', + status: 'confirmed', + transactionMeta: { + ...createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, + }), + metamaskPay: { + totalFiat: 50, + bridgeFeeFiat: 30, + networkFeeFiat: 30, + }, + }, + address: accountAddress, + }); - expect(controller.state.lastError).toBe( - 'Transaction batch submission failed', - ); + expect(amount).toBe(0); }); }); - it('store withdraw transaction state before creating batch', async () => { - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: 'batch-123', - }); + it('returns receiving amount for confirmed withdraw when state amount is missing', () => { + withController(({ controller }) => { + const getTransactionAmount = ( + controller as unknown as { + getTransactionAmount: (args: { + type: 'deposit' | 'claim' | 'withdraw'; + status: 'approved' | 'confirmed' | 'failed' | 'rejected'; + transactionMeta: TransactionMeta; + address: string; + }) => number | undefined; + } + ).getTransactionAmount.bind(controller); - await withController(async ({ controller }) => { - expect(controller.state.withdrawTransaction).toBeNull(); + controller.updateStateForTesting((state) => { + state.withdrawTransaction = null; + }); - await controller.prepareWithdraw({}); + const amount = getTransactionAmount({ + type: 'withdraw', + status: 'confirmed', + transactionMeta: { + ...createPredictTransactionMeta({ + nestedType: TransactionType.predictWithdraw, + status: TransactionStatus.confirmed, + }), + assetsFiatValues: { + receiving: '77.25', + }, + }, + address: accountAddress, + }); - expect(controller.state.withdrawTransaction).toBeDefined(); - expect(controller.state.withdrawTransaction?.status).toBe( - PredictWithdrawStatus.IDLE, - ); - expect(controller.state.withdrawTransaction?.chainId).toBe(137); + expect(amount).toBe(77.25); }); }); - it('convert hex chainId to number in state', async () => { - const customChainId = '0x1' as `0x${string}`; - mockPolymarketProvider.prepareWithdraw.mockResolvedValue({ - ...mockWithdrawResponse, - chainId: customChainId, - }); - (addTransactionBatch as jest.Mock).mockResolvedValue({ - batchId: 'batch-chain', - }); + it('returns undefined amount for approved withdraw transaction', () => { + withController(({ controller }) => { + const getTransactionAmount = ( + controller as unknown as { + getTransactionAmount: (args: { + type: 'deposit' | 'claim' | 'withdraw'; + status: 'approved' | 'confirmed' | 'failed' | 'rejected'; + transactionMeta: TransactionMeta; + address: string; + }) => number | undefined; + } + ).getTransactionAmount.bind(controller); - await withController(async ({ controller }) => { - await controller.prepareWithdraw({}); + const amount = getTransactionAmount({ + type: 'withdraw', + status: 'approved', + transactionMeta: createPredictTransactionMeta({ + nestedType: TransactionType.predictWithdraw, + status: TransactionStatus.approved, + }), + address: accountAddress, + }); - expect(controller.state.withdrawTransaction?.chainId).toBe(1); + expect(amount).toBeUndefined(); }); }); - it('return success when user denies transaction signature', async () => { - await withController(async ({ controller }) => { - mockPolymarketProvider.prepareWithdraw.mockRejectedValue( - new Error('User denied transaction signature'), - ); - - const result = await controller.prepareWithdraw({}); - - expect(result.success).toBe(true); - expect(result.response).toBe('User cancelled transaction'); - }); - }); - - it('return success when user denial error is wrapped in message', async () => { - await withController(async ({ controller }) => { - (addTransactionBatch as jest.Mock).mockRejectedValue( - new Error( - 'Transaction failed: User denied transaction signature - action cancelled', - ), - ); - mockPolymarketProvider.prepareWithdraw.mockResolvedValue( - mockWithdrawResponse, - ); - - const result = await controller.prepareWithdraw({}); + it('maps transaction statuses to predict transaction event statuses', () => { + withController(({ controller }) => { + const mapStatus = ( + controller as any + ).mapTransactionStatusToPredictTransactionEventStatus.bind(controller); - expect(result.success).toBe(true); - expect(result.response).toBe('User cancelled transaction'); + expect(mapStatus(TransactionStatus.approved)).toBe('approved'); + expect(mapStatus(TransactionStatus.submitted)).toBeNull(); + expect(mapStatus(TransactionStatus.confirmed)).toBe('confirmed'); + expect(mapStatus(TransactionStatus.failed)).toBe('failed'); + expect(mapStatus(TransactionStatus.rejected)).toBe('rejected'); }); }); - it('not update state when user cancels transaction', async () => { - await withController(async ({ controller }) => { - mockPolymarketProvider.prepareWithdraw.mockRejectedValue( - new Error('User denied transaction signature'), - ); - - await controller.prepareWithdraw({}); + it('maps transaction types to predict transaction event types', () => { + withController(({ controller }) => { + const mapType = ( + controller as any + ).mapTransactionTypeToPredictTransactionEventType.bind(controller); - expect(controller.state.lastError).toBeNull(); - expect(controller.state.withdrawTransaction).toBeNull(); + expect(mapType(TransactionType.predictDeposit)).toBe('deposit'); + expect(mapType(TransactionType.predictClaim)).toBe('claim'); + expect(mapType(TransactionType.predictWithdraw)).toBe('withdraw'); + expect(mapType(TransactionType.swap)).toBeNull(); }); }); }); - describe('beforeSign', () => { - const mockTransactionMeta = { - id: 'tx-1', - txParams: { - from: '0x1234567890123456789012345678901234567890', - to: '0xTarget', - data: '0xdata', - value: '0x0', - }, - nestedTransactions: [ - { - id: 'nested-1', - type: 'predictWithdraw' as const, - data: '0xoriginaldata' as `0x${string}`, - }, - ], - }; + describe('getAccountState', () => { + it('successfully retrieve account state', async () => { + // Given a valid account state + const mockAccountState = { + address: '0xProxyAddress' as `0x${string}`, + isDeployed: true, + hasAllowances: true, + balance: 100.5, + }; - beforeEach(() => { - mockPolymarketProvider.signWithdraw = jest.fn(); - }); + mockPolymarketProvider.getAccountState.mockResolvedValue( + mockAccountState, + ); - it('return undefined when no withdraw transaction in state', async () => { await withController(async ({ controller }) => { - const result = await controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, - }); + // When calling getAccountState + const result = await controller.getAccountState({}); - expect(result).toBeUndefined(); - }); - }); + // Then it should return the account state + expect(result).toEqual(mockAccountState); - it('return undefined when transaction is not a withdraw transaction', async () => { - await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; + // And provider should be called with correct owner address + expect(mockPolymarketProvider.getAccountState).toHaveBeenCalledWith({ + ownerAddress: '0x1234567890123456789012345678901234567890', }); + }); + }); - const nonWithdrawTx = { - ...mockTransactionMeta, - nestedTransactions: [ - { - id: 'nested-1', - type: 'otherType' as const, - data: '0xdata' as `0x${string}`, - }, - ], - }; - - const result = await controller.beforeSign({ - transactionMeta: nonWithdrawTx as any, - }); + it('throws provider errors when account state lookup fails', async () => { + mockPolymarketProvider.getAccountState.mockRejectedValue( + new Error('account state unavailable'), + ); - expect(result).toBeUndefined(); + await withController(async ({ controller }) => { + await expect(controller.getAccountState({})).rejects.toThrow( + 'account state unavailable', + ); }); }); + }); + + describe('getBalance', () => { + it('get balance successfully with default address', async () => { + // Given + const mockBalance = 1000; + mockPolymarketProvider.getBalance.mockResolvedValue(mockBalance); - it('return undefined when provider does not support signWithdraw', async () => { await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; - }); + // When calling getBalance + const result = await controller.getBalance({}); - delete (mockPolymarketProvider as any).signWithdraw; + // Then it should return the balance + expect(result).toBe(mockBalance); - const result = await controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, + // And provider should be called with default address + expect(mockPolymarketProvider.getBalance).toHaveBeenCalledWith({ + address: '0x1234567890123456789012345678901234567890', }); - - expect(result).toBeUndefined(); }); }); - it('call prepareWithdrawConfirmation with correct parameters', async () => { - mockPolymarketProvider.signWithdraw?.mockResolvedValue({ - callData: '0xnewdata' as `0x${string}`, - amount: 100, - }); + it('get balance successfully with custom address', async () => { + // Given + const mockBalance = 500; + mockPolymarketProvider.getBalance.mockResolvedValue(mockBalance); await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; + // When calling getBalance with custom address + const result = await controller.getBalance({ + address: '0x9876543210987654321098765432109876543210', }); - await controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, - }); + // Then it should return the balance + expect(result).toBe(mockBalance); - expect(mockPolymarketProvider.signWithdraw).toHaveBeenCalledWith({ - callData: '0xoriginaldata', - signer: expect.objectContaining({ - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: expect.any(Function), - signPersonalMessage: expect.any(Function), - }), + // And provider should be called with custom address + expect(mockPolymarketProvider.getBalance).toHaveBeenCalledWith({ + address: '0x9876543210987654321098765432109876543210', }); }); }); - it('update withdraw transaction amount and status', async () => { - mockPolymarketProvider.signWithdraw?.mockResolvedValue({ - callData: '0xnewdata' as `0x${string}`, - amount: 250.5, - }); + it('handle error when getBalance throws', async () => { + // Given + mockPolymarketProvider.getBalance.mockRejectedValue( + new Error('Balance fetch failed'), + ); await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; - }); - - await controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, - }); - - expect(controller.state.withdrawTransaction?.amount).toBe(250.5); - expect(controller.state.withdrawTransaction?.status).toBe( - PredictWithdrawStatus.PENDING, + // When calling getBalance + // Then it should throw an error + await expect(controller.getBalance({})).rejects.toThrow( + 'Balance fetch failed', ); }); }); + }); - it('return updateTransaction function that modifies transaction data', async () => { - mockPolymarketProvider.signWithdraw?.mockResolvedValue({ - callData: '0xmodifieddata' as `0x${string}`, - amount: 100, + describe('previewOrder', () => { + it('previews order successfully', async () => { + const mockOrderPreview = createMockOrderPreview({ + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + side: Side.BUY, }); + mockPolymarketProvider.previewOrder.mockResolvedValue(mockOrderPreview); + await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredictAddress' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; + const result = await controller.previewOrder({ + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + side: Side.BUY, + size: 100, }); - const result = await controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, + expect(result).toEqual(mockOrderPreview); + expect(mockPolymarketProvider.previewOrder).toHaveBeenCalledWith( + expect.objectContaining({ + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + side: Side.BUY, + size: 100, + signer: expect.objectContaining({ + address: '0x1234567890123456789012345678901234567890', + signTypedMessage: expect.any(Function), + signPersonalMessage: expect.any(Function), + }), + }), + ); + + const signer = mockPolymarketProvider.previewOrder.mock.calls[0][0] + .signer as { + signTypedMessage: ( + params: unknown, + version: unknown, + ) => Promise; + signPersonalMessage: (params: unknown) => Promise; + }; + await signer.signTypedMessage({} as never, 'V4' as never); + await signer.signPersonalMessage({} as never); + }); + }); + + it('handles preview errors', async () => { + mockPolymarketProvider.previewOrder.mockRejectedValue( + new Error('Preview failed'), + ); + + await withController(async ({ controller }) => { + await expect( + controller.previewOrder({ + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + side: Side.BUY, + size: 100, + }), + ).rejects.toThrow('Preview failed'); + }); + }); + + it('handles synchronous preview errors thrown by provider', async () => { + mockPolymarketProvider.previewOrder.mockImplementation(() => { + throw new Error('Preview failed synchronously'); + }); + + await withController(async ({ controller }) => { + await expect( + controller.previewOrder({ + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + side: Side.BUY, + size: 100, + }), + ).rejects.toThrow('Preview failed synchronously'); + }); + }); + }); + + describe('prepareWithdraw', () => { + const mockWithdrawResponse = { + chainId: '0x89' as `0x${string}`, + transaction: { + params: { + to: '0xWithdrawAddress' as `0x${string}`, + data: '0xwithdrawdata' as `0x${string}`, + }, + }, + predictAddress: '0xPredictAddress' as `0x${string}`, + }; + + it('successfully prepare withdraw transaction', async () => { + const mockBatchId = 'withdraw-batch-1'; + + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: mockBatchId, + }); + + await withController(async ({ controller }) => { + const result = await controller.prepareWithdraw({}); + + expect(result.success).toBe(true); + expect(result.response).toBe(mockBatchId); + expect(controller.state.withdrawTransaction).toEqual({ + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredictAddress', + transactionId: mockBatchId, + amount: 0, }); + }); + }); - expect(result).toBeDefined(); - expect(result?.updateTransaction).toBeDefined(); + it('updates state with lastError when prepare withdraw fails', async () => { + mockPolymarketProvider.prepareWithdraw.mockRejectedValue( + new Error('Provider error'), + ); - const testTransaction: { - txParams: { - from: string; - to: string; - data: string; - gas?: string; - gasLimit?: string; - }; - assetsFiatValues?: { - receiving?: string; - sending?: string; - }; - } = { - txParams: { - from: '0xFrom', - to: '0xOldTarget', - data: '0xolddata', + await withController(async ({ controller }) => { + await expect(controller.prepareWithdraw({})).rejects.toThrow( + 'Provider error', + ); + + expect(controller.state.lastError).toBe('Provider error'); + expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0); + expect(controller.state.withdrawTransaction).toBeNull(); + }); + }); + + it('logs error details when prepare withdraw fails', async () => { + mockPolymarketProvider.prepareWithdraw.mockRejectedValue( + new Error('Network error'), + ); + + await withController(async ({ controller }) => { + await expect(controller.prepareWithdraw({})).rejects.toThrow( + 'Network error', + ); + + expect(DevLogger.log).toHaveBeenCalledWith( + 'PredictController: Prepare withdraw failed', + expect.objectContaining({ + error: 'Network error', + timestamp: expect.any(String), + }), + ); + }); + }); + + it('call provider prepareWithdraw with correct signer', async () => { + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'batch-test', + }); + + await withController(async ({ controller }) => { + await controller.prepareWithdraw({}); + + expect(mockPolymarketProvider.prepareWithdraw).toHaveBeenCalledWith({ + signer: expect.objectContaining({ + address: '0x1234567890123456789012345678901234567890', + signTypedMessage: expect.any(Function), + signPersonalMessage: expect.any(Function), + }), + }); + }); + }); + + it('call addTransactionBatch with correct parameters', async () => { + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'batch-tx', + }); + + await withController(async ({ controller }) => { + await controller.prepareWithdraw({}); + + expect(addTransactionBatch).toHaveBeenCalledWith( + expect.objectContaining({ + from: '0x1234567890123456789012345678901234567890', + origin: 'metamask', + networkClientId: expect.any(String), + disableHook: true, + disableSequential: true, + requireApproval: true, + transactions: [mockWithdrawResponse.transaction], + }), + ); + }); + }); + + it('update transaction ID when batch ID is returned', async () => { + const mockBatchId = 'tx-batch-update'; + + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: mockBatchId, + }); + + await withController(async ({ controller }) => { + await controller.prepareWithdraw({}); + + expect(controller.state.withdrawTransaction?.transactionId).toBe( + mockBatchId, + ); + }); + }); + + it('returns error when addTransactionBatch fails', async () => { + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + (addTransactionBatch as jest.Mock).mockRejectedValue( + new Error('Transaction batch submission failed'), + ); + + await withController(async ({ controller }) => { + await expect(controller.prepareWithdraw({})).rejects.toThrow( + 'Transaction batch submission failed', + ); + + expect(controller.state.lastError).toBe( + 'Transaction batch submission failed', + ); + }); + }); + + it('store withdraw transaction state before creating batch', async () => { + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'batch-123', + }); + + await withController(async ({ controller }) => { + expect(controller.state.withdrawTransaction).toBeNull(); + + await controller.prepareWithdraw({}); + + expect(controller.state.withdrawTransaction).toBeDefined(); + expect(controller.state.withdrawTransaction?.status).toBe( + PredictWithdrawStatus.IDLE, + ); + expect(controller.state.withdrawTransaction?.chainId).toBe(137); + }); + }); + + it('convert hex chainId to number in state', async () => { + const customChainId = '0x1' as `0x${string}`; + mockPolymarketProvider.prepareWithdraw.mockResolvedValue({ + ...mockWithdrawResponse, + chainId: customChainId, + }); + (addTransactionBatch as jest.Mock).mockResolvedValue({ + batchId: 'batch-chain', + }); + + await withController(async ({ controller }) => { + await controller.prepareWithdraw({}); + + expect(controller.state.withdrawTransaction?.chainId).toBe(1); + }); + }); + + it('return success when user denies transaction signature', async () => { + await withController(async ({ controller }) => { + mockPolymarketProvider.prepareWithdraw.mockRejectedValue( + new Error('User denied transaction signature'), + ); + + const result = await controller.prepareWithdraw({}); + + expect(result.success).toBe(true); + expect(result.response).toBe('User cancelled transaction'); + }); + }); + + it('return success when user denial error is wrapped in message', async () => { + await withController(async ({ controller }) => { + (addTransactionBatch as jest.Mock).mockRejectedValue( + new Error( + 'Transaction failed: User denied transaction signature - action cancelled', + ), + ); + mockPolymarketProvider.prepareWithdraw.mockResolvedValue( + mockWithdrawResponse, + ); + + const result = await controller.prepareWithdraw({}); + + expect(result.success).toBe(true); + expect(result.response).toBe('User cancelled transaction'); + }); + }); + + it('not update state when user cancels transaction', async () => { + await withController(async ({ controller }) => { + mockPolymarketProvider.prepareWithdraw.mockRejectedValue( + new Error('User denied transaction signature'), + ); + + await controller.prepareWithdraw({}); + + expect(controller.state.lastError).toBeNull(); + expect(controller.state.withdrawTransaction).toBeNull(); + }); + }); + }); + + describe('beforeSign', () => { + const mockTransactionMeta = { + id: 'tx-1', + txParams: { + from: '0x1234567890123456789012345678901234567890', + to: '0xTarget', + data: '0xdata', + value: '0x0', + }, + nestedTransactions: [ + { + id: 'nested-1', + type: 'predictWithdraw' as const, + data: '0xoriginaldata' as `0x${string}`, + }, + ], + }; + + beforeEach(() => { + mockPolymarketProvider.signWithdraw = jest.fn(); + }); + + it('return undefined when no withdraw transaction in state', async () => { + await withController(async ({ controller }) => { + const result = await controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }); + + expect(result).toBeUndefined(); + }); + }); + + it('return undefined when transaction is not a withdraw transaction', async () => { + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + const nonWithdrawTx = { + ...mockTransactionMeta, + nestedTransactions: [ + { + id: 'nested-1', + type: 'otherType' as const, + data: '0xdata' as `0x${string}`, + }, + ], + }; + + const result = await controller.beforeSign({ + transactionMeta: nonWithdrawTx as any, + }); + + expect(result).toBeUndefined(); + }); + }); + + it('return undefined when provider does not support signWithdraw', async () => { + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + delete (mockPolymarketProvider as any).signWithdraw; + + const result = await controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }); + + expect(result).toBeUndefined(); + }); + }); + + it('call prepareWithdrawConfirmation with correct parameters', async () => { + mockPolymarketProvider.signWithdraw?.mockResolvedValue({ + callData: '0xnewdata' as `0x${string}`, + amount: 100, + }); + + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + await controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }); + + expect(mockPolymarketProvider.signWithdraw).toHaveBeenCalledWith({ + callData: '0xoriginaldata', + signer: expect.objectContaining({ + address: '0x1234567890123456789012345678901234567890', + signTypedMessage: expect.any(Function), + signPersonalMessage: expect.any(Function), + }), + }); + }); + }); + + it('update withdraw transaction amount and status', async () => { + mockPolymarketProvider.signWithdraw?.mockResolvedValue({ + callData: '0xnewdata' as `0x${string}`, + amount: 250.5, + }); + + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + await controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }); + + expect(controller.state.withdrawTransaction?.amount).toBe(250.5); + expect(controller.state.withdrawTransaction?.status).toBe( + PredictWithdrawStatus.PENDING, + ); + }); + }); + + it('return updateTransaction function that modifies transaction data', async () => { + mockPolymarketProvider.signWithdraw?.mockResolvedValue({ + callData: '0xmodifieddata' as `0x${string}`, + amount: 100, + }); + + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredictAddress' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + const result = await controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }); + + expect(result).toBeDefined(); + expect(result?.updateTransaction).toBeDefined(); + + const testTransaction: { + txParams: { + from: string; + to: string; + data: string; + gas?: string; + gasLimit?: string; + }; + assetsFiatValues?: { + receiving?: string; + sending?: string; + }; + } = { + txParams: { + from: '0xFrom', + to: '0xOldTarget', + data: '0xolddata', + }, + }; + + result?.updateTransaction?.(testTransaction as any); + + expect(testTransaction.txParams.data).toBe('0xmodifieddata'); + expect(testTransaction.txParams.to).toBe('0xPredictAddress'); + expect(testTransaction.assetsFiatValues).toEqual({ + receiving: '100', + }); + }); + }); + + it('throw error when prepareWithdrawConfirmation fails', async () => { + mockPolymarketProvider.signWithdraw?.mockRejectedValue( + new Error('Confirmation preparation failed'), + ); + + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + await expect( + controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }), + ).rejects.toThrow('Confirmation preparation failed'); + }); + }); + + it('sets withdraw transaction status to error when gas estimation fails', async () => { + mockPolymarketProvider.signWithdraw?.mockResolvedValue({ + callData: '0xnewdata' as `0x${string}`, + amount: 100, + }); + + await withController( + async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + const result = await controller.beforeSign({ + transactionMeta: mockTransactionMeta as any, + }); + + expect(result).toBeUndefined(); + expect(controller.state.withdrawTransaction?.status).toBe( + PredictWithdrawStatus.ERROR, + ); + }, + { + mocks: { + estimateGas: jest + .fn() + .mockRejectedValue(new Error('Gas estimation failed')), + }, + }, + ); + }); + + it('return undefined when nestedTransactions is undefined', async () => { + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + const txWithoutNested = { + ...mockTransactionMeta, + nestedTransactions: undefined, + }; + + const result = await controller.beforeSign({ + transactionMeta: txWithoutNested as any, + }); + + expect(result).toBeUndefined(); + }); + }); + + it('return undefined when nestedTransactions is empty array', async () => { + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-1', + amount: 0, + }; + }); + + const txWithEmptyNested = { + ...mockTransactionMeta, + nestedTransactions: [], + }; + + const result = await controller.beforeSign({ + transactionMeta: txWithEmptyNested as any, + }); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe('clearWithdrawTransaction', () => { + it('clear withdraw transaction from state', () => { + withController(({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-123', + amount: 100, + }; + }); + + expect(controller.state.withdrawTransaction).toEqual({ + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict', + transactionId: 'tx-123', + amount: 100, + }); + + controller.clearWithdrawTransaction(); + + expect(controller.state.withdrawTransaction).toBeNull(); + }); + }); + + it('handle clearing when withdraw transaction is already null', () => { + withController(({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = null; + }); + + expect(() => controller.clearWithdrawTransaction()).not.toThrow(); + + expect(controller.state.withdrawTransaction).toBeNull(); + }); + }); + + it('clear withdraw transaction with pending status', () => { + withController(({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.PENDING, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-456', + amount: 500, + }; + }); + + controller.clearWithdrawTransaction(); + + expect(controller.state.withdrawTransaction).toBeNull(); + }); + }); + + it('clear withdraw transaction does not affect other state properties', () => { + withController(({ controller }) => { + controller.updateStateForTesting((state) => { + state.withdrawTransaction = { + chainId: 137, + status: PredictWithdrawStatus.IDLE, + providerId: POLYMARKET_PROVIDER_ID, + predictAddress: '0xPredict' as `0x${string}`, + transactionId: 'tx-789', + amount: 200, + }; + state.eligibility = { eligible: true, country: 'PT' }; + state.lastError = 'Some error'; + }); + + const originalEligibility = controller.state.eligibility; + const originalLastError = controller.state.lastError; + + controller.clearWithdrawTransaction(); + + expect(controller.state.withdrawTransaction).toBeNull(); + expect(controller.state.eligibility).toEqual(originalEligibility); + expect(controller.state.lastError).toBe(originalLastError); + }); + }); + }); + + describe('confirmClaim', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('clears claimable positions from state after confirmation', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockPositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }), + createMockPosition({ + id: 'position-2', + status: PredictPositionStatus.WON, + currentValue: 200, + cashPnl: 100, + }), + ]; + + // Set up state with claimable positions + controller.updateStateForTesting((state) => { + state.claimablePositions[testAddress] = mockPositions; + }); + + mockPolymarketProvider.confirmClaim = jest.fn(); + + // Act + controller.confirmClaim({ address: testAddress }); + + // Assert + expect(controller.state.claimablePositions[testAddress]).toEqual([]); + }); + }); + + it('calls provider confirmClaim with correct positions', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockPositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }), + ]; + + controller.updateStateForTesting((state) => { + state.claimablePositions[testAddress] = mockPositions; + }); + + mockPolymarketProvider.confirmClaim = jest.fn(); + + // Act + controller.confirmClaim({ address: testAddress }); + + // Assert + expect(mockPolymarketProvider.confirmClaim).toHaveBeenCalledWith({ + positions: mockPositions, + signer: expect.objectContaining({ + address: testAddress, + }), + }); + }); + }); + + it('returns early when no claimable positions exist', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + + controller.updateStateForTesting((state) => { + state.claimablePositions[testAddress] = []; + }); + + mockPolymarketProvider.confirmClaim = jest.fn(); + + // Act + controller.confirmClaim({ address: testAddress }); + + // Assert + expect(mockPolymarketProvider.confirmClaim).not.toHaveBeenCalled(); + }); + }); + + it('returns early when claimable positions undefined for address', async () => { + // Arrange + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.claimablePositions = {}; + }); + + mockPolymarketProvider.confirmClaim = jest.fn(); + + // Act + controller.confirmClaim({ + address: '0x1234567890123456789012345678901234567890', + }); + + // Assert + expect(mockPolymarketProvider.confirmClaim).not.toHaveBeenCalled(); + }); + }); + + it('handles provider without confirmClaim method', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockPositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }), + ]; + + controller.updateStateForTesting((state) => { + state.claimablePositions[testAddress] = mockPositions; + }); + + // Remove confirmClaim method from provider + delete (mockPolymarketProvider as { confirmClaim?: unknown }) + .confirmClaim; + + // Act + controller.confirmClaim({ address: testAddress }); + + // Assert - should not throw, state should still be cleared + expect(controller.state.claimablePositions[testAddress]).toEqual([]); + }); + }); + }); + + describe('getPositions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('defaults to polymarket provider when no providerId specified', async () => { + // Arrange + await withController(async ({ controller }) => { + const mockPositions = [ + { + id: 'position-1', + marketId: 'market-1', + status: PredictPositionStatus.OPEN, + currentValue: 100, + cashPnl: 0, + }, + ]; + + mockPolymarketProvider.getPositions = jest + .fn() + .mockResolvedValue(mockPositions); + + // Act + const result = await controller.getPositions({ + address: '0x1234567890123456789012345678901234567890', + }); + + // Assert + expect(result).toEqual(mockPositions); + expect(mockPolymarketProvider.getPositions).toHaveBeenCalled(); + }); + }); + + it('stores claimable positions keyed by address', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockClaimablePositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }), + createMockPosition({ + id: 'position-2', + status: PredictPositionStatus.WON, + currentValue: 200, + cashPnl: 100, + }), + ]; + + mockPolymarketProvider.getPositions = jest + .fn() + .mockResolvedValue(mockClaimablePositions); + + // Act + await controller.getPositions({ + address: testAddress, + claimable: true, + }); + + // Assert + expect(controller.state.claimablePositions[testAddress]).toHaveLength( + 2, + ); + expect(controller.state.claimablePositions[testAddress]).toEqual( + mockClaimablePositions, + ); + }); + }); + }); + + describe('invalidateQueryCache', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls NetworkController.findNetworkClientIdByChainId with hex chain ID', async () => { + const mockFindNetworkClientIdByChainId = jest + .fn() + .mockReturnValue('polygon-mainnet'); + await withController( + async ({ controller }) => { + // Arrange + const chainId = 137; + + // Act + // eslint-disable-next-line dot-notation + await controller['invalidateQueryCache'](chainId); + + // Assert + expect(mockFindNetworkClientIdByChainId).toHaveBeenCalledWith('0x89'); + }, + { + mocks: { + findNetworkClientIdByChainId: mockFindNetworkClientIdByChainId, + }, + }, + ); + }); + + it('calls NetworkController.getNetworkClientById with network client ID', async () => { + const mockGetNetworkClientById = jest + .fn() + .mockReturnValue(DEFAULT_NETWORK_CLIENT); + await withController( + async ({ controller }) => { + // Arrange + const chainId = 137; + + // Act + // eslint-disable-next-line dot-notation + await controller['invalidateQueryCache'](chainId); + + // Assert + expect(mockGetNetworkClientById).toHaveBeenCalledWith( + 'polygon-mainnet', + ); + }, + { + mocks: { + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue('polygon-mainnet'), + getNetworkClientById: mockGetNetworkClientById, + }, + }, + ); + }); + + it('calls blockTracker.checkForLatestBlock to invalidate cache', async () => { + const mockCheckForLatestBlock = jest.fn().mockResolvedValue(undefined); + const mockGetNetworkClientById = jest.fn().mockReturnValue({ + blockTracker: { + checkForLatestBlock: mockCheckForLatestBlock, + }, + }); + + await withController( + async ({ controller }) => { + // Arrange + const chainId = 137; + + // Act + // eslint-disable-next-line dot-notation + await controller['invalidateQueryCache'](chainId); + + // Assert + expect(mockCheckForLatestBlock).toHaveBeenCalledWith(); + }, + { + mocks: { + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue('polygon-mainnet'), + getNetworkClientById: mockGetNetworkClientById, + }, + }, + ); + }); + + it('logs error when blockTracker.checkForLatestBlock fails', async () => { + const mockError = new Error('Block tracker error'); + const mockCheckForLatestBlock = jest.fn().mockRejectedValue(mockError); + const mockGetNetworkClientById = jest.fn().mockReturnValue({ + blockTracker: { + checkForLatestBlock: mockCheckForLatestBlock, + }, + }); + + await withController( + async ({ controller }) => { + // Arrange + const chainId = 137; + + // Act + // eslint-disable-next-line dot-notation + await controller['invalidateQueryCache'](chainId); + + // Assert + expect(DevLogger.log).toHaveBeenCalledWith( + 'PredictController: Error invalidating query cache', + expect.objectContaining({ + error: 'Block tracker error', + timestamp: expect.any(String), + }), + ); + }, + { + mocks: { + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue('polygon-mainnet'), + getNetworkClientById: mockGetNetworkClientById, + }, + }, + ); + }); + + it('continues execution when invalidation fails', async () => { + const mockError = new Error('Block tracker error'); + const mockCheckForLatestBlock = jest.fn().mockRejectedValue(mockError); + const mockGetNetworkClientById = jest.fn().mockReturnValue({ + blockTracker: { + checkForLatestBlock: mockCheckForLatestBlock, + }, + }); + + await withController( + async ({ controller }) => { + // Arrange + const chainId = 137; + + // Act & Assert - should not throw + await expect( + // eslint-disable-next-line dot-notation + controller['invalidateQueryCache'](chainId), + ).resolves.not.toThrow(); + }, + { + mocks: { + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue('polygon-mainnet'), + getNetworkClientById: mockGetNetworkClientById, + }, + }, + ); + }); + }); + + describe('placeOrder - optimistic balance updates', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('decreases balance by spent amount for BUY orders', async () => { + const preview = createMockOrderPreview({ side: Side.BUY }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100.50', + receivedAmount: '200', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBe(1000 - 100.5); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ balance: 1000 }), + }, + }, + }, + ); + }); + + it('sets validUntil to 5 seconds in future for BUY orders', async () => { + const preview = createMockOrderPreview({ side: Side.BUY }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '50', + receivedAmount: '100', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + const now = Date.now(); + jest.setSystemTime(now); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.validUntil).toBe(now + 5000); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance(), + }, + }, + }, + ); + }); + + it('increases balance by received amount for SELL orders', async () => { + const preview = createMockOrderPreview({ side: Side.SELL }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '95.50', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBe(500 + 95.5); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ balance: 500 }), + }, + }, + }, + ); + }); + + it('sets validUntil to 5 seconds in future for SELL orders', async () => { + const preview = createMockOrderPreview({ side: Side.SELL }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '50', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + const now = Date.now(); + jest.setSystemTime(now); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.validUntil).toBe(now + 5000); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance(), + }, + }, + }, + ); + }); + + it('results in NaN balance when parsing invalid spentAmount', async () => { + const preview = createMockOrderPreview({ side: Side.BUY }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: 'invalid', + receivedAmount: '100', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert - parseFloat('invalid') returns NaN + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBeNaN(); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ balance: 1000 }), + }, + }, + }, + ); + }); + + it('does not update balance when provider.placeOrder fails', async () => { + const preview = createMockOrderPreview({ side: Side.BUY }); + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order failed'), + ); + + await withController( + async ({ controller }) => { + // Act + await expect( + controller.placeOrder({ + preview, + }), + ).rejects.toThrow('Order failed'); + + // Assert - balance should remain unchanged + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBe(1000); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ balance: 1000 }), + }, + }, + }, + ); + }); + + it('results in NaN balance when spentAmount is empty for BUY orders', async () => { + const preview = createMockOrderPreview({ side: Side.BUY }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '', + receivedAmount: '100', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert - parseFloat('') returns NaN, so balance becomes NaN + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBeNaN(); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ balance: 1000 }), + }, + }, + }, + ); + }); + + it('results in NaN balance when receivedAmount is empty for SELL orders', async () => { + const preview = createMockOrderPreview({ side: Side.SELL }); + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '', + }, + }; + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + + await withController( + async ({ controller }) => { + // Act + await controller.placeOrder({ + preview, + }); + + // Assert - parseFloat('') returns NaN, so balance becomes NaN + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBeNaN(); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ balance: 500 }), + }, }, - }; + }, + ); + }); + }); - result?.updateTransaction?.(testTransaction as any); + describe('getBalance - caching behavior', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); - expect(testTransaction.txParams.data).toBe('0xmodifieddata'); - expect(testTransaction.txParams.to).toBe('0xPredictAddress'); - expect(testTransaction.assetsFiatValues).toEqual({ - receiving: '100', - }); - }); + afterEach(() => { + jest.useRealTimers(); }); - it('throw error when prepareWithdrawConfirmation fails', async () => { - mockPolymarketProvider.signWithdraw?.mockRejectedValue( - new Error('Confirmation preparation failed'), - ); + it('returns cached balance when validUntil is in future', async () => { + const now = Date.now(); + jest.setSystemTime(now); - await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; - }); + await withController( + async ({ controller }) => { + // Act + const result = await controller.getBalance({}); - await expect( - controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, - }), - ).rejects.toThrow('Confirmation preparation failed'); - }); + // Assert + expect(result).toBe(1500); + expect(mockPolymarketProvider.getBalance).not.toHaveBeenCalled(); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ + balance: 1500, + validUntil: now + 500, + }), + }, + }, + }, + ); }); - it('sets withdraw transaction status to error when gas estimation fails', async () => { - mockPolymarketProvider.signWithdraw?.mockResolvedValue({ - callData: '0xnewdata' as `0x${string}`, - amount: 100, - }); + it('fetches fresh balance when cache expired', async () => { + const now = Date.now(); + jest.setSystemTime(now); + mockPolymarketProvider.getBalance.mockResolvedValue(2000); await withController( async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; - }); - - const result = await controller.beforeSign({ - transactionMeta: mockTransactionMeta as any, - }); + // Act + const result = await controller.getBalance({}); - expect(result).toBeUndefined(); - expect(controller.state.withdrawTransaction?.status).toBe( - PredictWithdrawStatus.ERROR, - ); + // Assert + expect(result).toBe(2000); + expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); }, { - mocks: { - estimateGas: jest - .fn() - .mockRejectedValue(new Error('Gas estimation failed')), + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ + balance: 1500, + validUntil: now - 100, + }), + }, }, }, ); }); - it('return undefined when nestedTransactions is undefined', async () => { - await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; - }); - - const txWithoutNested = { - ...mockTransactionMeta, - nestedTransactions: undefined, - }; + it('fetches fresh balance when no cached balance exists', async () => { + mockPolymarketProvider.getBalance.mockResolvedValue(1000); - const result = await controller.beforeSign({ - transactionMeta: txWithoutNested as any, - }); + await withController(async ({ controller }) => { + // Act + const result = await controller.getBalance({}); - expect(result).toBeUndefined(); + // Assert + expect(result).toBe(1000); + expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); }); }); - it('return undefined when nestedTransactions is empty array', async () => { - await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-1', - amount: 0, - }; - }); - - const txWithEmptyNested = { - ...mockTransactionMeta, - nestedTransactions: [], - }; + it('updates cache with validUntil 1 second in future after fetch', async () => { + const now = Date.now(); + jest.setSystemTime(now); + mockPolymarketProvider.getBalance.mockResolvedValue(2500); - const result = await controller.beforeSign({ - transactionMeta: txWithEmptyNested as any, - }); + await withController(async ({ controller }) => { + // Act + await controller.getBalance({}); - expect(result).toBeUndefined(); + // Assert + const updatedBalance = + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ]; + expect(updatedBalance.balance).toBe(2500); + expect(updatedBalance.validUntil).toBe(now + 1000); }); }); - }); - describe('clearWithdrawTransaction', () => { - it('clear withdraw transaction from state', () => { - withController(({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-123', - amount: 100, - }; - }); + it('calls invalidateQueryCache before fetching fresh balance', async () => { + const mockCheckForLatestBlock = jest.fn().mockResolvedValue(undefined); + const mockGetNetworkClientById = jest.fn().mockReturnValue({ + blockTracker: { + checkForLatestBlock: mockCheckForLatestBlock, + }, + }); + mockPolymarketProvider.getBalance.mockResolvedValue(1000); - expect(controller.state.withdrawTransaction).toEqual({ - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict', - transactionId: 'tx-123', - amount: 100, - }); + await withController( + async ({ controller }) => { + // Act + await controller.getBalance({}); - controller.clearWithdrawTransaction(); + // Assert + expect(mockCheckForLatestBlock).toHaveBeenCalled(); + expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); + }, + { + mocks: { + findNetworkClientIdByChainId: jest + .fn() + .mockReturnValue('polygon-mainnet'), + getNetworkClientById: mockGetNetworkClientById, + }, + }, + ); + }); - expect(controller.state.withdrawTransaction).toBeNull(); - }); + it('fetches balance when validUntil equals current time', async () => { + const now = Date.now(); + jest.setSystemTime(now); + mockPolymarketProvider.getBalance.mockResolvedValue(1800); + + await withController( + async ({ controller }) => { + // Act + const result = await controller.getBalance({}); + + // Assert + expect(result).toBe(1800); + expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ + balance: 1500, + validUntil: now, + }), + }, + }, + }, + ); }); - it('handle clearing when withdraw transaction is already null', () => { - withController(({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = null; - }); + it('caches balance per providerId and address combination', async () => { + const now = Date.now(); + jest.setSystemTime(now); + mockPolymarketProvider.getBalance.mockResolvedValue(3000); - expect(() => controller.clearWithdrawTransaction()).not.toThrow(); + await withController( + async ({ controller }) => { + // Act - fetch for different address + const result = await controller.getBalance({ + address: '0xdifferentaddress000000000000000000000000', + }); - expect(controller.state.withdrawTransaction).toBeNull(); - }); + // Assert + expect(result).toBe(3000); + expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); + // Original cached balance should still exist + expect( + controller.state.balances[ + '0x1234567890123456789012345678901234567890' + ].balance, + ).toBe(1500); + }, + { + state: { + balances: { + '0x1234567890123456789012345678901234567890': + createMockPredictBalance({ + balance: 1500, + validUntil: now + 500, + }), + }, + }, + }, + ); }); + }); - it('clear withdraw transaction with pending status', () => { - withController(({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.PENDING, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-456', - amount: 500, - }; - }); + describe('WebSocket subscription methods', () => { + describe('subscribeToGameUpdates', () => { + it('delegates to provider and returns unsubscribe function', () => { + withController(({ controller }) => { + const mockUnsubscribe = jest.fn(); + const mockCallback = jest.fn(); + mockPolymarketProvider.subscribeToGameUpdates = jest + .fn() + .mockReturnValue(mockUnsubscribe); - controller.clearWithdrawTransaction(); + const unsubscribe = controller.subscribeToGameUpdates( + 'game123', + mockCallback, + ); - expect(controller.state.withdrawTransaction).toBeNull(); + expect( + mockPolymarketProvider.subscribeToGameUpdates, + ).toHaveBeenCalledWith('game123', mockCallback); + expect(unsubscribe).toBe(mockUnsubscribe); + }); }); - }); - it('clear withdraw transaction does not affect other state properties', () => { - withController(({ controller }) => { - controller.updateStateForTesting((state) => { - state.withdrawTransaction = { - chainId: 137, - status: PredictWithdrawStatus.IDLE, - providerId: POLYMARKET_PROVIDER_ID, - predictAddress: '0xPredict' as `0x${string}`, - transactionId: 'tx-789', - amount: 200, - }; - state.eligibility = { eligible: true, country: 'PT' }; - state.lastError = 'Some error'; - }); + it('returns no-op function when provider lacks method', () => { + withController(({ controller }) => { + delete ( + mockPolymarketProvider as { subscribeToGameUpdates?: unknown } + ).subscribeToGameUpdates; - const originalEligibility = controller.state.eligibility; - const originalLastError = controller.state.lastError; + const unsubscribe = controller.subscribeToGameUpdates( + 'game123', + jest.fn(), + ); - controller.clearWithdrawTransaction(); + expect(unsubscribe).toBeDefined(); + expect(unsubscribe()).toBeUndefined(); + }); + }); - expect(controller.state.withdrawTransaction).toBeNull(); - expect(controller.state.eligibility).toEqual(originalEligibility); - expect(controller.state.lastError).toBe(originalLastError); + it('returns no-op function for unknown provider', () => { + withController(({ controller }) => { + const unsubscribe = controller.subscribeToGameUpdates( + 'game123', + jest.fn(), + ); + + expect(unsubscribe).toBeDefined(); + expect(unsubscribe()).toBeUndefined(); + }); }); }); - }); - describe('confirmClaim', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); + describe('subscribeToMarketPrices', () => { + it('delegates to provider and returns unsubscribe function', () => { + withController(({ controller }) => { + const mockUnsubscribe = jest.fn(); + const mockCallback = jest.fn(); + mockPolymarketProvider.subscribeToMarketPrices = jest + .fn() + .mockReturnValue(mockUnsubscribe); - it('clears claimable positions from state after confirmation', async () => { - // Arrange - await withController(async ({ controller }) => { - const testAddress = '0x1234567890123456789012345678901234567890'; - const mockPositions = [ - createMockPosition({ - id: 'position-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }), - createMockPosition({ - id: 'position-2', - status: PredictPositionStatus.WON, - currentValue: 200, - cashPnl: 100, - }), - ]; + const unsubscribe = controller.subscribeToMarketPrices( + ['token1', 'token2'], + mockCallback, + ); - // Set up state with claimable positions - controller.updateStateForTesting((state) => { - state.claimablePositions[testAddress] = mockPositions; + expect( + mockPolymarketProvider.subscribeToMarketPrices, + ).toHaveBeenCalledWith(['token1', 'token2'], mockCallback); + expect(unsubscribe).toBe(mockUnsubscribe); }); + }); - mockPolymarketProvider.confirmClaim = jest.fn(); + it('returns no-op function when provider lacks method', () => { + withController(({ controller }) => { + delete ( + mockPolymarketProvider as { subscribeToMarketPrices?: unknown } + ).subscribeToMarketPrices; - // Act - controller.confirmClaim({ address: testAddress }); + const unsubscribe = controller.subscribeToMarketPrices( + ['token1'], + jest.fn(), + ); - // Assert - expect(controller.state.claimablePositions[testAddress]).toEqual([]); + expect(unsubscribe).toBeDefined(); + expect(unsubscribe()).toBeUndefined(); + }); }); }); - it('calls provider confirmClaim with correct positions', async () => { - // Arrange - await withController(async ({ controller }) => { - const testAddress = '0x1234567890123456789012345678901234567890'; - const mockPositions = [ - createMockPosition({ - id: 'position-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }), - ]; + describe('getConnectionStatus', () => { + it('returns connection status from provider', () => { + withController(({ controller }) => { + mockPolymarketProvider.getConnectionStatus = jest + .fn() + .mockReturnValue({ + sportsConnected: true, + marketConnected: false, + }); - controller.updateStateForTesting((state) => { - state.claimablePositions[testAddress] = mockPositions; + const status = controller.getConnectionStatus(); + + expect(mockPolymarketProvider.getConnectionStatus).toHaveBeenCalled(); + expect(status).toEqual({ + sportsConnected: true, + marketConnected: false, + }); }); + }); - mockPolymarketProvider.confirmClaim = jest.fn(); + it('returns disconnected status when provider lacks method', () => { + withController(({ controller }) => { + delete (mockPolymarketProvider as { getConnectionStatus?: unknown }) + .getConnectionStatus; - // Act - controller.confirmClaim({ address: testAddress }); + const status = controller.getConnectionStatus(); - // Assert - expect(mockPolymarketProvider.confirmClaim).toHaveBeenCalledWith({ - positions: mockPositions, - signer: expect.objectContaining({ - address: testAddress, - }), + expect(status).toEqual({ + sportsConnected: false, + marketConnected: false, + }); }); }); - }); - it('returns early when no claimable positions exist', async () => { - // Arrange - await withController(async ({ controller }) => { - const testAddress = '0x1234567890123456789012345678901234567890'; + it('returns disconnected status for unknown provider', () => { + withController(({ controller }) => { + const status = controller.getConnectionStatus(); - controller.updateStateForTesting((state) => { - state.claimablePositions[testAddress] = []; + expect(status).toEqual({ + sportsConnected: false, + marketConnected: false, + }); }); + }); + }); + }); - mockPolymarketProvider.confirmClaim = jest.fn(); - - // Act - controller.confirmClaim({ address: testAddress }); + describe('Analytics Tracking', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); - // Assert - expect(mockPolymarketProvider.confirmClaim).not.toHaveBeenCalled(); + it('calls analytics.trackEvent for trackPredictOrderEvent', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'succeeded', + analyticsProperties: { marketId: 'test' }, + }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); }); }); - it('returns early when claimable positions undefined for address', async () => { - // Arrange + it('does not call analytics.trackEvent when analyticsProperties is missing for trackPredictOrderEvent', async () => { await withController(async ({ controller }) => { - controller.updateStateForTesting((state) => { - state.claimablePositions = {}; + await controller.trackPredictOrderEvent({ + status: 'succeeded', }); + expect(analytics.trackEvent).not.toHaveBeenCalled(); + }); + }); - mockPolymarketProvider.confirmClaim = jest.fn(); - - // Act - controller.confirmClaim({ - address: '0x1234567890123456789012345678901234567890', + it('includes orderType in analytics properties when provided', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'submitted', + analyticsProperties: { marketId: 'test' }, + orderType: 'FAK', }); - // Assert - expect(mockPolymarketProvider.confirmClaim).not.toHaveBeenCalled(); + expect(analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + order_type: 'FAK', + }), + }), + ); }); }); - it('handles provider without confirmClaim method', async () => { - // Arrange + it('omits orderType from analytics properties when not provided', async () => { await withController(async ({ controller }) => { - const testAddress = '0x1234567890123456789012345678901234567890'; - const mockPositions = [ - createMockPosition({ - id: 'position-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }), - ]; - - controller.updateStateForTesting((state) => { - state.claimablePositions[testAddress] = mockPositions; + await controller.trackPredictOrderEvent({ + status: 'submitted', + analyticsProperties: { marketId: 'test' }, }); - // Remove confirmClaim method from provider - delete (mockPolymarketProvider as { confirmClaim?: unknown }) - .confirmClaim; - - // Act - controller.confirmClaim({ address: testAddress }); - - // Assert - should not throw, state should still be cleared - expect(controller.state.claimablePositions[testAddress]).toEqual([]); + const eventArg = (analytics.trackEvent as jest.Mock).mock.calls[0][0]; + expect(eventArg.properties).not.toHaveProperty('order_type'); }); }); - }); - describe('getPositions', () => { - beforeEach(() => { - jest.clearAllMocks(); + it('calls analytics.trackEvent for trackMarketDetailsOpened', () => { + withController(({ controller }) => { + controller.trackMarketDetailsOpened({ + marketId: 'test', + marketTitle: 'test', + entryPoint: 'test', + marketDetailsViewed: 'test', + }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); }); - it('defaults to polymarket provider when no providerId specified', async () => { - // Arrange - await withController(async ({ controller }) => { - const mockPositions = [ - { - id: 'position-1', - marketId: 'market-1', - status: PredictPositionStatus.OPEN, - currentValue: 100, - cashPnl: 0, - }, - ]; + it('calls analytics.trackEvent for trackPositionViewed', () => { + withController(({ controller }) => { + controller.trackPositionViewed({ openPositionsCount: 5 }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); - mockPolymarketProvider.getPositions = jest - .fn() - .mockResolvedValue(mockPositions); + it('calls analytics.trackEvent for trackActivityViewed', () => { + withController(({ controller }) => { + controller.trackActivityViewed({ activityType: 'all' }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); - // Act - const result = await controller.getPositions({ - address: '0x1234567890123456789012345678901234567890', + it('calls analytics.trackEvent for trackGeoBlockTriggered', () => { + withController(({ controller }) => { + controller.trackGeoBlockTriggered({ + attemptedAction: 'deposit', }); - - // Assert - expect(result).toEqual(mockPositions); - expect(mockPolymarketProvider.getPositions).toHaveBeenCalled(); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); }); }); - it('stores claimable positions keyed by address', async () => { - // Arrange - await withController(async ({ controller }) => { - const testAddress = '0x1234567890123456789012345678901234567890'; - const mockClaimablePositions = [ - createMockPosition({ - id: 'position-1', - status: PredictPositionStatus.WON, - currentValue: 100, - cashPnl: 50, - }), - createMockPosition({ - id: 'position-2', - status: PredictPositionStatus.WON, - currentValue: 200, - cashPnl: 100, - }), - ]; - - mockPolymarketProvider.getPositions = jest - .fn() - .mockResolvedValue(mockClaimablePositions); - - // Act - await controller.getPositions({ - address: testAddress, - claimable: true, + it('calls analytics.trackEvent for trackFeedViewed', () => { + withController(({ controller }) => { + controller.trackFeedViewed({ + sessionId: 'test', + feedTab: 'test', + numPagesViewed: 1, + sessionTime: 1000, }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + }); + }); - // Assert - expect(controller.state.claimablePositions[testAddress]).toHaveLength( - 2, - ); - expect(controller.state.claimablePositions[testAddress]).toEqual( - mockClaimablePositions, - ); + it('calls analytics.trackEvent for trackShareAction', () => { + withController(({ controller }) => { + controller.trackShareAction({ status: 'success' }); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); }); }); }); - describe('invalidateQueryCache', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); + describe('onPlaceOrderEnd', () => { + it('clears activeOrder and selectedPaymentToken', () => { + withController(({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.SUCCESS, + }); + controller.setSelectedPaymentToken({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }); - afterEach(() => { - jest.useRealTimers(); + controller.onPlaceOrderEnd(); + + expect(controller.state.activeBuyOrder).toBeNull(); + expect(controller.state.selectedPaymentToken).toBeNull(); + }); }); - it('calls NetworkController.findNetworkClientIdByChainId with hex chain ID', async () => { - const mockFindNetworkClientIdByChainId = jest - .fn() - .mockReturnValue('polygon-mainnet'); - await withController( - async ({ controller }) => { - // Arrange - const chainId = 137; + it('does not clear pendingOrderPreviews on navigation away', () => { + withController(({ controller }) => { + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + }; + }; + } + ).pendingOrderPreviews['tx-123'] = { + preview: createMockOrderPreview(), + signerAddress: MOCK_ADDRESS, + }; - // Act - // eslint-disable-next-line dot-notation - await controller['invalidateQueryCache'](chainId); + controller.onPlaceOrderEnd(); - // Assert - expect(mockFindNetworkClientIdByChainId).toHaveBeenCalledWith('0x89'); - }, - { - mocks: { - findNetworkClientIdByChainId: mockFindNetworkClientIdByChainId, - }, - }, - ); + expect( + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + }; + }; + } + ).pendingOrderPreviews['tx-123'], + ).toBeDefined(); + }); }); + }); - it('calls NetworkController.getNetworkClientById with network client ID', async () => { - const mockGetNetworkClientById = jest - .fn() - .mockReturnValue(DEFAULT_NETWORK_CLIENT); + describe('placeOrder with activeOrder', () => { + it('transitions pay with any token orders to depositing', async () => { await withController( async ({ controller }) => { - // Arrange - const chainId = 137; + setActiveOrderForTest(controller, { + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + }); - // Act - // eslint-disable-next-line dot-notation - await controller['invalidateQueryCache'](chainId); + const preview = createMockOrderPreview({ side: Side.BUY }); - // Assert - expect(mockGetNetworkClientById).toHaveBeenCalledWith( - 'polygon-mainnet', - ); + const result = await controller.placeOrder({ + analyticsProperties: { marketId: 'market-1' }, + preview, + transactionId: 'tx-deposit-1', + }); + + expect(result).toEqual({ + success: false, + response: { status: 'deposit_in_progress' }, + }); + expect(controller.state.activeBuyOrder).toEqual({ + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-deposit-1', + }); + expect(mockPolymarketProvider.placeOrder).not.toHaveBeenCalled(); }, { mocks: { - findNetworkClientIdByChainId: jest + getRemoteFeatureFlagState: jest .fn() - .mockReturnValue('polygon-mainnet'), - getNetworkClientById: mockGetNetworkClientById, + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('calls blockTracker.checkForLatestBlock to invalidate cache', async () => { - const mockCheckForLatestBlock = jest.fn().mockResolvedValue(undefined); - const mockGetNetworkClientById = jest.fn().mockReturnValue({ - blockTracker: { - checkForLatestBlock: mockCheckForLatestBlock, + it('sets activeOrder to success after provider order placement succeeds', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', }, - }); - + }; await withController( async ({ controller }) => { - // Arrange - const chainId = 137; + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); - // Act - // eslint-disable-next-line dot-notation - await controller['invalidateQueryCache'](chainId); + const preview = createMockOrderPreview({ side: Side.BUY }); - // Assert - expect(mockCheckForLatestBlock).toHaveBeenCalledWith(); + await controller.placeOrder({ preview }); + + expect(controller.state.activeBuyOrder).toEqual({ + state: ActiveOrderState.SUCCESS, + }); }, { mocks: { - findNetworkClientIdByChainId: jest + getRemoteFeatureFlagState: jest .fn() - .mockReturnValue('polygon-mainnet'), - getNetworkClientById: mockGetNetworkClientById, + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('logs error when blockTracker.checkForLatestBlock fails', async () => { - const mockError = new Error('Block tracker error'); - const mockCheckForLatestBlock = jest.fn().mockRejectedValue(mockError); - const mockGetNetworkClientById = jest.fn().mockReturnValue({ - blockTracker: { - checkForLatestBlock: mockCheckForLatestBlock, - }, - }); - + it('retries pay-with-any-token init after order placement fails with an active transactionId', async () => { await withController( async ({ controller }) => { - // Arrange - const chainId = 137; + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); - // Act - // eslint-disable-next-line dot-notation - await controller['invalidateQueryCache'](chainId); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue({ + success: true, + response: { + batchId: 'batch-2', + }, + } as never); - // Assert - expect(DevLogger.log).toHaveBeenCalledWith( - 'PredictController: Error invalidating query cache', - expect.objectContaining({ - error: 'Block tracker error', - timestamp: expect.any(String), - }), + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order placement failed'), + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order placement failed', + ); + + expect(retrySpy).toHaveBeenCalledTimes(1); + expect( + controller.state.activeBuyOrder?.transactionId, + ).toBeUndefined(); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect(controller.state.activeBuyOrder?.error).toBe( + 'Order placement failed', ); }, { mocks: { - findNetworkClientIdByChainId: jest + getRemoteFeatureFlagState: jest .fn() - .mockReturnValue('polygon-mainnet'), - getNetworkClientById: mockGetNetworkClientById, + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('continues execution when invalidation fails', async () => { - const mockError = new Error('Block tracker error'); - const mockCheckForLatestBlock = jest.fn().mockRejectedValue(mockError); - const mockGetNetworkClientById = jest.fn().mockReturnValue({ - blockTracker: { - checkForLatestBlock: mockCheckForLatestBlock, - }, - }); + it('passes explicit address to provider.placeOrder signer', async () => { + const explicitAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; await withController( async ({ controller }) => { - // Arrange - const chainId = 137; + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); - // Act & Assert - should not throw - await expect( - // eslint-disable-next-line dot-notation - controller['invalidateQueryCache'](chainId), - ).resolves.not.toThrow(); + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: true, + response: { + id: 'order-explicit', + spentAmount: '50', + receivedAmount: '100', + }, + }); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await controller.placeOrder({ preview, address: explicitAddress }); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.SUCCESS, + ); + expect(mockPolymarketProvider.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + signer: expect.objectContaining({ + address: explicitAddress, + }), + }), + ); }, { mocks: { - findNetworkClientIdByChainId: jest + getRemoteFeatureFlagState: jest .fn() - .mockReturnValue('polygon-mainnet'), - getNetworkClientById: mockGetNetworkClientById, + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - }); - - describe('placeOrder - optimistic balance updates', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('decreases balance by spent amount for BUY orders', async () => { - const preview = createMockOrderPreview({ side: Side.BUY }); - const mockResult = { - success: true as const, - response: { - id: 'order-123', - spentAmount: '100.50', - receivedAmount: '200', - }, - }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + it('retries initPayWithAnyToken after order placement fails', async () => { await withController( async ({ controller }) => { - // Act - await controller.placeOrder({ - preview, + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-explicit', }); - // Assert - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBe(1000 - 100.5); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue({ + success: true, + response: { batchId: 'batch-retry' }, + } as never); + + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order failed'), + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order failed', + ); + + expect(retrySpy).toHaveBeenCalledTimes(1); }, { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ balance: 1000 }), - }, + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('sets validUntil to 5 seconds in future for BUY orders', async () => { - const preview = createMockOrderPreview({ side: Side.BUY }); - const mockResult = { - success: true as const, - response: { - id: 'order-123', - spentAmount: '50', - receivedAmount: '100', - }, - }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); - const now = Date.now(); - jest.setSystemTime(now); - + it('does not update activeBuyOrder when background order completes for a different active order', async () => { await withController( async ({ controller }) => { - // Act - await controller.placeOrder({ - preview, + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - // Assert - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.validUntil).toBe(now + 5000); - }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance(), + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: { marketId?: string }; + }; + }; + } + ).pendingOrderPreviews['tx-bg-1'] = { + preview: createMockOrderPreview({ side: Side.BUY }), + signerAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }; + + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: true, + response: { + id: 'order-bg-1', + spentAmount: '100', + receivedAmount: '200', }, - }, - }, - ); - }); + }); - it('increases balance by received amount for SELL orders', async () => { - const preview = createMockOrderPreview({ side: Side.SELL }); - const mockResult = { - success: true as const, - response: { - id: 'order-123', - spentAmount: '100', - receivedAmount: '95.50', - }, - }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + const preview = createMockOrderPreview({ side: Side.BUY }); - await withController( - async ({ controller }) => { - // Act await controller.placeOrder({ preview, + address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + transactionId: 'tx-bg-1', }); - // Assert - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBe(500 + 95.5); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect(mockPolymarketProvider.placeOrder).toHaveBeenCalled(); + expect( + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + }; + }; + } + ).pendingOrderPreviews['tx-bg-1'], + ).toBeUndefined(); }, { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ balance: 500 }), - }, + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('sets validUntil to 5 seconds in future for SELL orders', async () => { - const preview = createMockOrderPreview({ side: Side.SELL }); - const mockResult = { - success: true as const, - response: { - id: 'order-123', - spentAmount: '100', - receivedAmount: '50', - }, - }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); - const now = Date.now(); - jest.setSystemTime(now); - + it('does not clear selectedPaymentToken when background order fails for a different active order', async () => { await withController( async ({ controller }) => { - // Act - await controller.placeOrder({ - preview, + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - // Assert - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.validUntil).toBe(now + 5000); + controller.setSelectedPaymentToken({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }); + + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order failed'), + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect( + controller.placeOrder({ preview, transactionId: 'tx-bg-1' }), + ).rejects.toThrow('Order failed'); + + expect(controller.state.selectedPaymentToken).toEqual({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }); }, { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance(), - }, + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('results in NaN balance when parsing invalid spentAmount', async () => { - const preview = createMockOrderPreview({ side: Side.BUY }); - const mockResult = { - success: true as const, - response: { - id: 'order-123', - spentAmount: 'invalid', - receivedAmount: '100', - }, - }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); - + it('publishes confirmed event when background order succeeds for a different active order', async () => { await withController( - async ({ controller }) => { - // Act + async ({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); + + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: { marketId?: string }; + }; + }; + } + ).pendingOrderPreviews['tx-bg-1'] = { + preview: createMockOrderPreview({ side: Side.BUY }), + signerAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }; + + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: true, + response: { + id: 'order-bg-1', + spentAmount: '100', + receivedAmount: '200', + }, + }); + + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + await controller.placeOrder({ preview, + address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + transactionId: 'tx-bg-1', }); - // Assert - parseFloat('invalid') returns NaN - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBeNaN(); - }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ balance: 1000 }), - }, + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'order', + status: 'confirmed', + }), + ); + }, + { + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('does not update balance when provider.placeOrder fails', async () => { - const preview = createMockOrderPreview({ side: Side.BUY }); - mockPolymarketProvider.placeOrder.mockRejectedValue( - new Error('Order failed'), - ); - + it('publishes failed event when background order fails for a different active order', async () => { await withController( - async ({ controller }) => { - // Act + async ({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); + + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order failed'), + ); + + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + await expect( controller.placeOrder({ preview, + transactionId: 'tx-bg-1', }), ).rejects.toThrow('Order failed'); - // Assert - balance should remain unchanged - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBe(1000); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'order', + status: 'failed', + }), + ); }, { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ balance: 1000 }), - }, + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); - it('results in NaN balance when spentAmount is empty for BUY orders', async () => { - const preview = createMockOrderPreview({ side: Side.BUY }); - const mockResult = { - success: true as const, - response: { - id: 'order-123', - spentAmount: '', - receivedAmount: '100', - }, - }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); - + it('does not enter PAY_WITH_ANY_TOKEN branch when transactionId has existing pending preview', async () => { await withController( async ({ controller }) => { - // Act + setActiveOrderForTest(controller, { + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + }); + + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: { marketId?: string }; + }; + }; + } + ).pendingOrderPreviews['tx-bg-1'] = { + preview: createMockOrderPreview({ side: Side.BUY }), + signerAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }; + + mockPolymarketProvider.placeOrder.mockResolvedValue({ + success: true, + response: { + id: 'order-bg-1', + spentAmount: '100', + receivedAmount: '200', + }, + }); + + const preview = createMockOrderPreview({ side: Side.BUY }); + await controller.placeOrder({ preview, + address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + transactionId: 'tx-bg-1', }); - // Assert - parseFloat('') returns NaN, so balance becomes NaN - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBeNaN(); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PAY_WITH_ANY_TOKEN, + ); + expect(mockPolymarketProvider.placeOrder).toHaveBeenCalled(); }, { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ balance: 1000 }), - }, + mocks: { + getRemoteFeatureFlagState: jest + .fn() + .mockReturnValue(REMOTE_FEATURE_FLAG_STATE_WITH_PAY_ANY_TOKEN), }, }, ); }); + }); - it('results in NaN balance when receivedAmount is empty for SELL orders', async () => { - const preview = createMockOrderPreview({ side: Side.SELL }); + describe('placeOrder with predictWithAnyToken flag disabled', () => { + it('skips deposit flow and places order directly when activeOrder is PAY_WITH_ANY_TOKEN', async () => { const mockResult = { success: true as const, response: { - id: 'order-123', + id: 'order-direct', spentAmount: '100', - receivedAmount: '', + receivedAmount: '200', }, }; - mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); - await withController( - async ({ controller }) => { - // Act - await controller.placeOrder({ - preview, - }); + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + setActiveOrderForTest(controller, { + state: ActiveOrderState.PAY_WITH_ANY_TOKEN, + }); - // Assert - parseFloat('') returns NaN, so balance becomes NaN - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBeNaN(); - }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ balance: 500 }), - }, - }, - }, - ); + const preview = createMockOrderPreview({ side: Side.BUY }); + + const result = await controller.placeOrder({ + analyticsProperties: { marketId: 'market-1' }, + preview, + }); + + expect(result.success).toBe(true); + expect(mockPolymarketProvider.placeOrder).toHaveBeenCalled(); + expect(controller.state.activeBuyOrder?.state).not.toBe( + ActiveOrderState.DEPOSITING, + ); + }); }); - }); - describe('getBalance - caching behavior', () => { - beforeEach(() => { - jest.useFakeTimers(); + it('does not update activeOrder to PLACING_ORDER for buy orders', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; + + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await controller.placeOrder({ preview }); + + expect(controller.state.activeBuyOrder).toBeNull(); + }); }); - afterEach(() => { - jest.useRealTimers(); + it('does not update activeOrder to SUCCESS after successful order', async () => { + const mockResult = { + success: true as const, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', + }, + }; + + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockResolvedValue(mockResult); + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await controller.placeOrder({ preview }); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + }); }); - it('returns cached balance when validUntil is in future', async () => { - const now = Date.now(); - jest.setSystemTime(now); + it('does not update activeOrder to PREVIEW on error', async () => { + await withController(async ({ controller }) => { + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order failed'), + ); + setActiveOrderForTest(controller, { + state: ActiveOrderState.PLACING_ORDER, + }); - await withController( - async ({ controller }) => { - // Act - const result = await controller.getBalance({}); + const preview = createMockOrderPreview({ side: Side.BUY }); - // Assert - expect(result).toBe(1500); - expect(mockPolymarketProvider.getBalance).not.toHaveBeenCalled(); - }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ - balance: 1500, - validUntil: now + 500, - }), - }, - }, - }, - ); + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order failed', + ); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PLACING_ORDER, + ); + expect(controller.state.lastError).toBe('Order failed'); + }); }); - it('fetches fresh balance when cache expired', async () => { - const now = Date.now(); - jest.setSystemTime(now); - mockPolymarketProvider.getBalance.mockResolvedValue(2000); + it('does not retry initPayWithAnyToken on error with active batch', async () => { + await withController(async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'batch-1', + }); - await withController( - async ({ controller }) => { - // Act - const result = await controller.getBalance({}); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue({ + success: true, + response: { batchId: 'batch-2' }, + } as never); - // Assert - expect(result).toBe(2000); - expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); + mockPolymarketProvider.placeOrder.mockRejectedValue( + new Error('Order failed'), + ); + + const preview = createMockOrderPreview({ side: Side.BUY }); + + await expect(controller.placeOrder({ preview })).rejects.toThrow( + 'Order failed', + ); + + expect(retrySpy).not.toHaveBeenCalled(); + expect(controller.state.activeBuyOrder?.transactionId).toBe('batch-1'); + }); + }); + }); + + describe('handleTransactionSideEffects for depositAndOrder', () => { + const accountAddress = '0x1234567890123456789012345678901234567890'; + + const createPredictTransactionMeta = ({ + nestedType, + status, + batchId, + }: { + nestedType: TransactionType; + status: TransactionStatus; + batchId?: string; + }) => + ({ + id: 'tx-1', + status, + batchId, + txParams: { + from: accountAddress, + to: '0x0000000000000000000000000000000000000001', + value: '0x0', + data: '0x', }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ - balance: 1500, - validUntil: now - 100, - }), + nestedTransactions: [ + { + type: nestedType, + }, + ], + }) as any; + + it('places the order when depositAndOrder transaction is confirmed and preview exists', () => { + withController(({ controller, messenger }) => { + const preview = createMockOrderPreview(); + const placeOrderSpy = jest + .spyOn(controller, 'placeOrder') + .mockResolvedValue({ + success: true, + response: { + id: 'order-123', + spentAmount: '100', + receivedAmount: '200', }, + } as any); + + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: { marketId?: string }; + }; + }; + } + ).pendingOrderPreviews['tx-1'] = { + preview, + signerAddress: accountAddress, + analyticsProperties: { marketId: 'market-1' }, + }; + + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], }, - }, - ); + } as { transactionMeta: TransactionMeta }); + + expect(placeOrderSpy).toHaveBeenCalledWith({ + analyticsProperties: { marketId: 'market-1' }, + preview, + address: accountAddress, + transactionId: 'tx-1', + }); + }); }); - it('fetches fresh balance when no cached balance exists', async () => { - mockPolymarketProvider.getBalance.mockResolvedValue(1000); + it('does not place order when depositAndOrder confirmed without stored pendingOrderPreview', () => { + withController(({ controller, messenger }) => { + const placeOrderSpy = jest.spyOn(controller, 'placeOrder'); - await withController(async ({ controller }) => { - // Act - const result = await controller.getBalance({}); + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); - // Assert - expect(result).toBe(1000); - expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + }, + } as { transactionMeta: TransactionMeta }); + + expect(placeOrderSpy).not.toHaveBeenCalled(); }); }); - it('updates cache with validUntil 1 second in future after fetch', async () => { - const now = Date.now(); - jest.setSystemTime(now); - mockPolymarketProvider.getBalance.mockResolvedValue(2500); + it('returns activeBuyOrder to preview and retries when depositAndOrder transaction fails', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); - await withController(async ({ controller }) => { - // Act - await controller.getBalance({}); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue(undefined as never); - // Assert - const updatedBalance = - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ]; - expect(updatedBalance.balance).toBe(2500); - expect(updatedBalance.validUntil).toBe(now + 1000); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'Transaction reverted' }, + }, + } as { transactionMeta: TransactionMeta }); + + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect(retrySpy).toHaveBeenCalledTimes(1); }); }); - it('calls invalidateQueryCache before fetching fresh balance', async () => { - const mockCheckForLatestBlock = jest.fn().mockResolvedValue(undefined); - const mockGetNetworkClientById = jest.fn().mockReturnValue({ - blockTracker: { - checkForLatestBlock: mockCheckForLatestBlock, - }, - }); - mockPolymarketProvider.getBalance.mockResolvedValue(1000); + it('retries when depositAndOrder transaction fails, even if the error message indicates user rejection', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); + controller.setSelectedPaymentToken({ + address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + chainId: '0x89', + symbol: 'USDC', + }); - await withController( - async ({ controller }) => { - // Act - await controller.getBalance({}); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue({ + success: false, + error: 'User rejected the request.', + } as never); - // Assert - expect(mockCheckForLatestBlock).toHaveBeenCalled(); - expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); - }, - { - mocks: { - findNetworkClientIdByChainId: jest - .fn() - .mockReturnValue('polygon-mainnet'), - getNetworkClientById: mockGetNetworkClientById, + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'User rejected the request.', code: 4001 }, }, - }, - ); + } as { transactionMeta: TransactionMeta }); + + expect(retrySpy).toHaveBeenCalledTimes(1); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); + expect(controller.state.activeBuyOrder?.error).toBe( + 'User rejected the request.', + ); + }); }); - it('fetches balance when validUntil equals current time', async () => { - const now = Date.now(); - jest.setSystemTime(now); - mockPolymarketProvider.getBalance.mockResolvedValue(1800); + it('uses default error message when depositAndOrder fails without error message', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); - await withController( - async ({ controller }) => { - // Act - const result = await controller.getBalance({}); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue(undefined as never); - // Assert - expect(result).toBe(1800); - expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); - }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ - balance: 1500, - validUntil: now, - }), - }, + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, + }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], }, - }, - ); + } as { transactionMeta: TransactionMeta }); + + expect(controller.state.activeBuyOrder?.error).toBeDefined(); + expect(retrySpy).toHaveBeenCalledTimes(1); + }); }); - it('caches balance per providerId and address combination', async () => { - const now = Date.now(); - jest.setSystemTime(now); - mockPolymarketProvider.getBalance.mockResolvedValue(3000); + it('publishes order failed event when depositAndOrder fails and there is no active buy order', () => { + withController(({ controller, messenger }) => { + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); - await withController( - async ({ controller }) => { - // Act - fetch for different address - const result = await controller.getBalance({ - address: '0xdifferentaddress000000000000000000000000', - }); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, + }); - // Assert - expect(result).toBe(3000); - expect(mockPolymarketProvider.getBalance).toHaveBeenCalled(); - // Original cached balance should still exist - expect( - controller.state.balances[ - '0x1234567890123456789012345678901234567890' - ].balance, - ).toBe(1500); - }, - { - state: { - balances: { - '0x1234567890123456789012345678901234567890': - createMockPredictBalance({ - balance: 1500, - validUntil: now + 500, - }), - }, + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'Transaction reverted' }, }, - }, - ); + } as { transactionMeta: TransactionMeta }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'order', + status: 'failed', + }), + ); + }); }); - }); - describe('WebSocket subscription methods', () => { - describe('subscribeToGameUpdates', () => { - it('delegates to provider and returns unsubscribe function', () => { - withController(({ controller }) => { - const mockUnsubscribe = jest.fn(); - const mockCallback = jest.fn(); - mockPolymarketProvider.subscribeToGameUpdates = jest - .fn() - .mockReturnValue(mockUnsubscribe); + it('does not publish order failed event when depositAndOrder fails and there is an active buy order', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-1', + }); - const unsubscribe = controller.subscribeToGameUpdates( - 'game123', - mockCallback, - ); + jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue(undefined as never); - expect( - mockPolymarketProvider.subscribeToGameUpdates, - ).toHaveBeenCalledWith('game123', mockCallback); - expect(unsubscribe).toBe(mockUnsubscribe); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); + + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'Transaction reverted' }, + }, + } as { transactionMeta: TransactionMeta }); + + expect(handler).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'order' }), + ); }); + }); - it('returns no-op function when provider lacks method', () => { - withController(({ controller }) => { - delete ( - mockPolymarketProvider as { subscribeToGameUpdates?: unknown } - ).subscribeToGameUpdates; + it('does not update activeBuyOrder when deposit confirms for a different active order', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); - const unsubscribe = controller.subscribeToGameUpdates( - 'game123', - jest.fn(), - ); + const preview = createMockOrderPreview(); + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: { marketId?: string }; + }; + }; + } + ).pendingOrderPreviews['tx-1'] = { + preview, + signerAddress: accountAddress, + analyticsProperties: { marketId: 'market-1' }, + }; - expect(unsubscribe).toBeDefined(); - expect(unsubscribe()).toBeUndefined(); + const placeOrderSpy = jest + .spyOn(controller, 'placeOrder') + .mockResolvedValue({ + success: true, + response: { + id: 'order-bg', + spentAmount: '100', + receivedAmount: '200', + }, + } as any); + + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, }); - }); - it('returns no-op function for unknown provider', () => { - withController(({ controller }) => { - const unsubscribe = controller.subscribeToGameUpdates( - 'game123', - jest.fn(), - ); + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + }, + } as { transactionMeta: TransactionMeta }); - expect(unsubscribe).toBeDefined(); - expect(unsubscribe()).toBeUndefined(); + expect(placeOrderSpy).toHaveBeenCalledWith({ + analyticsProperties: { marketId: 'market-1' }, + preview, + address: accountAddress, + transactionId: 'tx-1', }); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); }); }); - describe('subscribeToMarketPrices', () => { - it('delegates to provider and returns unsubscribe function', () => { - withController(({ controller }) => { - const mockUnsubscribe = jest.fn(); - const mockCallback = jest.fn(); - mockPolymarketProvider.subscribeToMarketPrices = jest - .fn() - .mockReturnValue(mockUnsubscribe); + it('does not retry initPayWithAnyToken when deposit fails for a different active order', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); - const unsubscribe = controller.subscribeToMarketPrices( - ['token1', 'token2'], - mockCallback, - ); + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue(undefined as never); - expect( - mockPolymarketProvider.subscribeToMarketPrices, - ).toHaveBeenCalledWith(['token1', 'token2'], mockCallback); - expect(unsubscribe).toBe(mockUnsubscribe); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, }); - }); - - it('returns no-op function when provider lacks method', () => { - withController(({ controller }) => { - delete ( - mockPolymarketProvider as { subscribeToMarketPrices?: unknown } - ).subscribeToMarketPrices; - const unsubscribe = controller.subscribeToMarketPrices( - ['token1'], - jest.fn(), - ); + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'Transaction reverted' }, + }, + } as { transactionMeta: TransactionMeta }); - expect(unsubscribe).toBeDefined(); - expect(unsubscribe()).toBeUndefined(); - }); + expect(retrySpy).not.toHaveBeenCalled(); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); }); }); - describe('getConnectionStatus', () => { - it('returns connection status from provider', () => { - withController(({ controller }) => { - mockPolymarketProvider.getConnectionStatus = jest - .fn() - .mockReturnValue({ - sportsConnected: true, - marketConnected: false, - }); + it('publishes failed event when deposit fails for a different active order', () => { + withController(({ controller, messenger }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); - const status = controller.getConnectionStatus(); + const handler = jest.fn(); + messenger.subscribe( + 'PredictController:transactionStatusChanged', + handler, + ); - expect(mockPolymarketProvider.getConnectionStatus).toHaveBeenCalled(); - expect(status).toEqual({ - sportsConnected: true, - marketConnected: false, - }); + const transactionMeta = createPredictTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, }); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...transactionMeta, + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'Transaction reverted' }, + }, + } as { transactionMeta: TransactionMeta }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'order', + status: 'failed', + }), + ); }); + }); - it('returns disconnected status when provider lacks method', () => { - withController(({ controller }) => { - delete (mockPolymarketProvider as { getConnectionStatus?: unknown }) - .getConnectionStatus; + describe('when user switched accounts after initiating deposit', () => { + const originalAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const currentlySelectedAddress = MOCK_ADDRESS; + const createSwitchedAccountTransactionMeta = ({ + nestedType, + status, + batchId, + }: { + nestedType: TransactionType; + status: TransactionStatus; + batchId?: string; + }) => + ({ + id: 'tx-switched', + status, + batchId, + txParams: { + from: originalAddress, + to: '0x0000000000000000000000000000000000000001', + value: '0x0', + data: '0x', + }, + nestedTransactions: [ + { + type: nestedType, + }, + ], + }) as any; + + it('forwards the transaction address to placeOrder when depositAndOrder confirms after account switch', () => { + withController(({ controller, messenger }) => { + const preview = createMockOrderPreview(); + const placeOrderSpy = jest + .spyOn(controller, 'placeOrder') + .mockResolvedValue({ + success: true, + response: { + id: 'order-456', + spentAmount: '100', + receivedAmount: '200', + }, + } as any); - const status = controller.getConnectionStatus(); + controller.updateStateForTesting((state) => { + state.activeBuyOrder = { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-switched', + }; + }); + ( + controller as unknown as { + pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: { marketId?: string }; + }; + }; + } + ).pendingOrderPreviews['tx-switched'] = { + preview, + signerAddress: originalAddress, + analyticsProperties: { marketId: 'market-2' }, + }; - expect(status).toEqual({ - sportsConnected: false, - marketConnected: false, + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...createSwitchedAccountTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.confirmed, + }), + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + }, + } as { transactionMeta: TransactionMeta }); + + expect(placeOrderSpy).toHaveBeenCalledWith({ + analyticsProperties: { marketId: 'market-2' }, + preview, + address: originalAddress, + transactionId: 'tx-switched', }); }); }); - it('returns disconnected status for unknown provider', () => { - withController(({ controller }) => { - const status = controller.getConnectionStatus(); - - expect(status).toEqual({ - sportsConnected: false, - marketConnected: false, + it('forwards the transaction address to initPayWithAnyToken when depositAndOrder fails after account switch', () => { + withController(({ controller, messenger }) => { + controller.updateStateForTesting((state) => { + state.activeBuyOrder = { + state: ActiveOrderState.DEPOSITING, + transactionId: 'tx-switched', + }; }); + + const retrySpy = jest + .spyOn(controller, 'initPayWithAnyToken') + .mockResolvedValue(undefined as never); + + messenger.publish('TransactionController:transactionStatusUpdated', { + transactionMeta: { + ...createSwitchedAccountTransactionMeta({ + nestedType: TransactionType.predictDeposit, + status: TransactionStatus.failed, + }), + type: TransactionType.predictDepositAndOrder, + nestedTransactions: [ + { type: TransactionType.predictDepositAndOrder }, + ], + error: { message: 'Transaction reverted' }, + }, + } as { transactionMeta: TransactionMeta }); + + expect(retrySpy).toHaveBeenCalled(); + expect(controller.state.activeBuyOrder?.state).toBe( + ActiveOrderState.PREVIEW, + ); }); }); }); }); - describe('Analytics Tracking', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('calls analytics.trackEvent for trackPredictOrderEvent', async () => { + describe('initPayWithAnyToken error branches', () => { + it('returns a failed result when deposit preparation returns undefined', async () => { await withController(async ({ controller }) => { - await controller.trackPredictOrderEvent({ - status: 'succeeded', - analyticsProperties: { marketId: 'test' }, + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); - }); - }); - it('does not call analytics.trackEvent when analyticsProperties is missing for trackPredictOrderEvent', async () => { - await withController(async ({ controller }) => { - await controller.trackPredictOrderEvent({ - status: 'succeeded', + mockPolymarketProvider.prepareDeposit.mockResolvedValue( + undefined as never, + ); + + await expect(controller.initPayWithAnyToken()).resolves.toEqual({ + success: false, + error: 'Deposit preparation returned undefined', }); - expect(analytics.trackEvent).not.toHaveBeenCalled(); }); }); - it('includes orderType in analytics properties when provided', async () => { + it('returns a failed result when deposit preparation returns empty transactions', async () => { await withController(async ({ controller }) => { - await controller.trackPredictOrderEvent({ - status: 'submitted', - analyticsProperties: { marketId: 'test' }, - orderType: 'FAK', + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - expect(analytics.trackEvent).toHaveBeenCalledWith( - expect.objectContaining({ - properties: expect.objectContaining({ - order_type: 'FAK', - }), - }), - ); + mockPolymarketProvider.prepareDeposit.mockResolvedValue({ + transactions: [], + chainId: '0x89', + }); + + await expect(controller.initPayWithAnyToken()).resolves.toEqual({ + success: false, + error: 'No transactions returned from deposit preparation', + }); }); }); - it('omits orderType from analytics properties when not provided', async () => { + it('returns a failed result when deposit preparation returns no chainId', async () => { await withController(async ({ controller }) => { - await controller.trackPredictOrderEvent({ - status: 'submitted', - analyticsProperties: { marketId: 'test' }, + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - const eventArg = (analytics.trackEvent as jest.Mock).mock.calls[0][0]; - expect(eventArg.properties).not.toHaveProperty('order_type'); - }); - }); + mockPolymarketProvider.prepareDeposit.mockResolvedValue({ + transactions: [ + { + params: { + to: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as `0x${string}`, + data: '0xa9059cbb' as `0x${string}`, + }, + type: TransactionType.predictDeposit, + }, + ], + } as never); - it('calls analytics.trackEvent for trackMarketDetailsOpened', () => { - withController(({ controller }) => { - controller.trackMarketDetailsOpened({ - marketId: 'test', - marketTitle: 'test', - entryPoint: 'test', - marketDetailsViewed: 'test', + await expect(controller.initPayWithAnyToken()).resolves.toEqual({ + success: false, + error: 'Chain ID not provided by deposit preparation', }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); }); }); - it('calls analytics.trackEvent for trackPositionViewed', () => { - withController(({ controller }) => { - controller.trackPositionViewed({ openPositionsCount: 5 }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); - }); - }); + it('returns a failed result when network client is not found for chain ID', async () => { + await withController( + async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, + }); - it('calls analytics.trackEvent for trackActivityViewed', () => { - withController(({ controller }) => { - controller.trackActivityViewed({ activityType: 'all' }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); - }); + await expect(controller.initPayWithAnyToken()).resolves.toEqual({ + success: false, + error: 'Network client not found for chain ID: 0x89', + }); + }, + { + mocks: { + findNetworkClientIdByChainId: jest.fn().mockReturnValue(undefined), + }, + }, + ); }); - it('calls analytics.trackEvent for trackGeoBlockTriggered', () => { - withController(({ controller }) => { - controller.trackGeoBlockTriggered({ - attemptedAction: 'deposit', + it('returns a failed result when transaction batch returns no batchId', async () => { + await withController(async ({ controller }) => { + setActiveOrderForTest(controller, { + state: ActiveOrderState.PREVIEW, }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); - }); - }); - it('calls analytics.trackEvent for trackFeedViewed', () => { - withController(({ controller }) => { - controller.trackFeedViewed({ - sessionId: 'test', - feedTab: 'test', - numPagesViewed: 1, - sessionTime: 1000, - }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); - }); - }); + (addTransactionBatch as jest.Mock).mockResolvedValue({}); - it('calls analytics.trackEvent for trackShareAction', () => { - withController(({ controller }) => { - controller.trackShareAction({ status: 'success' }); - expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + await expect(controller.initPayWithAnyToken()).resolves.toEqual({ + success: false, + error: 'Failed to get batch ID from transaction submission', + }); }); }); }); diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 2ef87af077a..d13d6a0dbc1 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -1,64 +1,82 @@ -import { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction } from '@metamask/account-tree-controller'; -import { isEvmAccountType } from '@metamask/keyring-api'; +import { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import { BaseController, ControllerGetStateAction, ControllerStateChangeEvent, StateMetadata, } from '@metamask/base-controller'; -import type { Messenger } from '@metamask/messenger'; import { ORIGIN_METAMASK } from '@metamask/controller-utils'; +import { isEvmAccountType } from '@metamask/keyring-api'; import { + KeyringControllerSignPersonalMessageAction, + KeyringControllerSignTypedMessageAction, PersonalMessageParams, SignTypedDataVersion, TypedMessageParams, - KeyringControllerSignTypedMessageAction, - KeyringControllerSignPersonalMessageAction, } from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; import { - NetworkControllerGetStateAction, NetworkControllerFindNetworkClientIdByChainIdAction, NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, } from '@metamask/network-controller'; import { - TransactionControllerTransactionStatusUpdatedEvent, + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerStateChangeEvent, +} from '@metamask/remote-feature-flag-controller'; +import { TransactionControllerEstimateGasAction, TransactionControllerTransactionConfirmedEvent, TransactionControllerTransactionFailedEvent, TransactionControllerTransactionRejectedEvent, + TransactionControllerTransactionStatusUpdatedEvent, TransactionControllerTransactionSubmittedEvent, TransactionMeta, TransactionStatus, TransactionType, } from '@metamask/transaction-controller'; -import { - RemoteFeatureFlagControllerGetStateAction, - RemoteFeatureFlagControllerStateChangeEvent, -} from '@metamask/remote-feature-flag-controller'; import { Hex, hexToNumber, numberToHex } from '@metamask/utils'; import performance from 'react-native-performance'; import { MetaMetricsEvents } from '../../../../core/Analytics'; -import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; -import { analytics } from '../../../../util/analytics/analytics'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Logger, { type LoggerErrorOptions } from '../../../../util/Logger'; +import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; +import { analytics } from '../../../../util/analytics/analytics'; +import { + validatedVersionGatedFeatureFlag, + VersionGatedFeatureFlag, +} from '../../../../util/remoteFeatureFlag'; import { - trace, endTrace, + trace, TraceName, TraceOperation, } from '../../../../util/trace'; import { addTransactionBatch } from '../../../../util/transaction-controller'; +import { AssetType } from '../../../Views/confirmations/types/token'; +import { PREDICT_CONSTANTS, PREDICT_ERROR_CODES } from '../constants/errors'; import { PredictEventProperties, PredictShareStatusValue, PredictTradeStatus, PredictTradeStatusValue, } from '../constants/eventNames'; -import { validateDepositTransactions } from '../utils/validateTransactions'; +import { + DEFAULT_FEE_COLLECTION_FLAG, + DEFAULT_LIVE_SPORTS_FLAG, + DEFAULT_MARKET_HIGHLIGHTS_FLAG, +} from '../constants/flags'; +import { GEO_BLOCKED_COUNTRIES } from '../constants/geoblock'; +import { filterSupportedLeagues } from '../constants/sports'; +import { PREDICT_BALANCE_PLACEHOLDER_ADDRESS } from '../constants/transactions'; import { PolymarketProvider } from '../providers/polymarket/PolymarketProvider'; +import { + MATIC_CONTRACTS, + POLYMARKET_PROVIDER_ID, +} from '../providers/polymarket/constants'; import { Signer } from '../providers/types'; +import { parse, PredictFeeCollectionSchema } from '../schemas'; import { AccountState, ActiveOrderState, @@ -94,31 +112,14 @@ import { Side, UnrealizedPnL, } from '../types'; -import { ensureError } from '../utils/predictErrorHandler'; -import { PREDICT_CONSTANTS, PREDICT_ERROR_CODES } from '../constants/errors'; -import { GEO_BLOCKED_COUNTRIES } from '../constants/geoblock'; -import { - MATIC_CONTRACTS, - POLYMARKET_PROVIDER_ID, -} from '../providers/polymarket/constants'; -import { - DEFAULT_FEE_COLLECTION_FLAG, - DEFAULT_LIVE_SPORTS_FLAG, - DEFAULT_MARKET_HIGHLIGHTS_FLAG, -} from '../constants/flags'; -import { filterSupportedLeagues } from '../constants/sports'; import { PredictFeatureFlags, PredictLiveSportsFlag, PredictMarketHighlightsFlag, } from '../types/flags'; -import { - VersionGatedFeatureFlag, - validatedVersionGatedFeatureFlag, -} from '../../../../util/remoteFeatureFlag'; import { unwrapRemoteFeatureFlag } from '../utils/flags'; -import { parse, PredictFeeCollectionSchema } from '../schemas'; -import { PREDICTION_ERROR_TRANSACTION_BATCH_ID } from '../constants/transactions'; +import { ensureError } from '../utils/predictErrorHandler'; +import { validateDepositTransactions } from '../utils/validateTransactions'; /** * State shape for PredictController @@ -150,10 +151,8 @@ export type PredictControllerState = { // TODO: change to be per-account basis withdrawTransaction: PredictWithdraw | null; - activeOrder?: { - amount?: number; - batchId?: string; - isInputFocused?: boolean; + activeBuyOrder: { + transactionId?: string; state: ActiveOrderState; error?: string; } | null; @@ -182,7 +181,7 @@ export const getDefaultPredictControllerState = (): PredictControllerState => ({ pendingDeposits: {}, pendingClaims: {}, withdrawTransaction: null, - activeOrder: null, + activeBuyOrder: null, selectedPaymentToken: null, accountMeta: {}, }); @@ -245,7 +244,7 @@ const metadata: StateMetadata = { includeInStateLogs: false, usedInUi: true, }, - activeOrder: { + activeBuyOrder: { persist: false, includeInDebugSnapshot: false, includeInStateLogs: false, @@ -262,7 +261,12 @@ const metadata: StateMetadata = { /** * PredictController events */ -export type PredictTransactionEventType = 'deposit' | 'claim' | 'withdraw'; +export type PredictTransactionEventType = + | 'deposit' + | 'depositAndOrder' + | 'claim' + | 'withdraw' + | 'order'; export type PredictTransactionEventStatus = | 'approved' @@ -279,6 +283,7 @@ export interface PredictControllerTransactionStatusChangedEvent { senderAddress: string; transactionId?: string; amount?: number; + marketId?: string; }, ]; } @@ -361,6 +366,14 @@ export class PredictController extends BaseController< > { private provider: PolymarketProvider; + private pendingOrderPreviews: { + [transactionId: string]: { + preview: OrderPreview; + signerAddress: string; + analyticsProperties?: PlaceOrderParams['analyticsProperties']; + }; + } = {}; + constructor({ messenger, state = {} }: PredictControllerOptions) { super({ name: 'PredictController', @@ -458,7 +471,10 @@ export class PredictController extends BaseController< const remoteFeatureFlagState = this.messenger.call( 'RemoteFeatureFlagController:getState', ); - const flags = remoteFeatureFlagState.remoteFeatureFlags; + const flags = { + ...(remoteFeatureFlagState.remoteFeatureFlags ?? {}), + ...(remoteFeatureFlagState.localOverrides ?? {}), + }; const liveSportsFlag = unwrapRemoteFeatureFlag(flags.predictLiveSports) ?? @@ -494,14 +510,29 @@ export class PredictController extends BaseController< ), ) ?? false; + const predictWithAnyTokenEnabled = + validatedVersionGatedFeatureFlag( + unwrapRemoteFeatureFlag( + flags.predictWithAnyToken, + ), + ) ?? false; + return { feeCollection, liveSportsLeagues, marketHighlightsFlag, fakOrdersEnabled, + predictWithAnyTokenEnabled, }; } + private isCurrentActiveBuyOrder(transactionId?: string): boolean { + if (!this.state.activeBuyOrder) return false; + if (!transactionId) return true; + if (!this.state.activeBuyOrder.transactionId) return false; + return this.state.activeBuyOrder.transactionId === transactionId; + } + private getEvmAccountAddress(): string { const accounts = this.messenger.call( 'AccountTreeController:getAccountsFromSelectedAccountGroup', @@ -1444,6 +1475,54 @@ export class PredictController extends BaseController< } async placeOrder(params: PlaceOrderParams): Promise { + const activeOrderAddress = params.address ?? this.getEvmAccountAddress(); + const { predictWithAnyTokenEnabled } = this.resolveFeatureFlags(); + const canUpdateActiveBuyOrder = this.isCurrentActiveBuyOrder( + params.transactionId, + ); + + const isExistingPendingOrder = + !!params.transactionId && + !!this.pendingOrderPreviews[params.transactionId]; + + if ( + predictWithAnyTokenEnabled && + this.state.activeBuyOrder?.state === + ActiveOrderState.PAY_WITH_ANY_TOKEN && + !isExistingPendingOrder + ) { + const transactionId = params.transactionId; + if (transactionId) { + this.pendingOrderPreviews[transactionId] = { + preview: params.preview, + signerAddress: activeOrderAddress, + analyticsProperties: params.analyticsProperties, + }; + } + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.state = ActiveOrderState.DEPOSITING; + state.activeBuyOrder.transactionId = transactionId; + } + }); + return { + success: false, + response: { status: 'deposit_in_progress' }, + } as unknown as Result; + } + + if ( + predictWithAnyTokenEnabled && + params.preview.side === Side.BUY && + canUpdateActiveBuyOrder + ) { + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.state = ActiveOrderState.PLACING_ORDER; + } + }); + } + const startTime = performance.now(); const { analyticsProperties, preview } = params; @@ -1478,7 +1557,10 @@ export class PredictController extends BaseController< try { const provider = this.provider; - const signer = this.getSigner(); + const signer = this.getSigner(activeOrderAddress); + + //await new Promise((resolve) => setTimeout(resolve, 1000)); + //throw new Error('Test error'); // Track Predict Trade Transaction with submitted status (fire and forget) this.trackPredictOrderEvent({ @@ -1504,6 +1586,18 @@ export class PredictController extends BaseController< throw new Error(result.error); } + if ( + predictWithAnyTokenEnabled && + preview.side === Side.BUY && + canUpdateActiveBuyOrder + ) { + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.state = ActiveOrderState.SUCCESS; + } + }); + } + const { spentAmount, receivedAmount } = result.response; const cachedBalance = this.state.balances[signer.address]?.balance ?? 0; @@ -1540,6 +1634,19 @@ export class PredictController extends BaseController< // If we can't get real share price, continue without it } + if ( + predictWithAnyTokenEnabled && + preview.side === Side.BUY && + !canUpdateActiveBuyOrder + ) { + this.messenger.publish('PredictController:transactionStatusChanged', { + type: 'order', + status: 'confirmed', + senderAddress: signer.address, + marketId: analyticsProperties?.marketId, + }); + } + // Track Predict Trade Transaction with succeeded status (fire and forget) this.trackPredictOrderEvent({ status: PredictTradeStatus.SUCCEEDED, @@ -1574,10 +1681,31 @@ export class PredictController extends BaseController< this.update((state) => { state.lastError = errorMessage; state.lastUpdateTimestamp = Date.now(); + if ( + predictWithAnyTokenEnabled && + preview.side === Side.BUY && + canUpdateActiveBuyOrder && + state.activeBuyOrder + ) { + state.activeBuyOrder.state = ActiveOrderState.PREVIEW; + state.activeBuyOrder.error = errorMessage; + } + if (canUpdateActiveBuyOrder) { + state.selectedPaymentToken = null; + } }); traceData = { success: false, error: errorMessage }; + if (!canUpdateActiveBuyOrder) { + this.messenger.publish('PredictController:transactionStatusChanged', { + type: 'order', + status: 'failed', + senderAddress: activeOrderAddress, + marketId: analyticsProperties?.marketId, + }); + } + // Log to Sentry with order context (excluding sensitive data like amounts) Logger.error( ensureError(error), @@ -1591,6 +1719,26 @@ export class PredictController extends BaseController< }), ); + if ( + predictWithAnyTokenEnabled && + canUpdateActiveBuyOrder && + this.state.activeBuyOrder?.transactionId + ) { + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.transactionId = undefined; + } + }); + this.initPayWithAnyToken().catch((err) => { + Logger.error( + ensureError(err), + this.getErrorContext('placeOrder', { + operation: 'initPayWithAnyToken', + }), + ); + }); + } + // Log error for debugging and future Sentry integration DevLogger.log('PredictController: Place order failed', { error: errorMessage, @@ -1602,6 +1750,12 @@ export class PredictController extends BaseController< throw new Error(errorMessage); } finally { + if ( + params.transactionId && + this.pendingOrderPreviews[params.transactionId] + ) { + delete this.pendingOrderPreviews[params.transactionId]; + } endTrace({ name: TraceName.PredictPlaceOrder, id: traceId, @@ -1927,15 +2081,73 @@ export class PredictController extends BaseController< this.update(updater); } - public setActiveOrder(order: PredictControllerState['activeOrder']): void { + public clearOrderError(): void { this.update((state) => { - state.activeOrder = order; + if (state.activeBuyOrder) { + delete state.activeBuyOrder.error; + } }); } + public onPlaceOrderEnd(): void { + this.update((state) => { + state.activeBuyOrder = null; + }); + this.setSelectedPaymentToken(null); + } + + public selectPaymentToken(token: AssetType | null): void { + if (!token) { + return; + } + + const isBalanceToken = + token.address === PREDICT_BALANCE_PLACEHOLDER_ADDRESS; + + this.setSelectedPaymentToken( + isBalanceToken + ? null + : { + address: token.address, + chainId: token.chainId ?? '', + symbol: token.symbol, + }, + ); + + const activeOrder = this.state.activeBuyOrder; + if (!activeOrder) { + return; + } + + this.clearOrderError(); + + if (activeOrder.state === ActiveOrderState.PAY_WITH_ANY_TOKEN) { + if (!isBalanceToken) { + return; + } + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.state = ActiveOrderState.PREVIEW; + } + }); + return; + } + + if (activeOrder.state === ActiveOrderState.PREVIEW) { + if (isBalanceToken) { + return; + } + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.state = ActiveOrderState.PAY_WITH_ANY_TOKEN; + } + }); + } + } + public clearActiveOrder(): void { this.update((state) => { - state.activeOrder = null; + state.activeBuyOrder = null; }); } @@ -2077,22 +2289,28 @@ export class PredictController extends BaseController< * type so the confirmation routing in `info-root.tsx` renders * `PredictPayWithAnyTokenInfo`. * - * TODO: Remove the cast once `predictDepositAndOrder` is added to - * `@metamask/transaction-controller`. */ - public async payWithAnyTokenConfirmation(): Promise< - Result<{ batchId: string }> - > { + public async initPayWithAnyToken(): Promise> { const provider = this.provider; - try { - const signer = this.getSigner(); - + if (!this.state.activeBuyOrder) { this.update((state) => { - if (state.activeOrder) { - delete state.activeOrder.batchId; - } + state.selectedPaymentToken = null; + state.activeBuyOrder = { + state: ActiveOrderState.PREVIEW, + }; }); + } + + const activeOrder = this.state.activeBuyOrder; + if (!activeOrder) { + throw new Error( + 'Active order is required for pay-with-any-token confirmation', + ); + } + + try { + const signer = this.getSigner(); const depositPreparation = await provider.prepareDeposit({ signer, @@ -2112,17 +2330,13 @@ export class PredictController extends BaseController< throw new Error('Chain ID not provided by deposit preparation'); } - // TODO: Remove cast once predictDepositAndOrder is in @metamask/transaction-controller - const predictDepositAndOrderType = - 'predictDepositAndOrder' as unknown as TransactionType; - // Override transaction types to predictDepositAndOrder so the // confirmation routing renders the deposit-and-order info component. const depositAndOrderTransactions = transactions.map((tx) => ({ ...tx, type: tx.type === TransactionType.predictDeposit - ? predictDepositAndOrderType + ? TransactionType.predictDepositAndOrder : tx.type, })); @@ -2169,9 +2383,8 @@ export class PredictController extends BaseController< const { batchId } = batchResult; this.update((state) => { - if (state.activeOrder) { - state.activeOrder.batchId = batchId; - delete state.activeOrder.error; + if (state.activeBuyOrder) { + delete state.activeBuyOrder.error; } }); @@ -2183,35 +2396,17 @@ export class PredictController extends BaseController< }; } catch (error) { const e = ensureError(error); - if (e.message.includes('User denied transaction signature')) { - this.update((state) => { - if (state.activeOrder) { - state.activeOrder = null; - } - }); - return { - success: true, - response: { batchId: PREDICTION_ERROR_TRANSACTION_BATCH_ID }, - }; - } - - const errorMessage = e.message ?? PREDICT_ERROR_CODES.DEPOSIT_FAILED; - - this.update((state) => { - if (state.activeOrder) { - state.activeOrder.error = errorMessage; - state.activeOrder.batchId = PREDICTION_ERROR_TRANSACTION_BATCH_ID; - } - }); - Logger.error( e, - this.getErrorContext('payWithAnyTokenConfirmation', { + this.getErrorContext('initPayWithAnyToken', { providerId: POLYMARKET_PROVIDER_ID, }), ); - throw new Error(errorMessage); + return { + success: false, + error: e.message, + }; } } @@ -2258,6 +2453,7 @@ export class PredictController extends BaseController< const nestedTransactionType = transactionMeta?.nestedTransactions?.find( ({ type }) => type === TransactionType.predictDeposit || + type === TransactionType.predictDepositAndOrder || type === TransactionType.predictClaim || type === TransactionType.predictWithdraw, )?.type; @@ -2296,7 +2492,7 @@ export class PredictController extends BaseController< }); try { - this.handleTransactionSideEffects(type, status, address); + this.handleTransactionSideEffects(type, status, address, transactionMeta); } catch (error) { Logger.error( ensureError(error), @@ -2323,6 +2519,7 @@ export class PredictController extends BaseController< type: PredictTransactionEventType, status: PredictTransactionEventStatus, address: string, + transactionMeta: TransactionMeta, ): void { const isTerminal = status === 'confirmed' || status === 'failed' || status === 'rejected'; @@ -2331,6 +2528,92 @@ export class PredictController extends BaseController< this.clearPendingDepositForAddress({ address }); } + if (type === 'depositAndOrder' && status === 'confirmed') { + const transactionId = transactionMeta.id; + const pendingOrder = transactionId + ? this.pendingOrderPreviews[transactionId] + : null; + + if (!pendingOrder) { + return; + } + + const { + preview, + signerAddress, + analyticsProperties: pendingAnalytics, + } = pendingOrder; + + this.placeOrder({ + analyticsProperties: pendingAnalytics, + preview, + address: signerAddress, + transactionId, + }).catch((error) => { + Logger.error( + ensureError(error), + this.getErrorContext('handleTransactionSideEffects', { + operation: 'placeOrder', + }), + ); + }); + } + + if (type === 'depositAndOrder' && status === 'failed') { + const transactionId = transactionMeta.id; + + // Extract market context before deleting the pending order preview + const pendingOrder = transactionId + ? this.pendingOrderPreviews[transactionId] + : null; + const marketId = pendingOrder?.analyticsProperties?.marketId; + + if (transactionId) { + delete this.pendingOrderPreviews[transactionId]; + } + + const canUpdateActiveBuyOrder = + this.isCurrentActiveBuyOrder(transactionId); + if (canUpdateActiveBuyOrder) { + const errorMessage = + transactionMeta.error?.message ?? PREDICT_ERROR_CODES.DEPOSIT_FAILED; + + this.update((state) => { + if (state.activeBuyOrder) { + state.activeBuyOrder.state = ActiveOrderState.PREVIEW; + state.activeBuyOrder.error = errorMessage; + state.activeBuyOrder.transactionId = undefined; + } + }); + this.initPayWithAnyToken().catch((error) => { + Logger.error( + ensureError(error), + this.getErrorContext('handleTransactionSideEffects', { + operation: 'initPayWithAnyToken', + }), + ); + }); + } else { + this.messenger.publish('PredictController:transactionStatusChanged', { + type: 'order', + status: 'failed', + senderAddress: address, + marketId, + }); + } + } + + if (type === 'depositAndOrder' && status === 'rejected') { + const transactionId = transactionMeta.id; + if (transactionId) { + delete this.pendingOrderPreviews[transactionId]; + } + + if (this.isCurrentActiveBuyOrder(transactionId)) { + this.onPlaceOrderEnd(); + } + } + if (type === 'claim' && isTerminal) { this.clearPendingClaimForAddress({ address }); } @@ -2446,6 +2729,7 @@ export class PredictController extends BaseController< Record > = { [TransactionType.predictDeposit]: 'deposit', + [TransactionType.predictDepositAndOrder]: 'depositAndOrder', [TransactionType.predictClaim]: 'claim', [TransactionType.predictWithdraw]: 'withdraw', }; diff --git a/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts b/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts index 41199bc7d2f..30df5665e9b 100644 --- a/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts +++ b/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts @@ -2,16 +2,14 @@ import { renderHook, act } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { usePredictActiveOrder } from './usePredictActiveOrder'; -import { ActiveOrderState, Recurrence } from '../types'; -import { PredictTradeStatus } from '../constants/eventNames'; +import { ActiveOrderState } from '../types'; jest.mock('../../../../core/Engine', () => ({ context: { PredictController: { - setActiveOrder: jest.fn(), clearActiveOrder: jest.fn(), - setSelectedPaymentToken: jest.fn(), - trackPredictOrderEvent: jest.fn(), + clearOrderError: jest.fn(), + initializeOrder: jest.fn(), }, }, })); @@ -20,351 +18,142 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -jest.mock('../utils/analytics', () => ({ - parseAnalyticsProperties: jest.fn(() => ({ marketId: 'market-1' })), -})); - const mockUseSelector = useSelector as jest.MockedFunction; describe('usePredictActiveOrder', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseSelector.mockReturnValue(undefined); - }); - - describe('updateActiveOrder', () => { - it('sets full order when state property is present', () => { - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ state: ActiveOrderState.PREVIEW }); - }); - - expect( - Engine.context.PredictController.setActiveOrder, - ).toHaveBeenCalledWith({ state: ActiveOrderState.PREVIEW }); - }); - - it('clears activeOrder when called with null', () => { - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder(null); - }); + mockUseSelector.mockImplementation((selector) => { + if (typeof selector === 'function') { + return selector({ + engine: { + backgroundState: { + PredictController: { + activeBuyOrder: { + state: ActiveOrderState.PREVIEW, + }, + }, + }, + }, + }); + } - expect( - Engine.context.PredictController.clearActiveOrder, - ).toHaveBeenCalled(); + return undefined; }); + }); - it('calls clearActiveOrder and setSelectedPaymentToken(null) when null', () => { + describe('clearOrderError', () => { + it('delegates to PredictController.clearOrderError', () => { const { result } = renderHook(() => usePredictActiveOrder()); act(() => { - result.current.updateActiveOrder(null); + result.current.clearOrderError(); }); expect( - Engine.context.PredictController.clearActiveOrder, + Engine.context.PredictController.clearOrderError, ).toHaveBeenCalled(); - expect( - Engine.context.PredictController.setSelectedPaymentToken, - ).toHaveBeenCalledWith(null); - }); - - it('deletes amount property when amount is null in patch', () => { - mockUseSelector.mockReturnValue({ - state: ActiveOrderState.PREVIEW, - amount: '100', - }); - - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ amount: null }); - }); - - const callArg = ( - Engine.context.PredictController.setActiveOrder as jest.Mock - ).mock.calls[0][0]; - expect(callArg).not.toHaveProperty('amount'); - }); - - it('deletes batchId property when batchId is null in patch', () => { - mockUseSelector.mockReturnValue({ - state: ActiveOrderState.PREVIEW, - batchId: 'batch-123', - }); - - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ batchId: null }); - }); - - const callArg = ( - Engine.context.PredictController.setActiveOrder as jest.Mock - ).mock.calls[0][0]; - expect(callArg).not.toHaveProperty('batchId'); - }); - - it('deletes isInputFocused when null in patch', () => { - mockUseSelector.mockReturnValue({ - state: ActiveOrderState.PREVIEW, - isInputFocused: true, - }); - - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ isInputFocused: null }); - }); - - const callArg = ( - Engine.context.PredictController.setActiveOrder as jest.Mock - ).mock.calls[0][0]; - expect(callArg).not.toHaveProperty('isInputFocused'); - }); - - it('deletes state when null in patch', () => { - mockUseSelector.mockReturnValue({ - state: ActiveOrderState.PREVIEW, - }); - - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ state: null }); - }); - - expect( - Engine.context.PredictController.setActiveOrder, - ).toHaveBeenCalledWith(null); - }); - - it('deletes error when null in patch', () => { - mockUseSelector.mockReturnValue({ - state: ActiveOrderState.PREVIEW, - error: 'some error', - }); - - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ error: null }); - }); - - const callArg = ( - Engine.context.PredictController.setActiveOrder as jest.Mock - ).mock.calls[0][0]; - expect(callArg).not.toHaveProperty('error'); }); + }); - it('merges patch with existing activeOrder state', () => { - mockUseSelector.mockReturnValue({ + describe('return values', () => { + it('returns activeOrder from useSelector', () => { + const mockActiveOrder = { state: ActiveOrderState.PREVIEW, - isInputFocused: true, - }); - - const { result } = renderHook(() => usePredictActiveOrder()); - - act(() => { - result.current.updateActiveOrder({ - state: ActiveOrderState.PLACING_ORDER, - }); - }); - - expect( - Engine.context.PredictController.setActiveOrder, - ).toHaveBeenCalledWith({ - state: ActiveOrderState.PLACING_ORDER, - isInputFocused: true, - }); - }); + }; + mockUseSelector.mockImplementation((selector) => { + if (typeof selector === 'function') { + return selector({ + engine: { + backgroundState: { + PredictController: { + activeBuyOrder: mockActiveOrder, + }, + }, + }, + }); + } - it('passes null to setActiveOrder when state is removed from nextOrder', () => { - mockUseSelector.mockReturnValue({ - state: ActiveOrderState.PREVIEW, - isInputFocused: true, + return undefined; }); const { result } = renderHook(() => usePredictActiveOrder()); - act(() => { - result.current.updateActiveOrder({ state: null }); - }); - - expect( - Engine.context.PredictController.setActiveOrder, - ).toHaveBeenCalledWith(null); + expect(result.current.activeOrder).toEqual(mockActiveOrder); }); - }); - - describe('initializeActiveOrder', () => { - it('sets state to PREVIEW and isInputFocused to true', () => { - const { result } = renderHook(() => usePredictActiveOrder()); - act(() => { - result.current.initializeActiveOrder({ - market: { - id: 'market-1', - providerId: 'provider-1', - slug: 'market-slug', - title: 'Market Title', - description: 'Market Description', - image: 'image-url', - status: 'open', - recurrence: Recurrence.NONE, - category: 'trending' as const, - tags: [], - outcomes: [], - liquidity: 1000, - volume: 5000, - }, - outcomeToken: { id: 'token-1', title: 'Yes', price: 0.6 }, - }); - }); + it('returns isDepositing when active order is depositing', () => { + mockUseSelector.mockImplementation((selector) => { + if (typeof selector === 'function') { + return selector({ + engine: { + backgroundState: { + PredictController: { + activeBuyOrder: { + state: ActiveOrderState.DEPOSITING, + }, + }, + }, + }, + }); + } - expect( - Engine.context.PredictController.setActiveOrder, - ).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - isInputFocused: true, + return undefined; }); - }); - it('calls setSelectedPaymentToken with null', () => { const { result } = renderHook(() => usePredictActiveOrder()); - act(() => { - result.current.initializeActiveOrder({ - market: { - id: 'market-1', - providerId: 'provider-1', - slug: 'market-slug', - title: 'Market Title', - description: 'Market Description', - image: 'image-url', - status: 'open', - recurrence: Recurrence.NONE, - category: 'trending' as const, - tags: [], - outcomes: [], - liquidity: 1000, - volume: 5000, - }, - outcomeToken: { id: 'token-1', title: 'Yes', price: 0.6 }, - }); - }); - - expect( - Engine.context.PredictController.setSelectedPaymentToken, - ).toHaveBeenCalledWith(null); + expect(result.current.isDepositing).toBe(true); + expect(result.current.isPlacingOrder).toBe(true); }); - it('calls trackPredictOrderEvent with INITIATED status', () => { - const { result } = renderHook(() => usePredictActiveOrder()); + it('returns isPlacingOrder when active order is placing order', () => { + mockUseSelector.mockImplementation((selector) => { + if (typeof selector === 'function') { + return selector({ + engine: { + backgroundState: { + PredictController: { + activeBuyOrder: { + state: ActiveOrderState.PLACING_ORDER, + }, + }, + }, + }, + }); + } - act(() => { - result.current.initializeActiveOrder({ - market: { - id: 'market-1', - providerId: 'provider-1', - slug: 'market-slug', - title: 'Market Title', - description: 'Market Description', - image: 'image-url', - status: 'open', - recurrence: Recurrence.NONE, - category: 'trending' as const, - tags: [], - outcomes: [], - liquidity: 1000, - volume: 5000, - }, - outcomeToken: { id: 'token-1', title: 'Yes', price: 0.6 }, - }); + return undefined; }); - expect( - Engine.context.PredictController.trackPredictOrderEvent, - ).toHaveBeenCalledWith( - expect.objectContaining({ status: PredictTradeStatus.INITIATED }), - ); - }); - - it('passes parsed analytics properties from market/outcomeToken/entryPoint', () => { - const { parseAnalyticsProperties } = jest.requireMock( - '../utils/analytics', - ) as { parseAnalyticsProperties: jest.Mock }; - - const mockMarket = { - id: 'market-1', - providerId: 'provider-1', - slug: 'market-slug', - title: 'Market Title', - description: 'Market Description', - image: 'image-url', - status: 'open' as const, - recurrence: Recurrence.NONE, - category: 'trending' as const, - tags: [], - outcomes: [], - liquidity: 1000, - volume: 5000, - }; - const mockOutcomeToken = { id: 'token-1', title: 'Yes', price: 0.6 }; - const mockEntryPoint = 'carousel' as const; - const { result } = renderHook(() => usePredictActiveOrder()); - act(() => { - result.current.initializeActiveOrder({ - market: mockMarket, - outcomeToken: mockOutcomeToken, - entryPoint: mockEntryPoint, - }); - }); - - expect(parseAnalyticsProperties).toHaveBeenCalledWith( - mockMarket, - mockOutcomeToken, - mockEntryPoint, - ); - expect( - Engine.context.PredictController.trackPredictOrderEvent, - ).toHaveBeenCalledWith({ - status: PredictTradeStatus.INITIATED, - analyticsProperties: { marketId: 'market-1' }, - }); + expect(result.current.isDepositing).toBe(false); + expect(result.current.isPlacingOrder).toBe(true); }); - }); - describe('clearActiveOrder', () => { - it('calls PredictController.clearActiveOrder', () => { - const { result } = renderHook(() => usePredictActiveOrder()); + it('returns false flags when there is no active buy order', () => { + mockUseSelector.mockImplementation((selector) => { + if (typeof selector === 'function') { + return selector({ + engine: { + backgroundState: { + PredictController: { + activeBuyOrder: null, + }, + }, + }, + }); + } - act(() => { - result.current.clearActiveOrder(); + return undefined; }); - expect( - Engine.context.PredictController.clearActiveOrder, - ).toHaveBeenCalled(); - }); - }); - - describe('return values', () => { - it('returns activeOrder from useSelector', () => { - const mockActiveOrder = { - state: ActiveOrderState.PREVIEW, - isInputFocused: true, - }; - mockUseSelector.mockReturnValue(mockActiveOrder); - const { result } = renderHook(() => usePredictActiveOrder()); - expect(result.current.activeOrder).toEqual(mockActiveOrder); + expect(result.current.activeOrder).toBeNull(); + expect(result.current.isDepositing).toBe(false); + expect(result.current.isPlacingOrder).toBe(false); }); }); }); diff --git a/app/components/UI/Predict/hooks/usePredictActiveOrder.ts b/app/components/UI/Predict/hooks/usePredictActiveOrder.ts index 35f6020d759..9ddac26d0b5 100644 --- a/app/components/UI/Predict/hooks/usePredictActiveOrder.ts +++ b/app/components/UI/Predict/hooks/usePredictActiveOrder.ts @@ -1,21 +1,10 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; -import { PredictControllerState } from '../controllers/PredictController'; -import { selectPredictActiveOrder } from '../selectors/predictController'; -import { parseAnalyticsProperties } from '../utils/analytics'; -import { PredictTradeStatus } from '../constants/eventNames'; +import { selectPredictActiveBuyOrder } from '../selectors/predictController'; import { ActiveOrderState, PredictMarket, PredictOutcomeToken } from '../types'; import { PredictEntryPoint } from '../types/navigation'; -type PredictActiveOrder = PredictControllerState['activeOrder']; -type PredictActiveOrderValue = NonNullable; -type PredictActiveOrderPatch = - | { - [K in keyof PredictActiveOrderValue]?: PredictActiveOrderValue[K] | null; - } - | null; - export interface InitializeActiveOrderParams { market: PredictMarket; outcomeToken: PredictOutcomeToken; @@ -25,97 +14,28 @@ export interface InitializeActiveOrderParams { export const usePredictActiveOrder = () => { const { PredictController } = Engine.context; - const activeOrder = useSelector(selectPredictActiveOrder); - - const activeOrderRef = useRef(activeOrder); - activeOrderRef.current = activeOrder; - - const updateActiveOrder = useCallback( - (order: PredictActiveOrderPatch) => { - if (order === null) { - PredictController.clearActiveOrder(); - PredictController.setSelectedPaymentToken(null); - return; - } - - const nextOrder: Partial = { - ...(activeOrderRef.current ?? {}), - }; - - if ('amount' in order) { - if (order.amount === null) { - delete nextOrder.amount; - } else { - nextOrder.amount = order.amount; - } - } + const activeOrder = useSelector(selectPredictActiveBuyOrder); - if ('batchId' in order) { - if (order.batchId === null) { - delete nextOrder.batchId; - } else { - nextOrder.batchId = order.batchId; - } - } - - if ('isInputFocused' in order) { - if (order.isInputFocused === null) { - delete nextOrder.isInputFocused; - } else { - nextOrder.isInputFocused = order.isInputFocused; - } - } - - if ('state' in order) { - if (order.state === null) { - delete nextOrder.state; - } else { - nextOrder.state = order.state; - } - } + const clearOrderError = useCallback(() => { + PredictController.clearOrderError(); + }, [PredictController]); - if ('error' in order) { - if (order.error === null) { - delete nextOrder.error; - } else { - nextOrder.error = order.error; - } - } + const currentState = useMemo(() => activeOrder?.state, [activeOrder]); - PredictController.setActiveOrder( - nextOrder.state ? (nextOrder as PredictActiveOrderValue) : null, - ); - }, - [PredictController], + const isDepositing = useMemo( + () => currentState === ActiveOrderState.DEPOSITING, + [currentState], ); - const initializeActiveOrder = useCallback( - (params: InitializeActiveOrderParams) => { - updateActiveOrder({ - state: ActiveOrderState.PREVIEW, - isInputFocused: true, - }); - PredictController.setSelectedPaymentToken(null); - PredictController.trackPredictOrderEvent({ - status: PredictTradeStatus.INITIATED, - analyticsProperties: parseAnalyticsProperties( - params.market, - params.outcomeToken, - params.entryPoint, - ), - }); - }, - [updateActiveOrder, PredictController], + const isPlacingOrder = useMemo( + () => currentState === ActiveOrderState.PLACING_ORDER || isDepositing, + [currentState, isDepositing], ); - const clearActiveOrder = useCallback(() => { - PredictController.clearActiveOrder(); - }, [PredictController]); - return { activeOrder, - updateActiveOrder, - clearActiveOrder, - initializeActiveOrder, + isDepositing, + isPlacingOrder, + clearOrderError, }; }; diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts index 8a1ae0b2edb..de9e31a6bb9 100644 --- a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts +++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts @@ -1,4 +1,5 @@ import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; import { AssetType } from '../../../Views/confirmations/types/token'; import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; import { @@ -36,13 +37,22 @@ jest.mock('../../SimulationDetails/FiatDisplay/useFiatFormatter', () => ({ `$${Number(value.toString()).toFixed(2)}`, })); +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + jest.mock('../../../Views/confirmations/utils/transaction', () => ({ hasTransactionType: jest.fn(), })); +jest.mock('../../../../util/networks', () => ({ + getNetworkImageSource: jest.fn(() => 'polygon-network-badge'), +})); + const mockHasTransactionType = hasTransactionType as jest.MockedFunction< typeof hasTransactionType >; +const mockUseSelector = useSelector as jest.MockedFunction; const createMockToken = (overrides?: Partial): AssetType => ({ address: '0xtoken1', @@ -68,6 +78,7 @@ describe('usePredictBalanceTokenFilter', () => { mockPredictBalance = 100; mockTransactionMeta = null; mockHasTransactionType.mockReturnValue(false); + mockUseSelector.mockReturnValue({ image: 'usdce-token-image' }); }); it('returns original tokens when transaction type does not match and forceEnabled is false', () => { @@ -176,4 +187,26 @@ describe('usePredictBalanceTokenFilter', () => { expect(filteredTokens[0].symbol).toBe('USDC.e'); }); + + it('uses empty string for image when usdceToken is null', () => { + mockHasTransactionType.mockReturnValue(true); + mockUseSelector.mockReturnValue(null); + const tokens = [createMockToken()]; + + const { result } = renderHook(() => usePredictBalanceTokenFilter()); + const filteredTokens = result.current(tokens); + + expect(filteredTokens[0].image).toBe(''); + expect(filteredTokens[0].logo).toBe(''); + }); + + it('adds the polygon network badge to the synthetic token', () => { + mockHasTransactionType.mockReturnValue(true); + const tokens = [createMockToken()]; + + const { result } = renderHook(() => usePredictBalanceTokenFilter()); + const filteredTokens = result.current(tokens); + + expect(filteredTokens[0].networkBadgeSource).toBe('polygon-network-badge'); + }); }); diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts index 7aa3ac7f69a..39de614cbe8 100644 --- a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts +++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts @@ -1,6 +1,12 @@ import { BigNumber } from 'bignumber.js'; import { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../../../reducers'; +import { selectSingleTokenByAddressAndChainId } from '../../../../selectors/tokensController'; +import { getNetworkImageSource } from '../../../../util/networks'; import useFiatFormatter from '../../SimulationDetails/FiatDisplay/useFiatFormatter'; +import { POLYGON_USDCE } from '../../../Views/confirmations/constants/predict'; +import { TransactionType } from '@metamask/transaction-controller'; import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import { AssetType } from '../../../Views/confirmations/types/token'; import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; @@ -10,10 +16,6 @@ import { } from '../constants/transactions'; import { usePredictBalance } from './usePredictBalance'; import { usePredictPaymentToken } from './usePredictPaymentToken'; -import { TransactionType } from '@metamask/transaction-controller'; - -//TODO: Remove this once the predictDepositAndOrder type is added to the transaction controller -const PREDICT_DEPOSIT_AND_ORDER_TYPE = 'predictDepositAndOrder'; export function usePredictBalanceTokenFilter( forceEnabled = false, @@ -22,14 +24,20 @@ export function usePredictBalanceTokenFilter( const { isPredictBalanceSelected } = usePredictPaymentToken(); const { data: predictBalance = 0 } = usePredictBalance(); const formatFiat = useFiatFormatter({ currency: 'usd' }); + const usdceToken = useSelector((state: RootState) => + selectSingleTokenByAddressAndChainId( + state, + POLYGON_USDCE.address, + PREDICT_BALANCE_CHAIN_ID, + ), + ); return useCallback( (tokens: AssetType[]): AssetType[] => { if ( !forceEnabled && !hasTransactionType(transactionMeta, [ - // TODO: Remove this once the predictDepositAndOrder type is added to the transaction controller - PREDICT_DEPOSIT_AND_ORDER_TYPE as TransactionType, + TransactionType.predictDepositAndOrder, ]) ) { return tokens; @@ -46,8 +54,11 @@ export function usePredictBalanceTokenFilter( symbol: 'USDC.e', balance: balanceStr, balanceInSelectedCurrency: balanceFormatted, - image: '', - logo: '', + image: usdceToken?.image ?? '', + logo: usdceToken?.image ?? '', + networkBadgeSource: getNetworkImageSource({ + chainId: PREDICT_BALANCE_CHAIN_ID, + }), decimals: 6, isETH: false, isNative: false, @@ -70,6 +81,7 @@ export function usePredictBalanceTokenFilter( isPredictBalanceSelected, predictBalance, formatFiat, + usdceToken, ], ); } diff --git a/app/components/UI/Predict/hooks/usePredictClaim.test.ts b/app/components/UI/Predict/hooks/usePredictClaim.test.ts index 6842d3ca917..0088cb4e308 100644 --- a/app/components/UI/Predict/hooks/usePredictClaim.test.ts +++ b/app/components/UI/Predict/hooks/usePredictClaim.test.ts @@ -159,8 +159,8 @@ describe('usePredictClaim', () => { getBalance: jest.fn(), previewOrder: jest.fn(), deposit: jest.fn(), - payWithAnyTokenConfirmation: jest.fn(), prepareWithdraw: jest.fn(), + initPayWithAnyToken: jest.fn(), } as ReturnType); mockUseConfirmNavigation.mockReturnValue({ diff --git a/app/components/UI/Predict/hooks/usePredictNavigation.test.ts b/app/components/UI/Predict/hooks/usePredictNavigation.test.ts index a24742bbffe..e6adc094d80 100644 --- a/app/components/UI/Predict/hooks/usePredictNavigation.test.ts +++ b/app/components/UI/Predict/hooks/usePredictNavigation.test.ts @@ -7,7 +7,6 @@ import { PredictMarket, PredictOutcome, PredictOutcomeToken } from '../types'; const mockNavigate = jest.fn(); const mockDispatch = jest.fn(); -const mockInitializeActiveOrder = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -17,15 +16,6 @@ jest.mock('@react-navigation/native', () => ({ }), })); -jest.mock('./usePredictActiveOrder', () => ({ - usePredictActiveOrder: () => ({ - initializeActiveOrder: mockInitializeActiveOrder, - activeOrder: null, - updateActiveOrder: jest.fn(), - clearActiveOrder: jest.fn(), - }), -})); - const createMockParams = ( overrides?: Partial, ): PredictBuyPreviewParams => ({ @@ -99,26 +89,6 @@ describe('usePredictNavigation', () => { ); }); - it('passes all params to the navigation call', () => { - const { result } = renderHook(() => usePredictNavigation()); - const params = createMockParams({ - isConfirmation: true, - animationEnabled: false, - }); - - act(() => { - result.current.navigateToBuyPreview(params); - }); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.PREDICT.MODALS.BUY_PREVIEW, - expect.objectContaining({ - isConfirmation: true, - animationEnabled: false, - }), - ); - }); - it('passes all params through ROOT navigation', () => { const { result } = renderHook(() => usePredictNavigation()); const params = createMockParams({ @@ -137,22 +107,6 @@ describe('usePredictNavigation', () => { }); }); - it('dispatches StackActions.replace when replace option is true', () => { - const { result } = renderHook(() => usePredictNavigation()); - const params = createMockParams({ - animationEnabled: false, - }); - - act(() => { - result.current.navigateToBuyPreview(params, { replace: true }); - }); - - expect(mockDispatch).toHaveBeenCalledWith( - StackActions.replace(Routes.PREDICT.MODALS.BUY_PREVIEW, params), - ); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - it('replace takes precedence over throughRoot', () => { const { result } = renderHook(() => usePredictNavigation()); const params = createMockParams(); @@ -170,7 +124,7 @@ describe('usePredictNavigation', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); - it('calls initializeActiveOrder on direct navigation', () => { + it('does not initialize active order on direct navigation', () => { const { result } = renderHook(() => usePredictNavigation()); const params = createMockParams(); @@ -178,14 +132,10 @@ describe('usePredictNavigation', () => { result.current.navigateToBuyPreview(params); }); - expect(mockInitializeActiveOrder).toHaveBeenCalledWith({ - market: params.market, - outcomeToken: params.outcomeToken, - entryPoint: params.entryPoint, - }); + expect(mockDispatch).not.toHaveBeenCalled(); }); - it('calls initializeActiveOrder on throughRoot navigation', () => { + it('does not dispatch a replace action on throughRoot navigation', () => { const { result } = renderHook(() => usePredictNavigation()); const params = createMockParams(); @@ -193,14 +143,10 @@ describe('usePredictNavigation', () => { result.current.navigateToBuyPreview(params, { throughRoot: true }); }); - expect(mockInitializeActiveOrder).toHaveBeenCalledWith({ - market: params.market, - outcomeToken: params.outcomeToken, - entryPoint: params.entryPoint, - }); + expect(mockDispatch).not.toHaveBeenCalled(); }); - it('does not call initializeActiveOrder on replace navigation', () => { + it('dispatches replace navigation when replace is true', () => { const { result } = renderHook(() => usePredictNavigation()); const params = createMockParams(); @@ -208,7 +154,7 @@ describe('usePredictNavigation', () => { result.current.navigateToBuyPreview(params, { replace: true }); }); - expect(mockInitializeActiveOrder).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledTimes(1); }); }); }); diff --git a/app/components/UI/Predict/hooks/usePredictNavigation.ts b/app/components/UI/Predict/hooks/usePredictNavigation.ts index a50695caf12..ef723eb2497 100644 --- a/app/components/UI/Predict/hooks/usePredictNavigation.ts +++ b/app/components/UI/Predict/hooks/usePredictNavigation.ts @@ -2,7 +2,6 @@ import { StackActions, useNavigation } from '@react-navigation/native'; import { useCallback } from 'react'; import Routes from '../../../../constants/navigation/Routes'; import { PredictBuyPreviewParams } from '../types/navigation'; -import { usePredictActiveOrder } from './usePredictActiveOrder'; interface NavigateToBuyPreviewOptions { throughRoot?: boolean; @@ -11,7 +10,6 @@ interface NavigateToBuyPreviewOptions { export const usePredictNavigation = () => { const navigation = useNavigation(); - const { initializeActiveOrder } = usePredictActiveOrder(); const navigateToBuyPreview = useCallback( ( @@ -22,24 +20,16 @@ export const usePredictNavigation = () => { navigation.dispatch( StackActions.replace(Routes.PREDICT.MODALS.BUY_PREVIEW, params), ); - } else { - initializeActiveOrder({ - market: params.market, - outcomeToken: params.outcomeToken, - entryPoint: params.entryPoint, + } else if (options?.throughRoot) { + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MODALS.BUY_PREVIEW, + params, }); - - if (options?.throughRoot) { - navigation.navigate(Routes.PREDICT.ROOT, { - screen: Routes.PREDICT.MODALS.BUY_PREVIEW, - params, - }); - } else { - navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, params); - } + } else { + navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, params); } }, - [navigation, initializeActiveOrder], + [navigation], ); return { navigateToBuyPreview }; diff --git a/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts b/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts index e89a4ad0b83..7489f49772b 100644 --- a/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts +++ b/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts @@ -92,54 +92,6 @@ describe('usePredictOrderPreview', () => { expect(result.current.error).toBeNull(); }); - it('initializes with initialPreview when provided', () => { - const { Wrapper } = createWrapper(); - const { result } = renderHook( - () => - usePredictOrderPreview({ - ...defaultParams, - initialPreview: mockPreview, - }), - { wrapper: Wrapper }, - ); - - expect(result.current.preview).toEqual(mockPreview); - expect(result.current.isCalculating).toBe(true); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBeNull(); - }); - - it('replaces initialPreview when new preview loads from API', async () => { - const { Wrapper } = createWrapper(); - const updatedPreview: OrderPreview = { - ...mockPreview, - sharePrice: 0.75, - maxAmountSpent: 200, - }; - mockPreviewOrder.mockResolvedValue(updatedPreview); - - const { result } = renderHook( - () => - usePredictOrderPreview({ - ...defaultParams, - initialPreview: mockPreview, - }), - { wrapper: Wrapper }, - ); - - expect(result.current.preview).toEqual(mockPreview); - - act(() => { - jest.advanceTimersByTime(100); - }); - - await waitFor(() => { - expect(result.current.preview).toEqual(updatedPreview); - }); - - expect(result.current.isLoading).toBe(false); - }); - it('calculates preview when size is valid', async () => { const { Wrapper } = createWrapper(); const { result } = renderHook( @@ -350,15 +302,11 @@ describe('usePredictOrderPreview', () => { }); describe('error handling', () => { - it('does not log an error when only initialPreview is provided', async () => { + it('does not log an error when preview loads from API', async () => { const { Wrapper } = createWrapper(); const { result } = renderHook( - () => - usePredictOrderPreview({ - ...defaultParams, - initialPreview: mockPreview, - }), + () => usePredictOrderPreview(defaultParams), { wrapper: Wrapper }, ); diff --git a/app/components/UI/Predict/hooks/usePredictOrderPreview.ts b/app/components/UI/Predict/hooks/usePredictOrderPreview.ts index e32b931bf83..80d8c6605ea 100644 --- a/app/components/UI/Predict/hooks/usePredictOrderPreview.ts +++ b/app/components/UI/Predict/hooks/usePredictOrderPreview.ts @@ -21,7 +21,6 @@ interface OrderPreviewResult { export function usePredictOrderPreview( params: PreviewOrderParams & { autoRefreshTimeout?: number; - initialPreview?: OrderPreview | null; }, ): OrderPreviewResult { // Destructure params for stable dependencies @@ -68,9 +67,7 @@ export function usePredictOrderPreview( hasValidSize && autoRefreshTimeout ? autoRefreshTimeout : false, }); - const preview = hasValidSize - ? (query.data ?? params.initialPreview ?? null) - : (params.initialPreview ?? null); + const preview = hasValidSize ? (query.data ?? null) : null; const error = query.error ? parseErrorMessage({ error: query.error, diff --git a/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.test.ts b/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.test.ts deleted file mode 100644 index 8ff7254ab12..00000000000 --- a/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { act, renderHook } from '@testing-library/react-native'; -import React from 'react'; -import { ToastContext } from '../../../../component-library/components/Toast'; -import { PredictBuyPreviewParams } from '../types/navigation'; -import { usePredictPayWithAnyToken } from './usePredictPayWithAnyToken'; -import { ConfirmationLoader } from '../../../Views/confirmations/components/confirm/confirm-component'; - -const mockGoBack = jest.fn(); -const mockPayWithAnyTokenConfirmation = jest.fn(); -const mockNavigateToConfirmation = jest.fn(); -const mockShowToast = jest.fn(); -const mockCloseToast = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - goBack: mockGoBack, - }), -})); - -jest.mock('./usePredictTrading', () => ({ - usePredictTrading: () => ({ - payWithAnyTokenConfirmation: mockPayWithAnyTokenConfirmation, - }), -})); - -jest.mock('../../../Views/confirmations/hooks/useConfirmNavigation', () => ({ - useConfirmNavigation: () => ({ - navigateToConfirmation: mockNavigateToConfirmation, - }), -})); - -jest.mock('../../../Views/confirmations/hooks/tokens/useAddToken', () => ({ - useAddToken: jest.fn(), -})); - -jest.mock('../../../../../locales/i18n', () => ({ - strings: (key: string) => key, -})); - -jest.mock('../../../../util/theme', () => ({ - useAppThemeFromContext: () => ({ - colors: { - error: { default: 'red' }, - accent04: { normal: 'black' }, - }, - }), -})); - -const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement( - ToastContext.Provider, - { - value: { - toastRef: { - current: { - showToast: mockShowToast, - closeToast: mockCloseToast, - }, - }, - }, - }, - children, - ); - -describe('usePredictPayWithAnyToken', () => { - const market = { id: 'market-1' } as PredictBuyPreviewParams['market']; - const outcome = { id: 'outcome-1' } as PredictBuyPreviewParams['outcome']; - const outcomeToken = { - id: 'token-1', - } as PredictBuyPreviewParams['outcomeToken']; - - beforeEach(() => { - jest.clearAllMocks(); - mockPayWithAnyTokenConfirmation.mockResolvedValue({ response: {} }); - }); - - it('triggers payWithAnyTokenConfirmation and navigates to confirmation', async () => { - const { result } = renderHook(() => usePredictPayWithAnyToken(), { - wrapper, - }); - - await act(async () => { - result.current.triggerPayWithAnyToken({ - market, - outcome, - outcomeToken, - }); - }); - - expect(mockPayWithAnyTokenConfirmation).toHaveBeenCalledWith(); - expect(mockNavigateToConfirmation).toHaveBeenCalledWith({ - loader: ConfirmationLoader.CustomAmount, - headerShown: false, - }); - expect(mockGoBack).not.toHaveBeenCalled(); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('goes back and shows error toast when payWithAnyTokenConfirmation fails', async () => { - mockPayWithAnyTokenConfirmation.mockImplementation(() => { - throw new Error('boom'); - }); - - const { result } = renderHook(() => usePredictPayWithAnyToken(), { - wrapper, - }); - - await act(async () => { - result.current.triggerPayWithAnyToken({ - market, - outcome, - outcomeToken, - }); - }); - - expect(mockGoBack).toHaveBeenCalledTimes(1); - expect(mockShowToast).toHaveBeenCalledTimes(1); - expect(mockNavigateToConfirmation).not.toHaveBeenCalled(); - }); -}); diff --git a/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.ts b/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.ts deleted file mode 100644 index ef371409a9a..00000000000 --- a/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { CHAIN_IDS } from '@metamask/transaction-controller'; -import { NavigationProp, useNavigation } from '@react-navigation/native'; -import { useCallback, useContext } from 'react'; -import { ToastContext } from '../../../../component-library/components/Toast'; -import Logger from '../../../../util/Logger'; -import { useAppThemeFromContext } from '../../../../util/theme'; -import { ConfirmationLoader } from '../../../Views/confirmations/components/confirm/confirm-component'; -import { POLYGON_USDCE } from '../../../Views/confirmations/constants/predict'; -import { useAddToken } from '../../../Views/confirmations/hooks/tokens/useAddToken'; -import { useConfirmNavigation } from '../../../Views/confirmations/hooks/useConfirmNavigation'; -import { PREDICT_CONSTANTS } from '../constants/errors'; -import { - PredictBuyPreviewParams, - PredictNavigationParamList, -} from '../types/navigation'; -import { - createDepositErrorToast, - ensureError, -} from '../utils/predictErrorHandler'; -import { usePredictTrading } from './usePredictTrading'; -import { OrderPreview } from '../types'; - -export interface PredictPayWithAnyTokenParams { - market: PredictBuyPreviewParams['market']; - outcome: PredictBuyPreviewParams['outcome']; - outcomeToken: PredictBuyPreviewParams['outcomeToken']; - preview?: OrderPreview; -} - -interface UsePredictPayWithAnyTokenResult { - triggerPayWithAnyToken: (params: PredictPayWithAnyTokenParams) => void; -} - -export function usePredictPayWithAnyToken(): UsePredictPayWithAnyTokenResult { - const { navigateToConfirmation } = useConfirmNavigation(); - const theme = useAppThemeFromContext(); - const { toastRef } = useContext(ToastContext); - const navigation = - useNavigation>(); - - useAddToken({ - chainId: CHAIN_IDS.POLYGON, - decimals: POLYGON_USDCE.decimals, - name: POLYGON_USDCE.name, - symbol: POLYGON_USDCE.symbol, - tokenAddress: POLYGON_USDCE.address, - }); - - const { payWithAnyTokenConfirmation } = usePredictTrading(); - - const handleDepositError = useCallback( - (err: unknown, action: string) => { - Logger.error(ensureError(err), { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - component: 'usePredictPayWithAnyToken', - }, - context: { - name: 'usePredictPayWithAnyToken', - data: { - method: 'triggerPayWithAnyToken', - action, - operation: 'financial_operations', - }, - }, - }); - - navigation.goBack(); - toastRef?.current?.showToast(createDepositErrorToast(theme)); - }, - [navigation, theme, toastRef], - ); - - const triggerPayWithAnyToken = useCallback( - //(params: PredictPayWithAnyTokenParams) => { - () => { - // TODO: Uncomment this when the confirmation screen is ready - try { - payWithAnyTokenConfirmation(); - navigateToConfirmation({ - loader: ConfirmationLoader.CustomAmount, - headerShown: false, - /* replace: true, - routeParams: { - market: params.market, - outcome: params.outcome, - outcomeToken: params.outcomeToken, - isConfirmation: true, - preview: params.preview, - }, */ - }); - } catch (err) { - handleDepositError(err, 'pay_with_any_token'); - } - }, - [payWithAnyTokenConfirmation, handleDepositError, navigateToConfirmation], - ); - - return { - triggerPayWithAnyToken, - }; -} diff --git a/app/components/UI/Predict/hooks/usePredictPaymentToken.test.ts b/app/components/UI/Predict/hooks/usePredictPaymentToken.test.ts index 5ec5ccd0533..ad6ecc5bc3d 100644 --- a/app/components/UI/Predict/hooks/usePredictPaymentToken.test.ts +++ b/app/components/UI/Predict/hooks/usePredictPaymentToken.test.ts @@ -1,6 +1,5 @@ import { act, renderHook } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; -import { Hex } from '@metamask/utils'; import { usePredictPaymentToken } from './usePredictPaymentToken'; import { PREDICT_BALANCE_PLACEHOLDER_ADDRESS } from '../constants/transactions'; import Engine from '../../../../core/Engine'; @@ -11,9 +10,6 @@ let mockSelectedPaymentToken: { chainId: string; symbol?: string; } | null = null; -let mockTransactionMeta: { id: string } | null = null; -let mockPayToken: { address: Hex; chainId: Hex } | null = null; -const mockSetPayToken = jest.fn(); const createMockAsset = (overrides?: Partial): AssetType => ({ address: '0x1234', @@ -32,26 +28,10 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -jest.mock( - '../../../Views/confirmations/hooks/pay/useTransactionPayToken', - () => ({ - useTransactionPayToken: () => ({ - payToken: mockPayToken, - setPayToken: mockSetPayToken, - }), - }), -); - -jest.mock( - '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest', - () => ({ - useTransactionMetadataRequest: () => mockTransactionMeta, - }), -); - jest.mock('../../../../core/Engine', () => ({ context: { PredictController: { + selectPaymentToken: jest.fn(), setSelectedPaymentToken: jest.fn(), }, }, @@ -61,95 +41,19 @@ describe('usePredictPaymentToken', () => { beforeEach(() => { jest.clearAllMocks(); mockSelectedPaymentToken = null; - mockTransactionMeta = null; - mockPayToken = null; jest.mocked(useSelector).mockImplementation(() => mockSelectedPaymentToken); jest .mocked(Engine.context.PredictController.setSelectedPaymentToken) .mockClear(); + jest + .mocked(Engine.context.PredictController.selectPaymentToken) + .mockClear(); }); afterEach(() => { jest.resetAllMocks(); }); - it('does not call onTokenSelected on initial render', () => { - const onTokenSelected = jest.fn(); - - renderHook(() => usePredictPaymentToken({ onTokenSelected })); - - expect(onTokenSelected).not.toHaveBeenCalled(); - }); - - it('calls onTokenSelected when token changes from predict balance to token', async () => { - const onTokenSelected = jest.fn(); - const { rerender } = renderHook( - ({ onTokenSelected: selectedCallback }) => - usePredictPaymentToken({ onTokenSelected: selectedCallback }), - { - initialProps: { onTokenSelected }, - }, - ); - - mockSelectedPaymentToken = { - address: '0x1234', - chainId: '0x1', - }; - - await act(async () => { - rerender({ onTokenSelected }); - }); - - expect(onTokenSelected).toHaveBeenCalledWith({ - tokenAddress: '0x1234', - tokenKey: '0x1234', - }); - }); - - it('calls onTokenSelected with predict-balance key when switching back to predict balance', async () => { - mockSelectedPaymentToken = { - address: '0x1234', - chainId: '0x1', - }; - - const onTokenSelected = jest.fn(); - const { rerender } = renderHook( - ({ onTokenSelected: selectedCallback }) => - usePredictPaymentToken({ onTokenSelected: selectedCallback }), - { - initialProps: { onTokenSelected }, - }, - ); - - mockSelectedPaymentToken = null; - - await act(async () => { - rerender({ onTokenSelected }); - }); - - expect(onTokenSelected).toHaveBeenCalledWith({ - tokenAddress: null, - tokenKey: 'predict-balance', - }); - }); - - it('does not call onTokenSelected when token selection does not change', async () => { - const onTokenSelected = jest.fn(); - const { rerender } = renderHook( - ({ onTokenSelected: selectedCallback }) => - usePredictPaymentToken({ onTokenSelected: selectedCallback }), - { - initialProps: { onTokenSelected }, - }, - ); - - await act(async () => { - rerender({ onTokenSelected }); - }); - - expect(onTokenSelected).not.toHaveBeenCalled(); - }); - describe('onPaymentTokenChange', () => { it('returns early when token is null', () => { const { result } = renderHook(() => usePredictPaymentToken()); @@ -159,32 +63,14 @@ describe('usePredictPaymentToken', () => { }); expect( - jest.mocked(Engine.context.PredictController.setSelectedPaymentToken), + jest.mocked(Engine.context.PredictController.selectPaymentToken), ).not.toHaveBeenCalled(); }); - it('calls setSelectedPaymentToken with null when token address is placeholder', () => { - const { result } = renderHook(() => usePredictPaymentToken()); - - act(() => { - result.current.onPaymentTokenChange( - createMockAsset({ - address: PREDICT_BALANCE_PLACEHOLDER_ADDRESS, - }), - ); - }); - - expect( - jest.mocked(Engine.context.PredictController.setSelectedPaymentToken), - ).toHaveBeenCalledWith(null); - }); - - it('calls setSelectedPaymentToken with token data when token is valid', () => { + it('calls selectPaymentToken with full token for balance placeholder', () => { const { result } = renderHook(() => usePredictPaymentToken()); const token = createMockAsset({ - address: '0xabcd', - chainId: '0x1', - symbol: 'TEST', + address: PREDICT_BALANCE_PLACEHOLDER_ADDRESS, }); act(() => { @@ -192,63 +78,32 @@ describe('usePredictPaymentToken', () => { }); expect( - jest.mocked(Engine.context.PredictController.setSelectedPaymentToken), - ).toHaveBeenCalledWith({ - address: '0xabcd', - chainId: '0x1', - symbol: 'TEST', - }); + jest.mocked(Engine.context.PredictController.selectPaymentToken), + ).toHaveBeenCalledWith( + expect.objectContaining({ + address: PREDICT_BALANCE_PLACEHOLDER_ADDRESS, + }), + ); }); - it('calls setPayToken when transactionMeta.id exists', () => { - mockTransactionMeta = { id: 'tx-123' }; - const { result } = renderHook(() => usePredictPaymentToken()); - const token = createMockAsset({ - address: '0xabcd', - chainId: '0x1', - }); - - act(() => { - result.current.onPaymentTokenChange(token); - }); - - expect(mockSetPayToken).toHaveBeenCalledWith({ - address: '0xabcd' as Hex, - chainId: '0x1' as Hex, - }); - }); - - it('does not call setPayToken when transactionMeta is null', () => { - mockTransactionMeta = null; - const { result } = renderHook(() => usePredictPaymentToken()); - const token = createMockAsset({ - address: '0xabcd', - chainId: '0x1', - }); - - act(() => { - result.current.onPaymentTokenChange(token); - }); - - expect(mockSetPayToken).not.toHaveBeenCalled(); - }); - - it('does not call setPayToken when transactionMeta.id is missing', () => { - mockTransactionMeta = { id: '' }; + it('calls selectPaymentToken with full token for valid token', () => { const { result } = renderHook(() => usePredictPaymentToken()); const token = createMockAsset({ address: '0xabcd', chainId: '0x1', + symbol: 'TEST', }); act(() => { result.current.onPaymentTokenChange(token); }); - expect(mockSetPayToken).not.toHaveBeenCalled(); + expect( + jest.mocked(Engine.context.PredictController.selectPaymentToken), + ).toHaveBeenCalledWith(token); }); - it('handles token with missing chainId', () => { + it('passes token with missing chainId to controller', () => { const { result } = renderHook(() => usePredictPaymentToken()); const token = createMockAsset({ address: '0xabcd', @@ -261,12 +116,8 @@ describe('usePredictPaymentToken', () => { }); expect( - jest.mocked(Engine.context.PredictController.setSelectedPaymentToken), - ).toHaveBeenCalledWith({ - address: '0xabcd', - chainId: '', - symbol: undefined, - }); + jest.mocked(Engine.context.PredictController.selectPaymentToken), + ).toHaveBeenCalledWith(token); }); }); @@ -284,105 +135,6 @@ describe('usePredictPaymentToken', () => { }); }); - describe('useEffect syncing payToken with selectedPaymentToken', () => { - it('skips sync when transactionMeta is missing', () => { - mockTransactionMeta = null; - mockSelectedPaymentToken = { - address: '0xabcd', - chainId: '0x1', - }; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).not.toHaveBeenCalled(); - }); - - it('skips sync when isPredictBalanceSelected is true', () => { - mockTransactionMeta = { id: 'tx-123' }; - mockSelectedPaymentToken = null; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).not.toHaveBeenCalled(); - }); - - it('skips sync when selectedPaymentToken is null', () => { - mockTransactionMeta = { id: 'tx-123' }; - mockSelectedPaymentToken = null; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).not.toHaveBeenCalled(); - }); - - it('skips sync when token is already applied', () => { - mockTransactionMeta = { id: 'tx-123' }; - mockSelectedPaymentToken = { - address: '0xabcd', - chainId: '0x1', - }; - mockPayToken = { - address: '0xabcd' as Hex, - chainId: '0x1' as Hex, - }; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).not.toHaveBeenCalled(); - }); - - it('skips sync when token is already applied with different case', () => { - mockTransactionMeta = { id: 'tx-123' }; - mockSelectedPaymentToken = { - address: '0xABCD', - chainId: '0x1', - }; - mockPayToken = { - address: '0xabcd' as Hex, - chainId: '0x1' as Hex, - }; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).not.toHaveBeenCalled(); - }); - - it('calls setPayToken when token is not yet applied', () => { - mockTransactionMeta = { id: 'tx-123' }; - mockSelectedPaymentToken = { - address: '0xabcd', - chainId: '0x1', - }; - mockPayToken = null; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).toHaveBeenCalledWith({ - address: '0xabcd' as Hex, - chainId: '0x1' as Hex, - }); - }); - - it('calls setPayToken when chainId differs', () => { - mockTransactionMeta = { id: 'tx-123' }; - mockSelectedPaymentToken = { - address: '0xabcd', - chainId: '0x2', - }; - mockPayToken = { - address: '0xabcd' as Hex, - chainId: '0x1' as Hex, - }; - - renderHook(() => usePredictPaymentToken()); - - expect(mockSetPayToken).toHaveBeenCalledWith({ - address: '0xabcd' as Hex, - chainId: '0x2' as Hex, - }); - }); - }); - describe('isPredictBalanceSelected', () => { it('returns true when selectedPaymentToken is null', () => { mockSelectedPaymentToken = null; diff --git a/app/components/UI/Predict/hooks/usePredictPaymentToken.ts b/app/components/UI/Predict/hooks/usePredictPaymentToken.ts index 8c073eea7bd..bed5c069153 100644 --- a/app/components/UI/Predict/hooks/usePredictPaymentToken.ts +++ b/app/components/UI/Predict/hooks/usePredictPaymentToken.ts @@ -1,26 +1,9 @@ -import { Hex } from '@metamask/utils'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback } from 'react'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; -import { useTransactionPayToken } from '../../../Views/confirmations/hooks/pay/useTransactionPayToken'; -import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import { AssetType } from '../../../Views/confirmations/types/token'; -import { - PREDICT_BALANCE_PLACEHOLDER_ADDRESS, - PREDICT_BALANCE_TOKEN_KEY, -} from '../constants/transactions'; import { selectPredictSelectedPaymentToken } from '../selectors/predictController'; -interface UsePredictPaymentTokenParams { - onTokenSelected?: ({ - tokenAddress, - tokenKey, - }: { - tokenAddress: string | null; - tokenKey: string | null; - }) => Promise | void; -} - export interface UsePredictPaymentTokenResult { onPaymentTokenChange: (token: AssetType | null) => void; isPredictBalanceSelected: boolean; @@ -32,15 +15,9 @@ export interface UsePredictPaymentTokenResult { resetSelectedPaymentToken: () => void; } -export function usePredictPaymentToken({ - onTokenSelected, -}: UsePredictPaymentTokenParams = {}): UsePredictPaymentTokenResult { - const { payToken, setPayToken } = useTransactionPayToken(); - const transactionMeta = useTransactionMetadataRequest(); +export function usePredictPaymentToken(): UsePredictPaymentTokenResult { const selectedPaymentToken = useSelector(selectPredictSelectedPaymentToken); const isPredictBalanceSelected = selectedPaymentToken === null; - const hasInitializedSelectionRef = useRef(false); - const previousSelectedTokenKeyRef = useRef(null); const { PredictController } = Engine.context; @@ -50,83 +27,11 @@ export function usePredictPaymentToken({ return; } - if (token.address === PREDICT_BALANCE_PLACEHOLDER_ADDRESS) { - PredictController.setSelectedPaymentToken(null); - return; - } - - PredictController.setSelectedPaymentToken({ - address: token.address, - chainId: token.chainId ?? '', - symbol: token.symbol, - }); - if (transactionMeta?.id) { - setPayToken({ - address: token.address as Hex, - chainId: (token.chainId ?? '') as Hex, - }); - } + PredictController.selectPaymentToken(token); }, - [PredictController, setPayToken, transactionMeta?.id], + [PredictController], ); - useEffect(() => { - if (!transactionMeta || isPredictBalanceSelected || !selectedPaymentToken) { - return; - } - - const hasSelectedTokenApplied = - payToken?.address?.toLowerCase() === - selectedPaymentToken.address.toLowerCase() && - payToken?.chainId?.toLowerCase() === - selectedPaymentToken.chainId.toLowerCase(); - - if (!hasSelectedTokenApplied) { - setPayToken({ - address: selectedPaymentToken.address as Hex, - chainId: selectedPaymentToken.chainId as Hex, - }); - } - }, [ - transactionMeta, - isPredictBalanceSelected, - selectedPaymentToken, - payToken?.address, - payToken?.chainId, - setPayToken, - ]); - - useEffect(() => { - const selectedTokenAddress = selectedPaymentToken?.address ?? null; - const selectedTokenKey = isPredictBalanceSelected - ? PREDICT_BALANCE_TOKEN_KEY - : selectedTokenAddress; - - if (!hasInitializedSelectionRef.current) { - hasInitializedSelectionRef.current = true; - previousSelectedTokenKeyRef.current = selectedTokenKey; - return; - } - - if (previousSelectedTokenKeyRef.current === selectedTokenKey) { - return; - } - - previousSelectedTokenKeyRef.current = selectedTokenKey; - const callbackResult = onTokenSelected?.({ - tokenAddress: selectedTokenAddress, - tokenKey: selectedTokenKey, - }); - - if (callbackResult) { - Promise.resolve(callbackResult).catch(() => undefined); - } - }, [ - isPredictBalanceSelected, - onTokenSelected, - selectedPaymentToken?.address, - ]); - const resetSelectedPaymentToken = useCallback(() => { PredictController.setSelectedPaymentToken(null); }, [PredictController]); diff --git a/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts b/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts index fa07e0c32db..4a9ba0c733a 100644 --- a/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts +++ b/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts @@ -21,6 +21,7 @@ jest.mock('./usePredictBalance'); jest.mock('./usePredictDeposit'); const mockQueryClient = { invalidateQueries: jest.fn() }; jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), useQueryClient: () => mockQueryClient, })); jest.mock('../../../../../locales/i18n', () => ({ @@ -123,7 +124,7 @@ describe('usePredictPlaceOrder', () => { previewOrder: jest.fn(), prepareWithdraw: jest.fn(), deposit: jest.fn(), - payWithAnyTokenConfirmation: jest.fn(), + initPayWithAnyToken: jest.fn(), }); mockRefetchBalance.mockResolvedValue({ data: 1000 }); mockUsePredictBalance.mockReturnValue({ @@ -149,6 +150,7 @@ describe('usePredictPlaceOrder', () => { expect(result.current.error).toBeUndefined(); expect(result.current.result).toBeNull(); expect(typeof result.current.placeOrder).toBe('function'); + expect(typeof result.current.invalidateOrderQueries).toBe('function'); }); }); @@ -191,6 +193,27 @@ describe('usePredictPlaceOrder', () => { hasNoTimeout: false, }), ); + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'balance'], + }), + ); + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'positions'], + }), + ); + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'activity'], + }), + ); + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'unrealizedPnL'], + }), + ); }); it('shows cashed out toast when SELL order is placed', async () => { diff --git a/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts b/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts index c2dde6b1c78..7399a626926 100644 --- a/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts +++ b/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts @@ -1,28 +1,27 @@ -import { useCallback, useContext, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import { useCallback, useContext, useState } from 'react'; +import { strings } from '../../../../../locales/i18n'; import { IconName } from '../../../../component-library/components/Icons/Icon'; import { ToastContext, ToastVariants, } from '../../../../component-library/components/Toast'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; -import Logger from '../../../../util/Logger'; import { - trace, endTrace, + trace, TraceName, TraceOperation, } from '../../../../util/trace'; +import { PREDICT_CONSTANTS } from '../constants/errors'; +import { PredictEventValues } from '../constants/eventNames'; +import { predictQueries } from '../queries'; import { PlaceOrderParams, Side, type Result } from '../types'; -import { usePredictTrading } from './usePredictTrading'; -import { strings } from '../../../../../locales/i18n'; import { formatPrice } from '../utils/format'; -import { ensureError, parseErrorMessage } from '../utils/predictErrorHandler'; -import { PREDICT_CONSTANTS, PREDICT_ERROR_CODES } from '../constants/errors'; +import { checkPlaceOrderError } from '../utils/predictErrorHandler'; import { usePredictBalance } from './usePredictBalance'; -import { predictQueries } from '../queries'; import { usePredictDeposit } from './usePredictDeposit'; -import { PredictEventValues } from '../constants/eventNames'; +import { usePredictTrading } from './usePredictTrading'; interface UsePredictPlaceOrderOptions { /** @@ -42,6 +41,8 @@ interface UsePredictPlaceOrderReturn { placeOrder: (params: PlaceOrderParams) => Promise; isOrderNotFilled: boolean; resetOrderNotFilled: () => void; + showOrderPlacedToast: () => void; + invalidateOrderQueries: () => void; } export type PlaceOrderOutcome = @@ -151,6 +152,21 @@ export function usePredictPlaceOrder( }); }, [toastRef]); + const invalidateOrderQueries = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: predictQueries.balance.keys.all(), + }); + queryClient.invalidateQueries({ + queryKey: predictQueries.positions.keys.all(), + }); + queryClient.invalidateQueries({ + queryKey: predictQueries.activity.keys.all(), + }); + queryClient.invalidateQueries({ + queryKey: predictQueries.unrealizedPnL.keys.all(), + }); + }, [queryClient]); + const placeOrder = useCallback( async (orderParams: PlaceOrderParams): Promise => { const { @@ -218,21 +234,7 @@ export function usePredictPlaceOrder( setResult(orderResult); - queryClient.invalidateQueries({ - queryKey: predictQueries.balance.keys.all(), - }); - - queryClient.invalidateQueries({ - queryKey: predictQueries.positions.keys.all(), - }); - - queryClient.invalidateQueries({ - queryKey: predictQueries.activity.keys.all(), - }); - - queryClient.invalidateQueries({ - queryKey: predictQueries.unrealizedPnL.keys.all(), - }); + invalidateOrderQueries(); if (side === Side.BUY) { showOrderPlacedToast(); @@ -245,47 +247,15 @@ export function usePredictPlaceOrder( DevLogger.log('usePredictPlaceOrder: Order placed successfully'); return { status: 'success', result: orderResult }; } catch (err) { - const parsedErrorMessage = parseErrorMessage({ - error: err, - defaultCode: PREDICT_ERROR_CODES.PLACE_ORDER_FAILED, - }); - DevLogger.log('usePredictPlaceOrder: Error placing order', { - error: parsedErrorMessage, - orderParams, - }); - - // Log error with order context (no sensitive data like amounts) - Logger.error(ensureError(err), { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - component: 'usePredictPlaceOrder', - }, - context: { - name: 'usePredictPlaceOrder', - data: { - method: 'placeOrder', - action: 'order_placement', - operation: 'order_management', - side: orderParams.preview?.side, - marketId: orderParams.analyticsProperties?.marketId, - transactionType: orderParams.analyticsProperties?.transactionType, - }, - }, - }); - - const rawMessage = err instanceof Error ? err.message : String(err); - const isNotFilled = - rawMessage === PREDICT_ERROR_CODES.BUY_ORDER_NOT_FULLY_FILLED || - rawMessage === PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED; - - if (isNotFilled) { + const errorResult = checkPlaceOrderError({ error: err, orderParams }); + if (errorResult.status === 'order_not_filled') { setIsOrderNotFilled(true); - return { status: 'order_not_filled' }; + } else if (errorResult.status === 'error') { + setError(errorResult.error); + onError?.(errorResult.error); } - setError(parsedErrorMessage); - onError?.(parsedErrorMessage); - return { status: 'error', error: parsedErrorMessage }; + return errorResult; } finally { setIsLoading(false); } @@ -298,7 +268,7 @@ export function usePredictPlaceOrder( toastRef, controllerPlaceOrder, onComplete, - queryClient, + invalidateOrderQueries, showOrderPlacedToast, showCashedOutToast, onError, @@ -317,5 +287,7 @@ export function usePredictPlaceOrder( placeOrder, isOrderNotFilled, resetOrderNotFilled, + showOrderPlacedToast, + invalidateOrderQueries, }; } diff --git a/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx b/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx index da140bd3f1b..aefb02f599b 100644 --- a/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx @@ -753,4 +753,171 @@ describe('usePredictToastRegistrations', () => { expect(showToast).not.toHaveBeenCalled(); }); }); + + describe('order transactions', () => { + it('shows prediction placed toast on confirmed status', () => { + const handler = getHandler(); + + handler( + { + type: 'order', + status: 'confirmed', + senderAddress: selectedAddress, + }, + showToast, + ); + + expect(showToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'Icon', + iconName: 'Check', + hasNoTimeout: false, + }), + ); + expect(mockInvalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'balance'], + }), + ); + expect(mockInvalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'positions'], + }), + ); + expect(mockInvalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'activity'], + }), + ); + expect(mockInvalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'unrealizedPnL'], + }), + ); + }); + + it('shows prediction placed toast with View button when marketId is present', () => { + const handler = getHandler(); + + handler( + { + type: 'order', + status: 'confirmed', + senderAddress: selectedAddress, + marketId: 'market-123', + }, + showToast, + ); + + expect(showToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'Icon', + iconName: 'Check', + linkButtonOptions: expect.objectContaining({ + label: 'predict.order.view', + onPress: expect.any(Function), + }), + }), + ); + + const onView = showToast.mock.calls[0][0].linkButtonOptions.onPress; + onView(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { marketId: 'market-123' }, + }); + }); + + it('shows prediction placed toast without View button when marketId is absent', () => { + const handler = getHandler(); + + handler( + { + type: 'order', + status: 'confirmed', + senderAddress: selectedAddress, + }, + showToast, + ); + + expect(showToast).toHaveBeenCalledWith( + expect.not.objectContaining({ + linkButtonOptions: expect.anything(), + }), + ); + }); + + it('shows error toast on failed status', () => { + const handler = getHandler(); + + handler( + { + type: 'order', + status: 'failed', + senderAddress: selectedAddress, + }, + showToast, + ); + + expect(showToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'Icon', + iconName: 'Error', + hasNoTimeout: false, + }), + ); + }); + + it('shows error toast with Try Again button when marketId is present', () => { + const handler = getHandler(); + + handler( + { + type: 'order', + status: 'failed', + senderAddress: selectedAddress, + marketId: 'market-456', + }, + showToast, + ); + + expect(showToast).toHaveBeenCalledWith( + expect.objectContaining({ + iconName: 'Error', + linkButtonOptions: expect.objectContaining({ + label: 'predict.order.try_again', + onPress: expect.any(Function), + }), + }), + ); + + const onRetry = showToast.mock.calls[0][0].linkButtonOptions.onPress; + onRetry(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { marketId: 'market-456' }, + }); + }); + + it('shows error toast without Try Again button when marketId is absent', () => { + const handler = getHandler(); + + handler( + { + type: 'order', + status: 'failed', + senderAddress: selectedAddress, + }, + showToast, + ); + + expect(showToast).toHaveBeenCalledWith( + expect.not.objectContaining({ + linkButtonOptions: expect.anything(), + }), + ); + }); + }); }); diff --git a/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx b/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx index 8be10f0f751..428788d4d14 100644 --- a/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx +++ b/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx @@ -148,7 +148,7 @@ export const usePredictToastRegistrations = (): ToastRegistration[] => { const normalizedSelectedAddress = selectedAddress.toLowerCase(); const handleTransactionStatusChanged = useCallback( (payload: unknown, showToast: ToastRef['showToast']): void => { - const { type, status, senderAddress, transactionId, amount } = + const { type, status, senderAddress, transactionId, amount, marketId } = payload as PredictTransactionStatusChangedPayload; const canRetry = Boolean(senderAddress) && senderAddress === normalizedSelectedAddress; @@ -163,7 +163,7 @@ export const usePredictToastRegistrations = (): ToastRegistration[] => { }); // Deposit/Withdraw should not invalidate positions/activity - if (type === 'claim') { + if (type === 'claim' || type === 'order') { queryClient.invalidateQueries({ queryKey: predictQueries.positions.keys.all(), }); @@ -335,6 +335,59 @@ export const usePredictToastRegistrations = (): ToastRegistration[] => { return; } } + + if (type === 'order') { + if (status === 'confirmed') { + showToast({ + variant: ToastVariants.Icon, + iconName: IconName.Check, + iconColor: theme.colors.success.default, + labelOptions: [ + { + label: strings('predict.order.prediction_placed'), + isBold: true, + }, + ], + hasNoTimeout: false, + ...(marketId + ? { + linkButtonOptions: { + label: strings('predict.order.view'), + onPress: () => { + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { marketId }, + }); + }, + }, + } + : {}), + }); + return; + } + + if (status === 'failed') { + showErrorToast({ + showToast, + title: strings('predict.order.prediction_failed'), + description: strings('predict.order.order_failed_generic'), + ...(marketId + ? { + retryLabel: strings('predict.order.try_again'), + onRetry: () => { + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { marketId }, + }); + }, + } + : {}), + backgroundColor: theme.colors.accent04.normal, + iconColor: theme.colors.error.default, + }); + return; + } + } }, [ claim, diff --git a/app/components/UI/Predict/hooks/usePredictTrading.test.ts b/app/components/UI/Predict/hooks/usePredictTrading.test.ts index fc4d3eef623..ead7bf26598 100644 --- a/app/components/UI/Predict/hooks/usePredictTrading.test.ts +++ b/app/components/UI/Predict/hooks/usePredictTrading.test.ts @@ -18,6 +18,10 @@ jest.mock('../../../../core/Engine', () => ({ getBalance: jest.fn(), deposit: jest.fn(), payWithAnyTokenConfirmation: jest.fn(), + initPayWithAnyToken: jest.fn(), + previewOrder: jest.fn(), + prepareWithdraw: jest.fn(), + depositWithConfirmation: jest.fn(), }, }, })); @@ -227,39 +231,178 @@ describe('usePredictTrading', () => { }); }); - describe('payWithAnyTokenConfirmation', () => { - it('calls PredictController.payWithAnyTokenConfirmation and returns result', async () => { + describe('initPayWithAnyToken', () => { + it('calls PredictController.initPayWithAnyToken and returns result', async () => { const mockResult = { success: true, response: { batchId: 'batch-123' }, }; ( - Engine.context.PredictController - .payWithAnyTokenConfirmation as jest.Mock + Engine.context.PredictController.initPayWithAnyToken as jest.Mock ).mockResolvedValue(mockResult); const { result } = renderHook(() => usePredictTrading()); - const response = await result.current.payWithAnyTokenConfirmation(); + const response = await result.current.initPayWithAnyToken(); expect( - Engine.context.PredictController.payWithAnyTokenConfirmation, + Engine.context.PredictController.initPayWithAnyToken, ).toHaveBeenCalled(); expect(response).toEqual(mockResult); }); - it('throws error when PredictController.payWithAnyTokenConfirmation fails', async () => { - const mockError = new Error('Failed to pay with any token'); + it('throws error when PredictController.initPayWithAnyToken fails', async () => { + const mockError = new Error('Failed to initialize pay with any token'); ( - Engine.context.PredictController - .payWithAnyTokenConfirmation as jest.Mock + Engine.context.PredictController.initPayWithAnyToken as jest.Mock ).mockRejectedValue(mockError); const { result } = renderHook(() => usePredictTrading()); - await expect( - result.current.payWithAnyTokenConfirmation(), - ).rejects.toThrow('Failed to pay with any token'); + await expect(result.current.initPayWithAnyToken()).rejects.toThrow( + 'Failed to initialize pay with any token', + ); + }); + }); + + describe('previewOrder', () => { + it('calls PredictController.previewOrder and returns result', async () => { + const mockPreviewResult = { + marketId: 'market-1', + outcomeId: 'outcome-789', + outcomeTokenId: 'outcome-token-101', + timestamp: Date.now(), + side: Side.BUY, + sharePrice: 0.5, + maxAmountSpent: 100, + minAmountReceived: 180, + slippage: 0.01, + tickSize: 0.01, + minOrderSize: 1, + negRisk: false, + }; + + ( + Engine.context.PredictController.previewOrder as jest.Mock + ).mockResolvedValue(mockPreviewResult); + + const { result } = renderHook(() => usePredictTrading()); + + const params = { + marketId: 'market-1', + outcomeId: 'outcome-789', + outcomeTokenId: 'outcome-token-101', + side: Side.BUY, + size: 100, + }; + + const response = await result.current.previewOrder(params); + + expect( + Engine.context.PredictController.previewOrder, + ).toHaveBeenCalledWith(params); + expect(response).toEqual(mockPreviewResult); + }); + + it('throws error when PredictController.previewOrder fails', async () => { + const mockError = new Error('Failed to preview order'); + ( + Engine.context.PredictController.previewOrder as jest.Mock + ).mockRejectedValue(mockError); + + const { result } = renderHook(() => usePredictTrading()); + + const params = { + marketId: 'market-1', + outcomeId: 'outcome-789', + outcomeTokenId: 'outcome-token-101', + side: Side.BUY, + size: 100, + }; + + await expect(result.current.previewOrder(params)).rejects.toThrow( + 'Failed to preview order', + ); + }); + }); + + describe('prepareWithdraw', () => { + it('calls PredictController.prepareWithdraw and returns result', async () => { + const mockWithdrawResult = { + txMeta: { id: 'tx-withdraw-123', hash: '0xwithdraw123' }, + success: true, + amount: 500, + }; + + ( + Engine.context.PredictController.prepareWithdraw as jest.Mock + ).mockResolvedValue(mockWithdrawResult); + + const { result } = renderHook(() => usePredictTrading()); + + const params = {}; + + const response = await result.current.prepareWithdraw(params); + + expect( + Engine.context.PredictController.prepareWithdraw, + ).toHaveBeenCalledWith(params); + expect(response).toEqual(mockWithdrawResult); + }); + + it('throws error when PredictController.prepareWithdraw fails', async () => { + const mockError = new Error('Failed to prepare withdraw'); + ( + Engine.context.PredictController.prepareWithdraw as jest.Mock + ).mockRejectedValue(mockError); + + const { result } = renderHook(() => usePredictTrading()); + + const params = {}; + + await expect(result.current.prepareWithdraw(params)).rejects.toThrow( + 'Failed to prepare withdraw', + ); + }); + }); + + describe('deposit', () => { + it('calls PredictController.depositWithConfirmation and returns result', async () => { + const mockDepositResult = { + txMeta: { id: 'tx-deposit-456', hash: '0xdeposit456' }, + success: true, + depositedAmount: 1000, + }; + + ( + Engine.context.PredictController.depositWithConfirmation as jest.Mock + ).mockResolvedValue(mockDepositResult); + + const { result } = renderHook(() => usePredictTrading()); + + const params = {}; + + const response = await result.current.deposit(params); + + expect( + Engine.context.PredictController.depositWithConfirmation, + ).toHaveBeenCalledWith(params); + expect(response).toEqual(mockDepositResult); + }); + + it('throws error when PredictController.depositWithConfirmation fails', async () => { + const mockError = new Error('Failed to deposit'); + ( + Engine.context.PredictController.depositWithConfirmation as jest.Mock + ).mockRejectedValue(mockError); + + const { result } = renderHook(() => usePredictTrading()); + + const params = {}; + + await expect(result.current.deposit(params)).rejects.toThrow( + 'Failed to deposit', + ); }); }); @@ -271,8 +414,9 @@ describe('usePredictTrading', () => { const initialClaim = result.current.claim; const initialGetBalance = result.current.getBalance; const initialPreviewOrder = result.current.previewOrder; - const initialPayWithAnyTokenConfirmation = - result.current.payWithAnyTokenConfirmation; + const initialPrepareWithdraw = result.current.prepareWithdraw; + const initialDeposit = result.current.deposit; + const initialInitPayWithAnyToken = result.current.initPayWithAnyToken; rerender({}); @@ -280,8 +424,10 @@ describe('usePredictTrading', () => { expect(result.current.claim).toBe(initialClaim); expect(result.current.getBalance).toBe(initialGetBalance); expect(result.current.previewOrder).toBe(initialPreviewOrder); - expect(result.current.payWithAnyTokenConfirmation).toBe( - initialPayWithAnyTokenConfirmation, + expect(result.current.prepareWithdraw).toBe(initialPrepareWithdraw); + expect(result.current.deposit).toBe(initialDeposit); + expect(result.current.initPayWithAnyToken).toBe( + initialInitPayWithAnyToken, ); }); }); diff --git a/app/components/UI/Predict/hooks/usePredictTrading.ts b/app/components/UI/Predict/hooks/usePredictTrading.ts index 1da6041130f..fdf71e90ba0 100644 --- a/app/components/UI/Predict/hooks/usePredictTrading.ts +++ b/app/components/UI/Predict/hooks/usePredictTrading.ts @@ -44,9 +44,9 @@ export function usePredictTrading() { return controller.depositWithConfirmation(params); }, []); - const payWithAnyTokenConfirmation = useCallback(async () => { + const initPayWithAnyToken = useCallback(async () => { const controller = Engine.context.PredictController; - return controller.payWithAnyTokenConfirmation(); + return controller.initPayWithAnyToken(); }, []); return { @@ -56,6 +56,6 @@ export function usePredictTrading() { previewOrder, prepareWithdraw, deposit, - payWithAnyTokenConfirmation, + initPayWithAnyToken, }; } diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index d2b8711b15b..dac628237c1 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -254,6 +254,7 @@ describe('PolymarketProvider', () => { minimumVersion: '7.64.0', }, fakOrdersEnabled: false, + predictWithAnyTokenEnabled: false, }; const createProvider = ( featureFlagsOverride?: Partial, diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts index 32576547c90..f0b174b5ec3 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts @@ -2,6 +2,8 @@ import { selectPredictEnabledFlag, selectPredictFakOrdersEnabledFlag, selectPredictFeeCollectionFlag, + selectPredictGtmOnboardingModalEnabledFlag, + selectPredictHomeFeaturedVariant, selectPredictHotTabFlag, selectPredictWithAnyTokenEnabledFlag, } from '.'; @@ -926,39 +928,40 @@ describe('Predict Feature Flag Selectors', () => { }); }); - describe('selectPredictPayWithAnyTokenEnabledFlag', () => { - it('returns true when remote flag is enabled and version check passes', () => { - mockHasMinimumRequiredVersion.mockReturnValue(true); + describe('selectPredictWithAnyTokenEnabledFlag', () => { + it('returns false when remote flags are empty (version-gated default)', () => { + const result = selectPredictWithAnyTokenEnabledFlag( + mockedEmptyFlagsState, + ); + + expect(result).toBe(false); + }); + + it('returns false when controller is undefined', () => { const state = { engine: { backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictWithAnyToken: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - cacheTimestamp: 0, - }, + RemoteFeatureFlagController: undefined, }, }, }; const result = selectPredictWithAnyTokenEnabledFlag(state); - expect(result).toBe(true); + expect(result).toBe(false); }); + }); - it('returns false when remote flag is disabled', () => { + describe('selectPredictGtmOnboardingModalEnabledFlag', () => { + it('returns version-gated flag value when remote flag is set', () => { mockHasMinimumRequiredVersion.mockReturnValue(true); - const state = { + const stateWithRemoteFlag = { engine: { backgroundState: { RemoteFeatureFlagController: { remoteFeatureFlags: { - predictWithAnyToken: { - enabled: false, + predictGtmOnboardingModalEnabled: { + enabled: true, minimumVersion: '1.0.0', }, }, @@ -968,22 +971,20 @@ describe('Predict Feature Flag Selectors', () => { }, }; - const result = selectPredictWithAnyTokenEnabledFlag(state); + const result = + selectPredictGtmOnboardingModalEnabledFlag(stateWithRemoteFlag); - expect(result).toBe(false); + expect(result).toBe(true); }); - it('returns false when app version is below minimum required version', () => { - mockHasMinimumRequiredVersion.mockReturnValue(false); - const state = { + it('returns false when env var not set and no remote flag', () => { + delete process.env.MM_PREDICT_GTM_MODAL_ENABLED; + const stateWithoutRemoteFlag = { engine: { backgroundState: { RemoteFeatureFlagController: { remoteFeatureFlags: { - predictWithAnyToken: { - enabled: true, - minimumVersion: '99.0.0', - }, + predictGtmOnboardingModalEnabled: null, }, cacheTimestamp: 0, }, @@ -991,18 +992,33 @@ describe('Predict Feature Flag Selectors', () => { }, }; - const result = selectPredictWithAnyTokenEnabledFlag(state); + const result = selectPredictGtmOnboardingModalEnabledFlag( + stateWithoutRemoteFlag, + ); expect(result).toBe(false); }); + }); - it('defaults to false when remote flag is null', () => { - const state = { + describe('selectPredictHomeFeaturedVariant', () => { + it('returns carousel by default', () => { + const result = selectPredictHomeFeaturedVariant(mockedEmptyFlagsState); + + expect(result).toBe('carousel'); + }); + + it('returns list when remote flag variant is list and version check passes', () => { + mockHasMinimumRequiredVersion.mockReturnValue(true); + const stateWithListVariant = { engine: { backgroundState: { RemoteFeatureFlagController: { remoteFeatureFlags: { - predictWithAnyToken: null, + predictHomeFeaturedVariant: { + enabled: true, + variant: 'list', + minimumVersion: '1.0.0', + }, }, cacheTimestamp: 0, }, @@ -1010,43 +1026,42 @@ describe('Predict Feature Flag Selectors', () => { }, }; - const result = selectPredictWithAnyTokenEnabledFlag(state); - - expect(result).toBe(false); - }); - - it('defaults to false when remote feature flags are empty', () => { - const result = selectPredictWithAnyTokenEnabledFlag( - mockedEmptyFlagsState, - ); + const result = selectPredictHomeFeaturedVariant(stateWithListVariant); - expect(result).toBe(false); + expect(result).toBe('list'); }); - it('defaults to false when controller is undefined', () => { - const state = { + it('returns carousel when version check fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + const stateWithHighMinVersion = { engine: { backgroundState: { - RemoteFeatureFlagController: undefined, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictHomeFeaturedVariant: { + enabled: true, + variant: 'list', + minimumVersion: '99.0.0', + }, + }, + cacheTimestamp: 0, + }, }, }, }; - const result = selectPredictWithAnyTokenEnabledFlag(state); + const result = selectPredictHomeFeaturedVariant(stateWithHighMinVersion); - expect(result).toBe(false); + expect(result).toBe('carousel'); }); - it('defaults to false when remote flag is invalid', () => { - const state = { + it('returns carousel when remote flag is null', () => { + const stateWithNullFlag = { engine: { backgroundState: { RemoteFeatureFlagController: { remoteFeatureFlags: { - predictWithAnyToken: { - enabled: 'invalid', - minimumVersion: 123, - }, + predictHomeFeaturedVariant: null, }, cacheTimestamp: 0, }, @@ -1054,9 +1069,9 @@ describe('Predict Feature Flag Selectors', () => { }, }; - const result = selectPredictWithAnyTokenEnabledFlag(state); + const result = selectPredictHomeFeaturedVariant(stateWithNullFlag); - expect(result).toBe(false); + expect(result).toBe('carousel'); }); }); }); diff --git a/app/components/UI/Predict/selectors/predictController/index.test.ts b/app/components/UI/Predict/selectors/predictController/index.test.ts index 2546b6b2b48..5aff1526292 100644 --- a/app/components/UI/Predict/selectors/predictController/index.test.ts +++ b/app/components/UI/Predict/selectors/predictController/index.test.ts @@ -10,6 +10,7 @@ import { selectPredictAccountMeta, selectPredictAccountMetaByAddress, selectPredictWithdrawTransaction, + selectPredictActiveBuyOrder, selectPredictSelectedPaymentToken, } from './index'; import { PredictPosition, PredictPositionStatus } from '../../types'; @@ -140,6 +141,62 @@ describe('Predict Controller Selectors', () => { }); }); + describe('selectPredictActiveBuyOrder', () => { + it('returns active buy order when it exists', () => { + const activeBuyOrder = { + state: 'preview', + transactionId: 'tx-1', + }; + + const mockState = { + engine: { + backgroundState: { + PredictController: { + activeBuyOrder, + }, + }, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selectPredictActiveBuyOrder(mockState as any); + + expect(result).toEqual(activeBuyOrder); + }); + + it('returns null when active buy order is null', () => { + const mockState = { + engine: { + backgroundState: { + PredictController: { + activeBuyOrder: null, + }, + }, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selectPredictActiveBuyOrder(mockState as any); + + expect(result).toBeNull(); + }); + + it('returns null when PredictController state is undefined', () => { + const mockState = { + engine: { + backgroundState: { + PredictController: undefined, + }, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selectPredictActiveBuyOrder(mockState as any); + + expect(result).toBeNull(); + }); + }); + describe('selectPredictClaimablePositions', () => { it('returns claimable positions when they exist', () => { const testAddress = '0x123'; diff --git a/app/components/UI/Predict/selectors/predictController/index.ts b/app/components/UI/Predict/selectors/predictController/index.ts index fe2c5770272..984511ee6a3 100644 --- a/app/components/UI/Predict/selectors/predictController/index.ts +++ b/app/components/UI/Predict/selectors/predictController/index.ts @@ -20,9 +20,9 @@ const selectPredictWithdrawTransaction = createSelector( (predictControllerState) => predictControllerState?.withdrawTransaction, ); -const selectPredictActiveOrder = createSelector( +const selectPredictActiveBuyOrder = createSelector( selectPredictControllerState, - (predictState) => predictState?.activeOrder ?? null, + (predictState) => predictState?.activeBuyOrder ?? null, ); const selectPredictClaimablePositions = createSelector( @@ -113,7 +113,7 @@ export { selectPredictPendingDeposits, selectPredictPendingClaims, selectPredictWithdrawTransaction, - selectPredictActiveOrder, + selectPredictActiveBuyOrder, selectPredictClaimablePositions, selectPredictClaimablePositionsByAddress, selectPredictWonPositions, diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts index 17e908799e3..3d43cfff0d5 100644 --- a/app/components/UI/Predict/types/flags.ts +++ b/app/components/UI/Predict/types/flags.ts @@ -23,6 +23,7 @@ export interface PredictFeatureFlags { liveSportsLeagues: string[]; marketHighlightsFlag: PredictMarketHighlightsFlag; fakOrdersEnabled: boolean; + predictWithAnyTokenEnabled: boolean; } export interface PredictHotTabFlag extends VersionGatedFeatureFlag { diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts index 0bd8fab6710..c4cfd0dd16f 100644 --- a/app/components/UI/Predict/types/index.ts +++ b/app/components/UI/Predict/types/index.ts @@ -11,10 +11,10 @@ export type PredictOrderType = 'FOK' | 'FAK'; export enum ActiveOrderState { PREVIEW = 'preview', + PAY_WITH_ANY_TOKEN = 'pay_with_any_token', DEPOSITING = 'depositing', PLACING_ORDER = 'placing_order', - REDIRECTING = 'redirecting', - PAY_WITH_ANY_TOKEN = 'pay_with_any_token', + SUCCESS = 'success', } export enum PredictPriceHistoryInterval { @@ -493,6 +493,8 @@ export type OrderResult = Result<{ export interface PlaceOrderParams { preview: OrderPreview; + address?: string; + transactionId?: string; analyticsProperties?: { marketId?: string; marketTitle?: string; diff --git a/app/components/UI/Predict/types/navigation.ts b/app/components/UI/Predict/types/navigation.ts index 01fc08c1b56..c87cc8ca001 100644 --- a/app/components/UI/Predict/types/navigation.ts +++ b/app/components/UI/Predict/types/navigation.ts @@ -4,7 +4,6 @@ import { ParamListBase } from '@react-navigation/native'; import { - OrderPreview, PredictActivityItem, PredictCategory, PredictMarket, @@ -56,11 +55,6 @@ export interface PredictBuyPreviewParams { outcome: PredictOutcome; outcomeToken: PredictOutcomeToken; entryPoint?: PredictEntryPoint; - batchId?: string; - animationEnabled?: boolean; - isConfirmation?: boolean; - isConfirming?: boolean; - preview?: OrderPreview; } /** Predict sell preview parameters */ diff --git a/app/components/UI/Predict/utils/predictErrorHandler.test.ts b/app/components/UI/Predict/utils/predictErrorHandler.test.ts index 530e72ecfd5..57ea1253b4a 100644 --- a/app/components/UI/Predict/utils/predictErrorHandler.test.ts +++ b/app/components/UI/Predict/utils/predictErrorHandler.test.ts @@ -1,11 +1,15 @@ import { IconName } from '../../../../component-library/components/Icons/Icon'; import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types'; import { PREDICT_ERROR_CODES } from '../constants/errors'; +import { Side } from '../types'; import { ensureError, createDepositErrorToast, parseErrorMessage, + checkPlaceOrderError, } from './predictErrorHandler'; +import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; +import Logger from '../../../../util/Logger'; jest.mock('../../../../../locales/i18n', () => ({ strings: (key: string) => key, @@ -17,9 +21,21 @@ jest.mock('../constants/errors', () => ({ PREDICT_NOT_ELIGIBLE: 'You are not eligible', PREDICT_PLACE_ORDER_FAILED: 'Order placement failed', PREDICT_UNKNOWN_ERROR: 'Something went wrong', + PREDICT_BUY_ORDER_NOT_FULLY_FILLED: 'Buy order not fully filled', + PREDICT_SELL_ORDER_NOT_FULLY_FILLED: 'Sell order not fully filled', }), })); +jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ + __esModule: true, + default: { log: jest.fn() }, +})); + +jest.mock('../../../../util/Logger', () => ({ + __esModule: true, + default: { error: jest.fn() }, +})); + const mockTheme = { colors: { error: { default: 'error-color' }, @@ -167,4 +183,207 @@ describe('predictErrorHandler', () => { expect(result).toBe('Order placement failed'); }); }); + + describe('checkPlaceOrderError', () => { + const mockDevLogger = jest.mocked(DevLogger); + const mockLogger = jest.mocked(Logger); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createMockOrderParams = () => ({ + preview: { + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + timestamp: 1234567890, + side: Side.BUY, + sharePrice: 0.5, + maxAmountSpent: 100, + minAmountReceived: 50, + slippage: 0.01, + tickSize: 0.01, + minOrderSize: 1, + negRisk: false, + }, + analyticsProperties: { + marketId: 'market-1', + transactionType: 'buy', + }, + }); + + it('returns order_not_filled status when error message is BUY_ORDER_NOT_FULLY_FILLED', () => { + const error = new Error(PREDICT_ERROR_CODES.BUY_ORDER_NOT_FULLY_FILLED); + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ status: 'order_not_filled' }); + }); + + it('returns order_not_filled status when error message is SELL_ORDER_NOT_FULLY_FILLED', () => { + const error = new Error(PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED); + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ status: 'order_not_filled' }); + }); + + it('returns error status with parsed message for generic Error', () => { + const error = new Error('Some generic error'); + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ + status: 'error', + error: 'Order placement failed', + }); + }); + + it('returns error status with parsed message for string error', () => { + const error = 'String error message'; + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ + status: 'error', + error: 'Order placement failed', + }); + }); + + it('calls Logger.error with wrapped error and context', () => { + const error = new Error('Test error'); + const orderParams = createMockOrderParams(); + + checkPlaceOrderError({ error, orderParams }); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { + feature: 'Predict', + component: 'usePredictPlaceOrder', + }, + context: expect.objectContaining({ + name: 'usePredictPlaceOrder', + data: expect.objectContaining({ + method: 'placeOrder', + action: 'order_placement', + operation: 'order_management', + side: 'BUY', + marketId: 'market-1', + transactionType: 'buy', + }), + }), + }), + ); + }); + + it('calls DevLogger.log with parsed error message and order params', () => { + const error = new Error('Test error'); + const orderParams = createMockOrderParams(); + + checkPlaceOrderError({ error, orderParams }); + + expect(mockDevLogger.log).toHaveBeenCalledWith( + 'usePredictPlaceOrder: Error placing order', + expect.objectContaining({ + error: 'Order placement failed', + orderParams, + }), + ); + }); + + it('returns error status with mapped message when error code is known', () => { + const error = new Error(PREDICT_ERROR_CODES.PLACE_ORDER_FAILED); + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ + status: 'error', + error: 'Order placement failed', + }); + }); + + it('handles string error that matches BUY_ORDER_NOT_FULLY_FILLED', () => { + const error = PREDICT_ERROR_CODES.BUY_ORDER_NOT_FULLY_FILLED; + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ status: 'order_not_filled' }); + }); + + it('handles string error that matches SELL_ORDER_NOT_FULLY_FILLED', () => { + const error = PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED; + const orderParams = createMockOrderParams(); + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ status: 'order_not_filled' }); + }); + + it('includes side from preview in Logger context', () => { + const error = new Error('Test error'); + const orderParams = createMockOrderParams(); + orderParams.preview.side = Side.SELL; + + checkPlaceOrderError({ error, orderParams }); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + side: Side.SELL, + }), + }), + }), + ); + }); + + it('includes marketId from analyticsProperties in Logger context', () => { + const error = new Error('Test error'); + const orderParams = createMockOrderParams(); + orderParams.analyticsProperties = { + marketId: 'custom-market-id', + transactionType: 'buy', + }; + + checkPlaceOrderError({ error, orderParams }); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + marketId: 'custom-market-id', + }), + }), + }), + ); + }); + + it('handles error with minimal analyticsProperties', () => { + const error = new Error('Test error'); + const orderParams = createMockOrderParams(); + orderParams.analyticsProperties = { + marketId: 'market-1', + transactionType: 'buy', + }; + + const result = checkPlaceOrderError({ error, orderParams }); + + expect(result).toEqual({ + status: 'error', + error: 'Order placement failed', + }); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Predict/utils/predictErrorHandler.ts b/app/components/UI/Predict/utils/predictErrorHandler.ts index 54c491d2151..b6a3fe52bd2 100644 --- a/app/components/UI/Predict/utils/predictErrorHandler.ts +++ b/app/components/UI/Predict/utils/predictErrorHandler.ts @@ -1,7 +1,15 @@ import { strings } from '../../../../../locales/i18n'; import { IconName } from '../../../../component-library/components/Icons/Icon'; import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types'; -import { getPredictErrorMessages } from '../constants/errors'; +import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; +import Logger from '../../../../util/Logger'; +import { + getPredictErrorMessages, + PREDICT_CONSTANTS, + PREDICT_ERROR_CODES, +} from '../constants/errors'; +import { PlaceOrderOutcome } from '../hooks/usePredictPlaceOrder'; +import { PlaceOrderParams } from '../types'; /** * Ensures we have a proper Error object for logging @@ -62,3 +70,71 @@ export function parseErrorMessage({ } return errorMessage; } + +interface PlaceOrderErrorParams { + error: unknown; + orderParams: PlaceOrderParams; +} + +export const getPlaceOrderErrorOutcome = ({ + error: placeOrderError, +}: PlaceOrderErrorParams): PlaceOrderOutcome => { + const parsedErrorMessage = parseErrorMessage({ + error: placeOrderError, + defaultCode: PREDICT_ERROR_CODES.PLACE_ORDER_FAILED, + }); + + const rawMessage = + placeOrderError instanceof Error + ? placeOrderError.message + : String(placeOrderError); + const isNotFilled = + rawMessage === PREDICT_ERROR_CODES.BUY_ORDER_NOT_FULLY_FILLED || + rawMessage === PREDICT_ERROR_CODES.SELL_ORDER_NOT_FULLY_FILLED; + + if (isNotFilled) { + return { status: 'order_not_filled' }; + } + + return { status: 'error', error: parsedErrorMessage }; +}; + +export const logPlaceOrderError = ({ + error: placeOrderError, + orderParams, +}: PlaceOrderErrorParams): void => { + const parsedErrorMessage = parseErrorMessage({ + error: placeOrderError, + defaultCode: PREDICT_ERROR_CODES.PLACE_ORDER_FAILED, + }); + DevLogger.log('usePredictPlaceOrder: Error placing order', { + error: parsedErrorMessage, + orderParams, + }); + + // Log error with order context (no sensitive data like amounts) + Logger.error(ensureError(placeOrderError), { + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + component: 'usePredictPlaceOrder', + }, + context: { + name: 'usePredictPlaceOrder', + data: { + method: 'placeOrder', + action: 'order_placement', + operation: 'order_management', + side: orderParams.preview?.side, + marketId: orderParams.analyticsProperties?.marketId, + transactionType: orderParams.analyticsProperties?.transactionType, + }, + }, + }); +}; + +export const checkPlaceOrderError = ( + params: PlaceOrderErrorParams, +): PlaceOrderOutcome => { + logPlaceOrderError(params); + return getPlaceOrderErrorOutcome(params); +}; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx new file mode 100644 index 00000000000..612b37dd9c0 --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx @@ -0,0 +1,410 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import PredictBuyWithAnyToken from './PredictBuyWithAnyToken'; + +const mockHandleConfirm = jest.fn(); +const mockPlaceOrder = jest.fn(); +const mockShowOrderPlacedToast = jest.fn(); +const mockInvalidateOrderQueries = jest.fn(); +const mockResetOrderNotFilled = jest.fn(); +const mockSetCurrentValue = jest.fn(); +const mockSetCurrentValueUSDString = jest.fn(); +const mockSetIsInputFocused = jest.fn(); +const mockSetIsUserInputChange = jest.fn(); +const mockSetIsConfirming = jest.fn(); +const mockHandleRetryWithBestPrice = jest.fn(); + +let mockPayWithAnyTokenEnabled = true; +let mockFakOrdersEnabled = false; +let mockIsPreviewCalculating = false; +let mockIsPlacingOrder = false; +let mockCanSelectToken = true; +let mockErrorMessage: string | undefined; + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ + style: jest.fn(() => ({})), + }), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useRoute: () => ({ + params: { + market: { id: 'market-1' }, + outcome: { id: 'outcome-1' }, + outcomeToken: { id: 'token-1', title: 'Yes', price: 0.62 }, + entryPoint: 'market_details', + }, + }), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('../../selectors/featureFlags', () => ({ + selectPredictWithAnyTokenEnabledFlag: jest.fn( + () => mockPayWithAnyTokenEnabled, + ), + selectPredictFakOrdersEnabledFlag: jest.fn(() => mockFakOrdersEnabled), +})); + +jest.mock('../../utils/analytics', () => ({ + parseAnalyticsProperties: jest.fn(() => ({ + marketId: 'market-1', + sharePrice: 0.62, + })), +})); + +jest.mock('../../utils/format', () => ({ + formatPrice: jest.fn((value: number) => `$${value.toFixed(2)}`), +})); + +jest.mock('../../hooks/usePredictActiveOrder', () => ({ + usePredictActiveOrder: () => ({ + isPlacingOrder: mockIsPlacingOrder, + }), +})); + +jest.mock('../../hooks/usePredictMeasurement', () => ({ + usePredictMeasurement: jest.fn(), +})); + +jest.mock('../../hooks/usePredictOrderPreview', () => ({ + usePredictOrderPreview: () => ({ + preview: { + sharePrice: 0.62, + minAmountReceived: 24, + }, + error: null, + isCalculating: mockIsPreviewCalculating, + }), +})); + +jest.mock('../../hooks/usePredictOrderRetry', () => ({ + usePredictOrderRetry: () => ({ + retrySheetRef: { current: null }, + retrySheetVariant: 'busy', + isRetrying: false, + handleRetryWithBestPrice: mockHandleRetryWithBestPrice, + }), +})); + +jest.mock('../../hooks/usePredictPlaceOrder', () => ({ + usePredictPlaceOrder: () => ({ + showOrderPlacedToast: mockShowOrderPlacedToast, + invalidateOrderQueries: mockInvalidateOrderQueries, + }), +})); + +jest.mock('./hooks/usePredictBuyAvailableBalance', () => ({ + usePredictBuyAvailableBalance: () => ({ + availableBalance: 10, + isBalanceLoading: false, + }), +})); + +jest.mock('./hooks/usePredictBuyInputState', () => ({ + usePredictBuyInputState: () => ({ + currentValue: 20, + setCurrentValue: mockSetCurrentValue, + currentValueUSDString: '$20.00', + setCurrentValueUSDString: mockSetCurrentValueUSDString, + isInputFocused: false, + setIsInputFocused: mockSetIsInputFocused, + isUserInputChange: true, + setIsUserInputChange: mockSetIsUserInputChange, + isConfirming: false, + setIsConfirming: mockSetIsConfirming, + }), +})); + +jest.mock('./hooks/usePredictBuyInfo', () => ({ + usePredictBuyInfo: () => ({ + toWin: 24, + metamaskFee: 1, + providerFee: 2, + total: 23, + depositFee: 3, + depositAmount: 4, + rewardsFeeAmount: 5, + totalPayForPredictBalance: 20, + }), +})); + +jest.mock('./hooks/usePredictBuyConditions', () => ({ + usePredictBuyConditions: () => ({ + canPlaceBet: true, + isUserChangeTriggeringCalculation: false, + isPayFeesLoading: false, + isBalancePulsing: false, + isBelowMinimum: false, + isInsufficientBalance: false, + maxBetAmount: 50, + canSelectToken: mockCanSelectToken, + }), +})); + +jest.mock('./hooks/usePredictBuyError', () => ({ + usePredictBuyError: () => ({ + errorMessage: mockErrorMessage, + isOrderNotFilled: false, + resetOrderNotFilled: mockResetOrderNotFilled, + }), +})); + +jest.mock('./hooks/usePredictBuyActions', () => ({ + usePredictBuyActions: () => ({ + handleConfirm: mockHandleConfirm, + placeOrder: mockPlaceOrder, + }), +})); + +jest.mock( + './components/PredictBuyPreviewHeader/PredictBuyPreviewHeader', + () => { + const { Text } = jest.requireActual('react-native'); + return function MockPredictBuyPreviewHeader() { + return Header; + }; + }, +); + +jest.mock('./components/PredictBuyAmountSection', () => { + const { Text } = jest.requireActual('react-native'); + return function MockPredictBuyAmountSection({ + availableBalanceDisplay, + isPlacingOrder, + }: { + availableBalanceDisplay: string; + isPlacingOrder: boolean; + }) { + return ( + + {`Amount Section ${availableBalanceDisplay} placing-${String( + isPlacingOrder, + )}`} + + ); + }; +}); + +jest.mock('./components/PredictBuyBottomContent', () => { + const { View } = jest.requireActual('react-native'); + return function MockPredictBuyBottomContent({ + children, + }: { + children: React.ReactNode; + }) { + return {children}; + }; +}); + +jest.mock('./components/PredictBuyError', () => { + const { Text } = jest.requireActual('react-native'); + return function MockPredictBuyError({ + errorMessage, + }: { + errorMessage?: string; + }) { + return {errorMessage ?? 'no-error'}; + }; +}); + +jest.mock('../../components/PredictFeeBreakdownSheet', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return ReactActual.forwardRef( + ( + { + onClose, + fakOrdersEnabled, + }: { + onClose: () => void; + fakOrdersEnabled: boolean; + }, + _ref: unknown, + ) => ( + + {`fak-orders-${String(fakOrdersEnabled)}`} + + Close Fee Breakdown + + + ), + ); +}); + +jest.mock('../../components/PredictKeypad', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return ReactActual.forwardRef((_props: unknown, _ref: unknown) => ( + + )); +}); + +jest.mock('../../components/PredictOrderRetrySheet', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return ReactActual.forwardRef((_props: unknown, _ref: unknown) => ( + + )); +}); + +jest.mock('./components/PredictPayWithAnyTokenInfo', () => { + const { Text } = jest.requireActual('react-native'); + return function MockPredictPayWithAnyTokenInfo({ + depositAmount, + }: { + depositAmount: number; + }) { + return ( + {depositAmount} + ); + }; +}); + +jest.mock('./components/PredictPayWithRow', () => { + const { Text } = jest.requireActual('react-native'); + return { + PredictPayWithRow: ({ disabled }: { disabled?: boolean }) => ( + {`disabled-${String(disabled)}`} + ), + }; +}); + +jest.mock('./components/PredictFeeSummary/PredictFeeSummary', () => { + const { Pressable, Text } = jest.requireActual('react-native'); + return function MockPredictFeeSummary({ + handleFeesInfoPress, + }: { + handleFeesInfoPress: () => void; + }) { + return ( + + Fee Summary + + ); + }; +}); + +jest.mock('./components/PredictBuyActionButton', () => { + const { Pressable, Text } = jest.requireActual('react-native'); + return function MockPredictBuyActionButton({ + onPress, + disabled, + }: { + onPress: () => void; + disabled: boolean; + }) { + return ( + + {`button-disabled-${String(disabled)}`} + + ); + }; +}); + +const mockUseSelector = useSelector as jest.MockedFunction; + +describe('PredictBuyWithAnyToken', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPayWithAnyTokenEnabled = true; + mockFakOrdersEnabled = false; + mockIsPreviewCalculating = false; + mockIsPlacingOrder = false; + mockCanSelectToken = true; + mockErrorMessage = undefined; + mockUseSelector.mockImplementation((selector) => { + if (typeof selector === 'function') { + return selector({ + engine: { + backgroundState: { + RemoteFeatureFlagController: {}, + }, + }, + }); + } + + return undefined; + }); + }); + + it('renders the screen, resets user input change after preview calculation, and opens the fee breakdown sheet', () => { + renderWithProvider(); + + expect(screen.getByTestId('predict-buy-preview-header')).toBeOnTheScreen(); + expect(screen.getByTestId('predict-buy-amount-section')).toHaveTextContent( + 'Amount Section $10.00 placing-false', + ); + expect(screen.getByTestId('predict-pay-with-row')).toHaveTextContent( + 'disabled-false', + ); + expect(mockSetIsUserInputChange).toHaveBeenCalledWith(false); + + fireEvent.press(screen.getByTestId('predict-fee-summary')); + + expect(screen.getByTestId('predict-fee-breakdown-sheet')).toBeOnTheScreen(); + + fireEvent.press(screen.getByTestId('close-fee-breakdown')); + + expect( + screen.queryByTestId('predict-fee-breakdown-sheet'), + ).not.toBeOnTheScreen(); + }); + + it('hides the pay with row when the feature flag is disabled', () => { + mockPayWithAnyTokenEnabled = false; + + renderWithProvider(); + + expect(screen.queryByTestId('predict-pay-with-row')).not.toBeOnTheScreen(); + }); + + it('disables the pay with row when token selection is unavailable and forwards the confirm action', () => { + mockCanSelectToken = false; + mockErrorMessage = 'Insufficient balance'; + + renderWithProvider(); + + expect(screen.getByTestId('predict-pay-with-row')).toHaveTextContent( + 'disabled-true', + ); + expect(screen.getByTestId('predict-buy-error')).toHaveTextContent( + 'Insufficient balance', + ); + expect( + screen.getByTestId('predict-pay-with-any-token-info'), + ).toHaveTextContent('4'); + + fireEvent.press(screen.getByTestId('predict-buy-action-button')); + + expect(mockHandleConfirm).toHaveBeenCalledTimes(1); + }); + + it('does not reset user input change while preview calculation is still running', () => { + mockIsPreviewCalculating = true; + + renderWithProvider(); + + expect(mockSetIsUserInputChange).not.toHaveBeenCalled(); + }); + + it('disables token selection while an order is being placed', () => { + mockIsPlacingOrder = true; + + renderWithProvider(); + + expect(screen.getByTestId('predict-buy-amount-section')).toHaveTextContent( + 'Amount Section $10.00 placing-true', + ); + expect(screen.getByTestId('predict-pay-with-row')).toHaveTextContent( + 'disabled-true', + ); + }); +}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx index a752c80ee1e..1d817c461cc 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx @@ -14,7 +14,7 @@ import React, { useState, } from 'react'; import { ScrollView } from 'react-native'; -import { Edge, SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useSelector } from 'react-redux'; import { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet'; import { TraceName } from '../../../../../util/trace'; @@ -22,7 +22,7 @@ import { PredictBuyPreviewSelectorsIDs } from '../../Predict.testIds'; import PredictBuyActionButton from './components/PredictBuyActionButton'; import PredictBuyAmountSection from './components/PredictBuyAmountSection'; import PredictBuyBottomContent from './components/PredictBuyBottomContent'; -import PredictBuyMinimumError from './components/PredictBuyMinimumError'; +import PredictBuyError from './components/PredictBuyError'; import PredictBuyPreviewHeader from './components/PredictBuyPreviewHeader/PredictBuyPreviewHeader'; import PredictFeeBreakdownSheet from '../../components/PredictFeeBreakdownSheet'; import PredictFeeSummary from './components/PredictFeeSummary/PredictFeeSummary'; @@ -33,24 +33,24 @@ import PredictOrderRetrySheet from '../../components/PredictOrderRetrySheet'; import PredictPayWithAnyTokenInfo from './components/PredictPayWithAnyTokenInfo'; import { PredictPayWithRow } from './components/PredictPayWithRow'; import { usePredictBuyAvailableBalance } from './hooks/usePredictBuyAvailableBalance'; -import usePredictBuyBackSwipe from './hooks/usePredictBuyBackSwipe'; import { usePredictBuyConditions } from './hooks/usePredictBuyConditions'; import { usePredictBuyInfo } from './hooks/usePredictBuyInfo'; import { usePredictBuyInputState } from './hooks/usePredictBuyInputState'; -import { usePredictBuyActions } from './hooks/usePredictBuyPreviewActions'; +import { usePredictBuyActions } from './hooks/usePredictBuyActions'; import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; import { usePredictOrderPreview } from '../../hooks/usePredictOrderPreview'; import { usePredictOrderRetry } from '../../hooks/usePredictOrderRetry'; -import { usePredictPayWithAnyTokenTracking } from './hooks/usePredictPayWithAnyTokenTracking'; -import { usePredictPaymentToken } from '../../hooks/usePredictPaymentToken'; import { usePredictPlaceOrder } from '../../hooks/usePredictPlaceOrder'; -import { selectPredictFakOrdersEnabledFlag } from '../../selectors/featureFlags'; +import { + selectPredictFakOrdersEnabledFlag, + selectPredictWithAnyTokenEnabledFlag, +} from '../../selectors/featureFlags'; import { Side } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; import { parseAnalyticsProperties } from '../../utils/analytics'; -import { usePredictOrderTracking } from './hooks/usePredictOrderTracking'; - -const SHOW_TOKEN_SELECTION = false; +import { formatPrice } from '../../utils/format'; +import { usePredictBuyError } from './hooks/usePredictBuyError'; +import { usePredictActiveOrder } from '../../hooks/usePredictActiveOrder'; const PredictBuyWithAnyToken = () => { const tw = useTailwind(); @@ -59,17 +59,19 @@ const PredictBuyWithAnyToken = () => { const route = useRoute>(); - const { - market, - outcome, - outcomeToken, - entryPoint, - isConfirmation, - preview: initialPreview, - } = route.params; + const { market, outcome, outcomeToken, entryPoint } = route.params; + + const { isPlacingOrder } = usePredictActiveOrder(); + const { showOrderPlacedToast, invalidateOrderQueries } = + usePredictPlaceOrder(); const [isFeeBreakdownVisible, setIsFeeBreakdownVisible] = useState(false); + const payWithAnyTokenEnabled = useSelector( + selectPredictWithAnyTokenEnabledFlag, + ); + const fakOrdersEnabled = useSelector(selectPredictFakOrdersEnabledFlag); + const analyticsProperties = useMemo( () => parseAnalyticsProperties(market, outcomeToken, entryPoint), [market, outcomeToken, entryPoint], @@ -78,6 +80,15 @@ const PredictBuyWithAnyToken = () => { const { availableBalance, isBalanceLoading } = usePredictBuyAvailableBalance(); + const availableBalanceDisplay = useMemo( + () => + formatPrice(availableBalance, { + minimumDecimals: 2, + maximumDecimals: 2, + }), + [availableBalance], + ); + const { currentValue, setCurrentValue, @@ -91,15 +102,6 @@ const PredictBuyWithAnyToken = () => { setIsConfirming, } = usePredictBuyInputState(); - const { - placeOrder, - isLoading: isPlaceOrderLoading, - error: placeOrderError, - result, - isOrderNotFilled, - resetOrderNotFilled, - } = usePredictPlaceOrder(); - const handleFeesInfoPress = useCallback(() => { setIsFeeBreakdownVisible(true); }, []); @@ -108,8 +110,6 @@ const PredictBuyWithAnyToken = () => { setIsFeeBreakdownVisible(false); }, []); - const fakOrdersEnabled = useSelector(selectPredictFakOrdersEnabledFlag); - const { preview, error: previewError, @@ -121,7 +121,6 @@ const PredictBuyWithAnyToken = () => { side: Side.BUY, size: currentValue, autoRefreshTimeout: 1000, - initialPreview, }); const { @@ -130,60 +129,54 @@ const PredictBuyWithAnyToken = () => { providerFee, total, depositFee, + depositAmount, rewardsFeeAmount, - errorMessage, + totalPayForPredictBalance, } = usePredictBuyInfo({ currentValue, preview, previewError, - isPlaceOrderLoading, - placeOrderError, - isOrderNotFilled, isConfirming, + isPlacingOrder, }); const { - handleBack, - handleBackSwipe, - handleTokenSelected, - handleConfirm, - handleDepositFailed, - handlePlaceOrderSuccess, - handlePlaceOrderError, - } = usePredictBuyActions({ - currentValue, - analyticsProperties, - preview, - placeOrder, - depositAmount: total - depositFee, - setIsConfirming, - }); - - usePredictBuyBackSwipe({ onBack: handleBackSwipe }); - - usePredictPayWithAnyTokenTracking({ - onFail: handleDepositFailed, - onConfirm: handleConfirm, - }); - - const { - isPlacingOrder, - isBelowMinimum, canPlaceBet, isUserChangeTriggeringCalculation, isPayFeesLoading, isBalancePulsing, + isBelowMinimum, + isInsufficientBalance, + maxBetAmount, + canSelectToken, } = usePredictBuyConditions({ currentValue, preview, isPreviewCalculating, - isPlaceOrderLoading, isUserInputChange, isConfirming, + totalPayForPredictBalance, + isInputFocused, }); - usePredictPaymentToken({ - onTokenSelected: handleTokenSelected, + const { errorMessage, isOrderNotFilled, resetOrderNotFilled } = + usePredictBuyError({ + preview, + previewError, + isPlacingOrder, + isBelowMinimum, + isInsufficientBalance, + maxBetAmount, + isConfirming, + isPayFeesLoading, + }); + + const { handleConfirm, placeOrder } = usePredictBuyActions({ + analyticsProperties, + preview, + setIsConfirming, + showOrderPlacedToast, + invalidateOrderQueries, }); useEffect(() => { @@ -216,28 +209,13 @@ const PredictBuyWithAnyToken = () => { }, }); - usePredictOrderTracking({ - result, - error: placeOrderError, - onSuccess: handlePlaceOrderSuccess, - onError: handlePlaceOrderError, - }); - - const edges = useMemo( - () => (isConfirmation ? (['top', 'left', 'right'] as Edge[]) : undefined), - [isConfirmation], - ); - return ( - + { isInputFocused={isInputFocused} isBalanceLoading={isBalanceLoading} isBalancePulsing={isBalancePulsing} - availableBalanceDisplay={availableBalance} + availableBalanceDisplay={availableBalanceDisplay} toWin={toWin} isShowingToWinSkeleton={isUserChangeTriggeringCalculation} + isPlacingOrder={isPlacingOrder} /> - {SHOW_TOKEN_SELECTION && ( - + {payWithAnyTokenEnabled && ( + )} - + { setCurrentValueUSDString={setCurrentValueUSDString} setIsInputFocused={setIsInputFocused} /> - + { onDismiss={resetOrderNotFilled} isRetrying={isRetrying} /> - {isConfirmation && ( - - )} + ); }; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx index d813b162b28..94d960a30f0 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { screen } from '@testing-library/react-native'; +import { fireEvent, screen } from '@testing-library/react-native'; import PredictBuyAmountSection from './PredictBuyAmountSection'; import renderWithProvider from '../../../../../../../util/test/renderWithProvider'; @@ -20,12 +20,24 @@ jest.mock('../../../../utils/format', () => ({ })); jest.mock('../../../../components/PredictAmountDisplay', () => { - const { View: RNView, Text: RNText } = jest.requireActual('react-native'); + const { + Pressable: RNPressable, + View: RNView, + Text: RNText, + } = jest.requireActual('react-native'); return function MockPredictAmountDisplay(props: Record) { return ( - - {props.amount as string} - + void} + > + + {props.amount as string} + + {String(props.isActive)} + + + ); }; }); @@ -65,6 +77,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -82,6 +95,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -100,6 +114,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$1,234.56" toWin={250} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -119,6 +134,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton + isPlacingOrder={false} />, ); @@ -136,6 +152,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={150} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -153,6 +170,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -172,6 +190,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={250} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -189,11 +208,34 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); const amountDisplay = screen.getByTestId('amount-display'); - expect(amountDisplay).toBeOnTheScreen(); + fireEvent.press(amountDisplay); + + expect(mockKeypadRef.current.handleAmountPress).toHaveBeenCalledTimes(1); + }); + + it('marks the amount display as active when focused and not placing an order', () => { + renderWithProvider( + , + ); + + expect(screen.getByTestId('amount-display-active')).toHaveTextContent( + 'true', + ); }); }); @@ -209,6 +251,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -226,6 +269,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -245,6 +289,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={0} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -262,6 +307,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$50000" toWin={10000} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -279,6 +325,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -296,6 +343,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); @@ -313,6 +361,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={100} isShowingToWinSkeleton + isPlacingOrder={false} />, ); @@ -321,6 +370,31 @@ describe('PredictBuyAmountSection', () => { }); }); + describe('isPlacingOrder behavior', () => { + it('disables amount press and isActive when isPlacingOrder is true', () => { + renderWithProvider( + , + ); + + fireEvent.press(screen.getByTestId('amount-display')); + + expect(mockKeypadRef.current.handleAmountPress).not.toHaveBeenCalled(); + expect(screen.getByTestId('amount-display-active')).toHaveTextContent( + 'false', + ); + }); + }); + describe('integration', () => { it('displays all sections together', () => { renderWithProvider( @@ -333,6 +407,7 @@ describe('PredictBuyAmountSection', () => { availableBalanceDisplay="$500" toWin={150} isShowingToWinSkeleton={false} + isPlacingOrder={false} />, ); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.tsx index 1e0f0a6f32e..521540ea5f4 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.tsx @@ -25,6 +25,7 @@ interface PredictBuyAmountSectionProps { availableBalanceDisplay: string; toWin: number; isShowingToWinSkeleton: boolean; + isPlacingOrder: boolean; } const PredictBuyAmountSection = ({ @@ -36,6 +37,7 @@ const PredictBuyAmountSection = ({ availableBalanceDisplay, toWin, isShowingToWinSkeleton, + isPlacingOrder, }: PredictBuyAmountSectionProps) => { const tw = useTailwind(); const pulseAnim = useRef(new Animated.Value(1)).current; @@ -68,8 +70,10 @@ const PredictBuyAmountSection = ({ keypadRef.current?.handleAmountPress()} - isActive={isInputFocused} + onPress={() => + !isPlacingOrder && keypadRef.current?.handleAmountPress() + } + isActive={isInputFocused && !isPlacingOrder} hasError={false} /> diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx index ea0670f1819..ca84d50fcf9 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx @@ -37,7 +37,7 @@ describe('PredictBuyBottomContent', () => { describe('when isInputFocused is true', () => { it('returns null and does not render anything', () => { renderWithProvider( - + {mockChildren} , ); @@ -45,26 +45,12 @@ describe('PredictBuyBottomContent', () => { expect(screen.queryByText(/Disclaimer text/)).not.toBeOnTheScreen(); expect(screen.queryByTestId('children-content')).not.toBeOnTheScreen(); }); - - it('returns null even when errorMessage is provided', () => { - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.queryByText(/Error message/)).not.toBeOnTheScreen(); - expect(screen.queryByText(/Disclaimer text/)).not.toBeOnTheScreen(); - }); }); describe('when isInputFocused is false', () => { it('renders children content', () => { renderWithProvider( - + {mockChildren} , ); @@ -74,10 +60,7 @@ describe('PredictBuyBottomContent', () => { it('renders disclaimer text', () => { renderWithProvider( - + {mockChildren} , ); @@ -87,10 +70,7 @@ describe('PredictBuyBottomContent', () => { it('renders learn more link', () => { renderWithProvider( - + {mockChildren} , ); @@ -100,10 +80,7 @@ describe('PredictBuyBottomContent', () => { it('opens Polymarket TOS URL when learn more is pressed', () => { renderWithProvider( - + {mockChildren} , ); @@ -117,61 +94,7 @@ describe('PredictBuyBottomContent', () => { }); }); - describe('error message display', () => { - it('displays error message when provided', () => { - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.getByText(/Insufficient balance/)).toBeOnTheScreen(); - }); - - it('does not display error message when not provided', () => { - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.queryByText(/Insufficient balance/)).not.toBeOnTheScreen(); - }); - - it('displays error message along with children and disclaimer', () => { - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.getByText(/Network error/)).toBeOnTheScreen(); - expect(screen.getByTestId('children-content')).toBeOnTheScreen(); - expect(screen.getByText(/Disclaimer text/)).toBeOnTheScreen(); - }); - }); - describe('edge cases', () => { - it('handles empty error message string', () => { - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.getByTestId('children-content')).toBeOnTheScreen(); - expect(screen.getByText(/Disclaimer text/)).toBeOnTheScreen(); - }); - it('handles multiple children elements', () => { const multipleChildren = ( <> @@ -185,10 +108,7 @@ describe('PredictBuyBottomContent', () => { ); renderWithProvider( - + {multipleChildren} , ); @@ -196,31 +116,12 @@ describe('PredictBuyBottomContent', () => { expect(screen.getByTestId('child-1')).toBeOnTheScreen(); expect(screen.getByTestId('child-2')).toBeOnTheScreen(); }); - - it('handles long error message text', () => { - const longError = - 'This is a very long error message that explains in detail what went wrong with the transaction'; - - renderWithProvider( - - {mockChildren} - , - ); - - expect(screen.getByText(new RegExp(longError))).toBeOnTheScreen(); - }); }); describe('Linking behavior', () => { it('calls Linking.openURL with correct URL', () => { renderWithProvider( - + {mockChildren} , ); @@ -236,10 +137,7 @@ describe('PredictBuyBottomContent', () => { it('opens URL only when learn more is pressed', () => { renderWithProvider( - + {mockChildren} , ); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx index a743fee508c..8d90ccc7f02 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx @@ -13,13 +13,11 @@ import { strings } from '../../../../../../../../locales/i18n'; interface PredictBuyBottomContentProps { isInputFocused: boolean; - errorMessage?: string; children: React.ReactNode; } const PredictBuyBottomContent = ({ isInputFocused, - errorMessage, children, }: PredictBuyBottomContentProps) => { const tw = useTailwind(); @@ -34,15 +32,6 @@ const PredictBuyBottomContent = ({ twClassName="border-t border-muted px-4 pb-0" > - {errorMessage && ( - - {errorMessage} - - )} {children} diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.test.tsx new file mode 100644 index 00000000000..63e1b1238ca --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { screen } from '@testing-library/react-native'; +import PredictBuyError from './PredictBuyError'; +import renderWithProvider from '../../../../../../../util/test/renderWithProvider'; + +describe('PredictBuyError', () => { + describe('when errorMessage is undefined', () => { + it('renders nothing', () => { + renderWithProvider(); + + expect(screen.queryByText(/./)).not.toBeOnTheScreen(); + }); + }); + + describe('when errorMessage is empty string', () => { + it('renders nothing', () => { + renderWithProvider(); + + expect(screen.queryByText(/./)).not.toBeOnTheScreen(); + }); + }); + + describe('when errorMessage is provided', () => { + it('displays the error message text', () => { + const errorMessage = 'Insufficient balance'; + + renderWithProvider(); + + expect(screen.getByText(errorMessage)).toBeOnTheScreen(); + }); + + it('renders error text with centered alignment and error color', () => { + const errorMessage = 'Minimum bet required'; + + renderWithProvider(); + + const errorText = screen.getByText(errorMessage); + expect(errorText).toBeOnTheScreen(); + expect(errorText.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ textAlign: 'center' }), + ]), + ); + }); + }); + + describe('when no props provided', () => { + it('renders nothing', () => { + renderWithProvider(); + + expect(screen.queryByText(/./)).not.toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.tsx new file mode 100644 index 00000000000..f23644b1a58 --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/PredictBuyError.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { + Box, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; + +interface PredictBuyErrorProps { + errorMessage?: string; +} + +const PredictBuyError = ({ errorMessage }: PredictBuyErrorProps) => { + const tw = useTailwind(); + + if (!errorMessage) return null; + + return ( + + + {errorMessage} + + + ); +}; + +export default PredictBuyError; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/index.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/index.ts new file mode 100644 index 00000000000..7f900bfa2da --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyError/index.ts @@ -0,0 +1 @@ +export { default } from './PredictBuyError'; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.test.tsx deleted file mode 100644 index e5bf94cec96..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react-native'; -import PredictBuyMinimumError from './PredictBuyMinimumError'; -import renderWithProvider from '../../../../../../../util/test/renderWithProvider'; - -jest.mock('../../../../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string, options?: Record) => { - if (key === 'predict.order.prediction_minimum_bet') { - return `Minimum bet: ${options?.amount}`; - } - return key; - }), -})); - -jest.mock('../../../../utils/format', () => ({ - formatPrice: jest.fn((value: number) => `$${value.toFixed(2)}`), -})); - -jest.mock('../../../../constants/transactions', () => ({ - MINIMUM_BET: 1, -})); - -describe('PredictBuyMinimumError', () => { - describe('when isBalanceLoading is true', () => { - it('returns null and does not render anything', () => { - renderWithProvider( - , - ); - - expect(screen.queryByText(/Minimum bet:/)).not.toBeOnTheScreen(); - }); - - it('returns null even when isBelowMinimum is true', () => { - renderWithProvider( - , - ); - - expect(screen.queryByText(/Minimum bet:/)).not.toBeOnTheScreen(); - }); - }); - - describe('when isBalanceLoading is false and isBelowMinimum is true', () => { - it('displays error message with formatted minimum bet amount', () => { - renderWithProvider( - , - ); - - expect(screen.getByText(/Minimum bet:/)).toBeOnTheScreen(); - expect(screen.getByText(/\$1\.00/)).toBeOnTheScreen(); - }); - - it('renders error text with correct styling', () => { - renderWithProvider( - , - ); - - const errorText = screen.getByText(/Minimum bet:/); - expect(errorText).toBeOnTheScreen(); - }); - - it('centers the error text', () => { - renderWithProvider( - , - ); - - const errorText = screen.getByText(/Minimum bet:/); - expect(errorText.props.style).toEqual( - expect.arrayContaining([ - expect.objectContaining({ textAlign: 'center' }), - ]), - ); - }); - }); - - describe('when isBalanceLoading is false and isBelowMinimum is false', () => { - it('returns null and does not render anything', () => { - renderWithProvider( - , - ); - - expect(screen.queryByText(/Minimum bet:/)).not.toBeOnTheScreen(); - }); - }); - - describe('edge cases', () => { - it('handles both flags being false', () => { - renderWithProvider( - , - ); - - expect(screen.queryByText(/Minimum bet:/)).not.toBeOnTheScreen(); - }); - - it('prioritizes isBalanceLoading over isBelowMinimum', () => { - renderWithProvider( - , - ); - - expect(screen.queryByText(/Minimum bet:/)).not.toBeOnTheScreen(); - }); - }); -}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.tsx deleted file mode 100644 index 96c57ef44db..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import { - Box, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { strings } from '../../../../../../../../locales/i18n'; -import { formatPrice } from '../../../../utils/format'; -import { MINIMUM_BET } from '../../../../constants/transactions'; - -interface PredictBuyMinimumErrorProps { - isBalanceLoading: boolean; - isBelowMinimum: boolean; -} - -const PredictBuyMinimumError = ({ - isBalanceLoading, - isBelowMinimum, -}: PredictBuyMinimumErrorProps) => { - const tw = useTailwind(); - - if (isBalanceLoading) return null; - - if (isBelowMinimum) { - return ( - - - {strings('predict.order.prediction_minimum_bet', { - amount: formatPrice(MINIMUM_BET, { - minimumDecimals: 2, - maximumDecimals: 2, - }), - })} - - - ); - } - - return null; -}; - -export default PredictBuyMinimumError; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/index.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/index.ts deleted file mode 100644 index c79e41c5253..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './PredictBuyMinimumError'; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.test.tsx index c77f1761c4f..f37374f9d3b 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.test.tsx @@ -10,6 +10,7 @@ import { Recurrence, type PredictMarket, type PredictOutcome, + type PredictOutcomeToken, type OrderPreview, } from '../../../../types'; @@ -100,6 +101,15 @@ describe('PredictBuyPreviewHeader', () => { ...overrides, }); + const createMockOutcomeToken = ( + overrides?: Partial, + ): PredictOutcomeToken => ({ + id: 'token-1', + title: 'Yes', + price: 0.65, + ...overrides, + }); + const createMockOrderPreview = ( overrides?: Partial, ): OrderPreview => ({ @@ -124,7 +134,11 @@ describe('PredictBuyPreviewHeader', () => { const outcome = createMockOutcome(); renderWithProvider( - , + , ); expect(screen.getByTestId('back-button')).toBeOnTheScreen(); @@ -135,7 +149,11 @@ describe('PredictBuyPreviewHeader', () => { const outcome = createMockOutcome(); renderWithProvider( - , + , ); expect(screen.getByText(/Will Bitcoin reach \$100k\?/)).toBeOnTheScreen(); @@ -150,6 +168,7 @@ describe('PredictBuyPreviewHeader', () => { , ); @@ -167,7 +186,11 @@ describe('PredictBuyPreviewHeader', () => { const outcome = createMockOutcome(); renderWithProvider( - , + , ); expect(screen.getByText(/Will Bitcoin reach \$100k\?/)).toBeOnTheScreen(); @@ -178,7 +201,11 @@ describe('PredictBuyPreviewHeader', () => { const outcome = createMockOutcome(); renderWithProvider( - , + , ); expect(screen.getByText(/Yes at 0\.65¢/)).toBeOnTheScreen(); @@ -191,7 +218,11 @@ describe('PredictBuyPreviewHeader', () => { }); renderWithProvider( - , + , ); expect(screen.getByText(/Q1 2024/)).toBeOnTheScreen(); @@ -204,7 +235,11 @@ describe('PredictBuyPreviewHeader', () => { }); renderWithProvider( - , + , ); expect(screen.queryByText(/Q1 2024/)).not.toBeOnTheScreen(); @@ -227,6 +262,11 @@ describe('PredictBuyPreviewHeader', () => { , ); @@ -234,7 +274,7 @@ describe('PredictBuyPreviewHeader', () => { expect(screen.getByText(/Yes \(alt\) at 0\.6¢/)).toBeOnTheScreen(); }); - it('falls back to first token when outcomeTokenId not found', () => { + it('falls back to outcomeToken prop when outcomeTokenId not found in outcome tokens', () => { const market = createMockMarket(); const outcome = createMockOutcome({ tokens: [ @@ -251,11 +291,16 @@ describe('PredictBuyPreviewHeader', () => { , ); - expect(screen.getByText(/Yes at 0\.65¢/)).toBeOnTheScreen(); + expect(screen.getByText(/Yes \(alt\) at 0\.65¢/)).toBeOnTheScreen(); }); it('uses preview sharePrice when provided', () => { @@ -272,6 +317,7 @@ describe('PredictBuyPreviewHeader', () => { , ); @@ -286,7 +332,11 @@ describe('PredictBuyPreviewHeader', () => { }); renderWithProvider( - , + , ); const outcomeText = screen.getByText(/Yes at 0\.65¢/); @@ -301,7 +351,15 @@ describe('PredictBuyPreviewHeader', () => { }); renderWithProvider( - , + , ); const outcomeText = screen.getByText(/No at 0\.35¢/); @@ -351,7 +409,11 @@ describe('PredictBuyPreviewHeader', () => { }); renderWithProvider( - , + , ); expect(screen.getByText(/Will Bitcoin reach \$100k\?/)).toBeOnTheScreen(); @@ -365,7 +427,11 @@ describe('PredictBuyPreviewHeader', () => { const outcome = createMockOutcome(); renderWithProvider( - , + , ); expect( @@ -380,13 +446,17 @@ describe('PredictBuyPreviewHeader', () => { }); renderWithProvider( - , + , ); expect(screen.getByText(/Yes \(50%\+\) at 0\.65¢/)).toBeOnTheScreen(); }); - it('handles null preview', () => { + it('handles null preview by using outcomeToken prop', () => { const market = createMockMarket(); const outcome = createMockOutcome(); @@ -394,14 +464,19 @@ describe('PredictBuyPreviewHeader', () => { , ); - expect(screen.getByText(/Yes at 0\.65¢/)).toBeOnTheScreen(); + expect(screen.getByText(/No at 0\.35¢/)).toBeOnTheScreen(); }); - it('handles undefined preview', () => { + it('handles undefined preview by using outcomeToken prop', () => { const market = createMockMarket(); const outcome = createMockOutcome(); @@ -409,11 +484,16 @@ describe('PredictBuyPreviewHeader', () => { , ); - expect(screen.getByText(/Yes at 0\.65¢/)).toBeOnTheScreen(); + expect(screen.getByText(/No at 0\.35¢/)).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.tsx index f3555963115..8d438d25d1e 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.tsx @@ -14,12 +14,18 @@ import { useNavigation } from '@react-navigation/native'; import React from 'react'; import { Image, TouchableOpacity } from 'react-native'; import { strings } from '../../../../../../../../locales/i18n'; -import { OrderPreview, PredictMarket, PredictOutcome } from '../../../../types'; +import { + OrderPreview, + PredictMarket, + PredictOutcome, + PredictOutcomeToken, +} from '../../../../types'; import { formatCents } from '../../../../utils/format'; export interface PredictBuyPreviewHeaderProps { market: PredictMarket; outcome: PredictOutcome; + outcomeToken: PredictOutcomeToken; preview?: OrderPreview | null; onBack?: () => void; } @@ -27,16 +33,18 @@ export interface PredictBuyPreviewHeaderProps { export interface PredictBuyPreviewHeaderTitleProps { market: PredictMarket; outcome: PredictOutcome; + outcomeToken: PredictOutcomeToken; preview?: OrderPreview | null; } const getOutcomeTokenLabel = ( outcome: PredictOutcome, + outcomeToken: PredictOutcomeToken, preview?: OrderPreview | null, ) => { const selectedOutcomeToken = outcome.tokens.find((token) => token.id === preview?.outcomeTokenId) ?? - outcome.tokens[0]; + outcomeToken; const sharePrice = preview?.sharePrice ?? selectedOutcomeToken?.price ?? 0; return { @@ -48,11 +56,13 @@ const getOutcomeTokenLabel = ( export function PredictBuyPreviewHeaderTitle({ market, outcome, + outcomeToken, preview, }: PredictBuyPreviewHeaderTitleProps) { const tw = useTailwind(); const { title: outcomeTokenTitle, sharePrice } = getOutcomeTokenLabel( outcome, + outcomeToken, preview, ); @@ -137,6 +147,7 @@ export function PredictBuyPreviewHeaderBack({ const PredictBuyPreviewHeader = ({ market, outcome, + outcomeToken, preview, onBack, }: PredictBuyPreviewHeaderProps) => ( @@ -149,6 +160,7 @@ const PredictBuyPreviewHeader = ({ diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.test.tsx index c12d3e02342..b9a64cd33c7 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.test.tsx @@ -7,13 +7,37 @@ let mockUpdatePendingAmount = jest.fn(); let mockAmountHuman = ''; let mockUpdateTokenAmountCallback = jest.fn(); let mockActiveTransactionMeta: { id?: string } | null = null; +let mockSelectedPaymentToken: + | { + address: string; + chainId: string; + } + | undefined; +let mockPayToken: + | { + address: string; + chainId: string; + } + | undefined; +let mockSetPayToken = jest.fn(); jest.mock('../../../../hooks/usePredictPaymentToken', () => ({ usePredictPaymentToken: () => ({ isPredictBalanceSelected: mockIsPredictBalanceSelected, + selectedPaymentToken: mockSelectedPaymentToken, }), })); +jest.mock( + '../../../../../../Views/confirmations/hooks/pay/useTransactionPayToken', + () => ({ + useTransactionPayToken: () => ({ + setPayToken: mockSetPayToken, + payToken: mockPayToken, + }), + }), +); + jest.mock( '../../../../../../Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe', () => jest.fn(), @@ -57,6 +81,9 @@ describe('PredictPayWithAnyTokenInfo', () => { mockAmountHuman = ''; mockUpdateTokenAmountCallback = jest.fn(); mockActiveTransactionMeta = null; + mockSelectedPaymentToken = undefined; + mockPayToken = undefined; + mockSetPayToken = jest.fn(); }); describe('render', () => { @@ -126,14 +153,24 @@ describe('PredictPayWithAnyTokenInfo', () => { }); describe('updateTokenAmountCallback effect', () => { - it('calls updateTokenAmountCallback when amountHuman is valid', () => { + it('calls updateTokenAmountCallback with the parsed deposit amount when amountHuman is valid', () => { mockIsPredictBalanceSelected = false; mockActiveTransactionMeta = { id: 'tx-1' }; mockAmountHuman = '100.50'; render(); - expect(mockUpdateTokenAmountCallback).toHaveBeenCalledWith('100.50'); + expect(mockUpdateTokenAmountCallback).toHaveBeenCalledWith('100'); + }); + + it('uses the rounded parsed deposit amount instead of the fiat-converted amountHuman', () => { + mockIsPredictBalanceSelected = false; + mockActiveTransactionMeta = { id: 'tx-1' }; + mockAmountHuman = '2.078803'; + + render(); + + expect(mockUpdateTokenAmountCallback).toHaveBeenCalledWith('2.08'); }); it('does not call updateTokenAmountCallback when amountHuman is "0"', () => { @@ -186,4 +223,75 @@ describe('PredictPayWithAnyTokenInfo', () => { expect(mockUpdateTokenAmountCallback).not.toHaveBeenCalled(); }); }); + + describe('setPayToken effect', () => { + it('calls setPayToken when selected token is not applied', () => { + mockActiveTransactionMeta = { id: 'tx-1' }; + mockSelectedPaymentToken = { + address: '0xabc123', + chainId: '0x1', + }; + mockPayToken = { + address: '0xdef456', + chainId: '0x1', + }; + + render(); + + expect(mockSetPayToken).toHaveBeenCalledWith({ + address: '0xabc123', + chainId: '0x1', + }); + }); + + it('does not call setPayToken when selected token is already applied (case-insensitive)', () => { + mockActiveTransactionMeta = { id: 'tx-1' }; + mockSelectedPaymentToken = { + address: '0xAbC123', + chainId: '0x1', + }; + mockPayToken = { + address: '0xabc123', + chainId: '0X1', + }; + + render(); + + expect(mockSetPayToken).not.toHaveBeenCalled(); + }); + + it('does not call setPayToken when isPredictBalanceSelected is true', () => { + mockActiveTransactionMeta = { id: 'tx-1' }; + mockIsPredictBalanceSelected = true; + mockSelectedPaymentToken = { + address: '0xabc123', + chainId: '0x1', + }; + + render(); + + expect(mockSetPayToken).not.toHaveBeenCalled(); + }); + + it('does not call setPayToken when selectedPaymentToken is undefined', () => { + mockActiveTransactionMeta = { id: 'tx-1' }; + mockSelectedPaymentToken = undefined; + + render(); + + expect(mockSetPayToken).not.toHaveBeenCalled(); + }); + + it('does not call setPayToken when transactionMeta is missing', () => { + mockActiveTransactionMeta = null; + mockSelectedPaymentToken = { + address: '0xabc123', + chainId: '0x1', + }; + + render(); + + expect(mockSetPayToken).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx index 769857e43af..f72649f94ee 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx @@ -1,11 +1,12 @@ import BigNumber from 'bignumber.js'; -import { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { PREDICT_CURRENCY } from '../../../../../../Views/confirmations/constants/predict'; import { useTransactionCustomAmount } from '../../../../../../Views/confirmations/hooks/transactions/useTransactionCustomAmount'; import { useTransactionMetadataRequest } from '../../../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import { useUpdateTokenAmount } from '../../../../../../Views/confirmations/hooks/transactions/useUpdateTokenAmount'; import { usePredictPaymentToken } from '../../../../hooks/usePredictPaymentToken'; -import useClearConfirmationOnBackSwipe from '../../../../../../Views/confirmations/hooks/ui/useClearConfirmationOnBackSwipe'; +import { useTransactionPayToken } from '../../../../../../Views/confirmations/hooks/pay/useTransactionPayToken'; +import { Hex } from '@metamask/utils'; interface PredictPayWithAnyTokenInfoProps { depositAmount: number; @@ -14,15 +15,26 @@ interface PredictPayWithAnyTokenInfoProps { const PredictPayWithAnyTokenInfo = ({ depositAmount, }: PredictPayWithAnyTokenInfoProps) => { - const { isPredictBalanceSelected } = usePredictPaymentToken(); + const transactionMeta = useTransactionMetadataRequest(); - useClearConfirmationOnBackSwipe(); + if (!transactionMeta) { + return null; + } + + return ; +}; + +function PredictPayWithAnyTokenInfoInner({ + depositAmount, +}: PredictPayWithAnyTokenInfoProps) { + const { isPredictBalanceSelected, selectedPaymentToken } = + usePredictPaymentToken(); + const { setPayToken, payToken } = useTransactionPayToken(); + const transactionMeta = useTransactionMetadataRequest(); const { updateTokenAmount: updateTokenAmountCallback } = useUpdateTokenAmount(); - const activeTransactionMeta = useTransactionMetadataRequest(); - const parsedDepositAmount = useMemo(() => { if (isPredictBalanceSelected || depositAmount <= 0) { return ''; @@ -40,11 +52,11 @@ const PredictPayWithAnyTokenInfo = ({ if ( parsedDepositAmount && parsedDepositAmount.trim() !== '' && - activeTransactionMeta + transactionMeta ) { updatePendingAmount(parsedDepositAmount); } - }, [parsedDepositAmount, activeTransactionMeta, updatePendingAmount]); + }, [parsedDepositAmount, transactionMeta, updatePendingAmount]); useEffect(() => { if ( @@ -52,19 +64,44 @@ const PredictPayWithAnyTokenInfo = ({ amountHuman !== '0' && parsedDepositAmount && parsedDepositAmount.trim() !== '' && - activeTransactionMeta + transactionMeta ) { - updateTokenAmountCallback(amountHuman); + updateTokenAmountCallback(parsedDepositAmount); } }, [ amountHuman, - depositAmount, - activeTransactionMeta, + transactionMeta, updateTokenAmountCallback, parsedDepositAmount, ]); + useEffect(() => { + if (!transactionMeta || isPredictBalanceSelected || !selectedPaymentToken) { + return; + } + + const hasSelectedTokenApplied = + payToken?.address?.toLowerCase() === + selectedPaymentToken.address.toLowerCase() && + payToken?.chainId?.toLowerCase() === + selectedPaymentToken.chainId.toLowerCase(); + + if (!hasSelectedTokenApplied) { + setPayToken({ + address: selectedPaymentToken.address as Hex, + chainId: selectedPaymentToken.chainId as Hex, + }); + } + }, [ + transactionMeta, + isPredictBalanceSelected, + selectedPaymentToken, + payToken?.address, + payToken?.chainId, + setPayToken, + ]); + return null; -}; +} export default PredictPayWithAnyTokenInfo; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx index 87f171705d0..6ac4a0c5659 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx @@ -140,7 +140,6 @@ describe('PredictPayWithRow', () => { expect(mockNavigate).toHaveBeenCalledWith( Routes.CONFIRMATION_PAY_WITH_MODAL, - { isPredictContext: true }, ); }); @@ -178,12 +177,12 @@ describe('PredictPayWithRow', () => { expect(tree).not.toContain('ArrowDown'); }); - it('falls back to empty string when no symbols available', () => { + it('falls back to Predict balance when payToken is null', () => { mockPayToken = null; renderWithProvider(); - expect(screen.getByText('Pay with')).toBeOnTheScreen(); + expect(screen.getByText('Pay with Predict balance')).toBeOnTheScreen(); }); it('renders with no transactionMeta without crashing', () => { diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx index 6b39d7e065b..4a74eb564a8 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx @@ -41,21 +41,21 @@ export function PredictPayWithRow({ const { isPredictBalanceSelected, selectedPaymentToken } = usePredictPaymentToken(); + const showPredictBalance = isPredictBalanceSelected || !payToken; + const handlePress = useCallback(() => { if (!canEdit) return; - navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL, { - isPredictContext: true, - }); + navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL); }, [canEdit, navigation]); const label = strings('confirm.label.pay_with'); - const displaySymbol = isPredictBalanceSelected + const displaySymbol = showPredictBalance ? 'Predict balance' : (selectedPaymentToken?.symbol ?? payToken?.symbol ?? ''); - const tokenIconAddress = isPredictBalanceSelected + const tokenIconAddress = showPredictBalance ? POLYGON_USDCE.address : (payToken?.address as Hex | undefined); - const tokenIconChainId = isPredictBalanceSelected + const tokenIconChainId = showPredictBalance ? PREDICT_BALANCE_CHAIN_ID : (payToken?.chainId as Hex | undefined); @@ -65,7 +65,7 @@ export function PredictPayWithRow({ flexDirection={BoxFlexDirection.Row} alignItems={BoxAlignItems.Center} justifyContent={BoxJustifyContent.Center} - twClassName="rounded-full bg-default p-4" + twClassName={`rounded-full py-2 pl-[9px] pr-[16px] mt-2 ${disabled ? '' : 'bg-muted'}`} gap={3} > {tokenIconAddress && tokenIconChainId && ( diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.test.ts new file mode 100644 index 00000000000..8779e581774 --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.test.ts @@ -0,0 +1,474 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { StackActions } from '@react-navigation/native'; +import { usePredictBuyActions } from './usePredictBuyActions'; +import { PREDICT_ERROR_CODES } from '../../../constants/errors'; +import { + ActiveOrderState, + OrderPreview, + PlaceOrderParams, + Side, +} from '../../../types'; + +const mockDispatch = jest.fn(); +const mockOnConfirmActionsReject = jest.fn(); +const mockOnApprovalConfirm = jest.fn(); +const mockUnsubscribe = jest.fn(); +const mockShowOrderPlacedToast = jest.fn(); +const mockInvalidateOrderQueries = jest.fn(); +const mockTrackPredictOrderEvent = jest.fn(); +const mockPlaceOrder = jest.fn, [PlaceOrderParams]>(); +const mockOnPlaceOrderEnd = jest.fn(); +const mockOnOrderCancelled = jest.fn(); +const mockInitPayWithAnyToken = jest.fn(); +const mockSetIsConfirming = jest.fn(); +const mockTransitionEndUnsubscribe = jest.fn(); +const mockBeforeRemoveUnsubscribe = jest.fn(); +const mockTransitionEndCallbacks: ((e: { + data: { closing: boolean }; +}) => void)[] = []; +const mockBeforeRemoveCallbacks: (() => void)[] = []; + +let mockActiveOrder: { + batchId?: string | null; + state?: ActiveOrderState; +} | null = null; +let mockPayWithAnyTokenEnabled = true; +let mockApprovalRequest: { id: string } | undefined; + +const createAddListenerMock = + () => + (event: string, callback: (e?: { data: { closing: boolean } }) => void) => { + if (event === 'transitionEnd') { + mockTransitionEndCallbacks.push( + callback as (e: { data: { closing: boolean } }) => void, + ); + callback({ data: { closing: false } }); + return mockTransitionEndUnsubscribe; + } + + if (event === 'beforeRemove') { + mockBeforeRemoveCallbacks.push(callback as () => void); + return () => { + callback(); + mockBeforeRemoveUnsubscribe(); + }; + } + + return mockUnsubscribe; + }; + +const mockAddListener = jest.fn(createAddListenerMock()); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + dispatch: mockDispatch, + addListener: mockAddListener, + }), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(() => mockPayWithAnyTokenEnabled), +})); + +jest.mock( + '../../../../../Views/confirmations/hooks/useApprovalRequest', + () => ({ + __esModule: true, + default: () => ({ + onConfirm: mockOnApprovalConfirm, + approvalRequest: mockApprovalRequest, + }), + }), +); + +jest.mock('../../../../../Views/confirmations/hooks/useConfirmActions', () => ({ + useConfirmActions: () => ({ + onReject: mockOnConfirmActionsReject, + }), +})); + +jest.mock('../../../hooks/usePredictActiveOrder', () => ({ + usePredictActiveOrder: () => ({ + activeOrder: mockActiveOrder, + }), +})); + +jest.mock('../../../hooks/usePredictTrading', () => ({ + usePredictTrading: () => ({ + placeOrder: mockPlaceOrder, + initPayWithAnyToken: mockInitPayWithAnyToken, + }), +})); + +jest.mock('../../../../../../core/Engine', () => ({ + context: { + PredictController: { + onPlaceOrderEnd: (...args: unknown[]) => mockOnPlaceOrderEnd(...args), + onOrderCancelled: (...args: unknown[]) => mockOnOrderCancelled(...args), + trackPredictOrderEvent: (...args: unknown[]) => + mockTrackPredictOrderEvent(...args), + initPayWithAnyToken: (...args: unknown[]) => + mockInitPayWithAnyToken(...args), + }, + }, +})); + +const createDefaultParams = (): Parameters[0] => ({ + preview: { + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + timestamp: Date.now(), + side: Side.BUY, + sharePrice: 0.5, + maxAmountSpent: 175, + minAmountReceived: 180, + slippage: 0.005, + tickSize: 0.01, + minOrderSize: 0.01, + negRisk: false, + fees: { totalFee: 5 }, + } as OrderPreview, + analyticsProperties: { marketId: 'market-1' }, + setIsConfirming: mockSetIsConfirming, + showOrderPlacedToast: mockShowOrderPlacedToast, + invalidateOrderQueries: mockInvalidateOrderQueries, +}); + +describe('usePredictBuyActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockActiveOrder = null; + mockPayWithAnyTokenEnabled = true; + mockApprovalRequest = undefined; + mockInitPayWithAnyToken.mockResolvedValue(undefined); + mockTransitionEndCallbacks.length = 0; + mockBeforeRemoveCallbacks.length = 0; + mockAddListener.mockImplementation(createAddListenerMock()); + }); + + describe('mount effect', () => { + it('tracks an initiated order event on mount', () => { + renderHook(() => usePredictBuyActions(createDefaultParams())); + + expect(mockTrackPredictOrderEvent).toHaveBeenCalledWith({ + status: 'initiated', + analyticsProperties: { marketId: 'market-1' }, + sharePrice: undefined, + }); + }); + + it('calls initPayWithAnyToken on mount when pay with any token is enabled', () => { + renderHook(() => usePredictBuyActions(createDefaultParams())); + + expect(mockInitPayWithAnyToken).toHaveBeenCalledTimes(1); + }); + + it('does not call initPayWithAnyToken when pay with any token is disabled', () => { + mockPayWithAnyTokenEnabled = false; + + renderHook(() => usePredictBuyActions(createDefaultParams())); + + expect(mockInitPayWithAnyToken).not.toHaveBeenCalled(); + }); + + it('rejects approval request on unmount when pay with any token is enabled', () => { + const { unmount } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + unmount(); + + expect(mockTransitionEndUnsubscribe).toHaveBeenCalledTimes(1); + expect(mockBeforeRemoveUnsubscribe).toHaveBeenCalledTimes(1); + expect(mockOnConfirmActionsReject).toHaveBeenCalledTimes(1); + }); + + it('only calls initPayWithAnyToken once even if transitionEnd fires again', () => { + const transitionEndCallbacks: ((e: { + data: { closing: boolean }; + }) => void)[] = []; + + mockAddListener.mockImplementation( + ( + event: string, + callback: + | ((e: { data: { closing: boolean } }) => void) + | (() => void), + ) => { + if (event === 'transitionEnd') { + const typedCallback = callback as (e: { + data: { closing: boolean }; + }) => void; + + transitionEndCallbacks.push(typedCallback); + typedCallback({ data: { closing: false } }); + + return mockTransitionEndUnsubscribe; + } + + if (event === 'beforeRemove') { + return mockBeforeRemoveUnsubscribe; + } + + return mockUnsubscribe; + }, + ); + + renderHook(() => usePredictBuyActions(createDefaultParams())); + + act(() => { + transitionEndCallbacks[0]({ data: { closing: false } }); + }); + + expect(mockInitPayWithAnyToken).toHaveBeenCalledTimes(1); + }); + + it('does not initialize pay with any token when transitionEnd is closing', () => { + renderHook(() => usePredictBuyActions(createDefaultParams())); + + mockInitPayWithAnyToken.mockClear(); + + act(() => { + mockTransitionEndCallbacks[0]({ data: { closing: true } }); + }); + + expect(mockInitPayWithAnyToken).not.toHaveBeenCalled(); + }); + + it('does not register cleanup listeners when pay with any token is disabled', () => { + mockPayWithAnyTokenEnabled = false; + + const { unmount } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + unmount(); + + expect(mockOnConfirmActionsReject).not.toHaveBeenCalled(); + expect(mockOnPlaceOrderEnd).not.toHaveBeenCalled(); + }); + }); + + describe('handleConfirm', () => { + it('sets isConfirming to true', async () => { + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockSetIsConfirming).toHaveBeenCalledWith(true); + }); + + it('calls placeOrder with preview and analyticsProperties', async () => { + const params = createDefaultParams(); + const { result } = renderHook(() => usePredictBuyActions(params)); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockPlaceOrder).toHaveBeenCalledWith({ + analyticsProperties: { marketId: 'market-1' }, + preview: params.preview, + }); + }); + + it('calls approval confirm when the order is paying with any token', async () => { + mockActiveOrder = { state: ActiveOrderState.PAY_WITH_ANY_TOKEN }; + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockOnApprovalConfirm).toHaveBeenCalledWith({ + deleteAfterResult: true, + waitForResult: true, + handleErrors: false, + }); + }); + + it('does not call placeOrder when preview is null', async () => { + const params = createDefaultParams(); + params.preview = null; + const { result } = renderHook(() => usePredictBuyActions(params)); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockPlaceOrder).not.toHaveBeenCalled(); + }); + + it('returns a preview not available error when preview is null', async () => { + const params = createDefaultParams(); + params.preview = null; + const { result } = renderHook(() => usePredictBuyActions(params)); + + let outcome; + await act(async () => { + outcome = await result.current.handleConfirm(); + }); + + expect(outcome).toEqual({ + status: 'error', + error: PREDICT_ERROR_CODES.PREVIEW_NOT_AVAILABLE, + }); + }); + + it('passes transactionId from approvalRequest when state is PAY_WITH_ANY_TOKEN', async () => { + mockActiveOrder = { state: ActiveOrderState.PAY_WITH_ANY_TOKEN }; + mockApprovalRequest = { id: 'approval-tx-123' }; + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockPlaceOrder).toHaveBeenCalledWith( + expect.objectContaining({ transactionId: 'approval-tx-123' }), + ); + }); + + it('passes undefined transactionId when state is PREVIEW (balance flow)', async () => { + mockActiveOrder = { state: ActiveOrderState.PREVIEW }; + mockApprovalRequest = { id: 'approval-tx-456' }; + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockPlaceOrder).toHaveBeenCalledWith( + expect.objectContaining({ transactionId: undefined }), + ); + }); + + it('passes undefined transactionId when approvalRequest is undefined', async () => { + mockActiveOrder = { state: ActiveOrderState.PAY_WITH_ANY_TOKEN }; + mockApprovalRequest = undefined; + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + await act(async () => { + await result.current.handleConfirm(); + }); + + expect(mockPlaceOrder).toHaveBeenCalledWith( + expect.objectContaining({ transactionId: undefined }), + ); + }); + }); + + describe('placeOrder helper', () => { + it('returns a success result when placeOrder resolves', async () => { + const placeOrderResult = { success: true }; + mockPlaceOrder.mockResolvedValue(placeOrderResult); + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + let outcome; + await act(async () => { + outcome = await result.current.placeOrder({ + analyticsProperties: { marketId: 'market-1' }, + preview: createDefaultParams().preview as OrderPreview, + }); + }); + + expect(outcome).toEqual({ + status: 'success', + result: placeOrderResult, + }); + }); + + it('returns the error message when placeOrder rejects with an Error', async () => { + mockPlaceOrder.mockRejectedValue(new Error('Order failed')); + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + let outcome; + await act(async () => { + outcome = await result.current.placeOrder({ + analyticsProperties: { marketId: 'market-1' }, + preview: createDefaultParams().preview as OrderPreview, + }); + }); + + expect(outcome).toEqual({ + status: 'error', + error: 'Order failed', + }); + }); + + it('returns the default error when placeOrder rejects with a non-Error value', async () => { + mockPlaceOrder.mockRejectedValue('unexpected failure'); + const { result } = renderHook(() => + usePredictBuyActions(createDefaultParams()), + ); + + let outcome; + await act(async () => { + outcome = await result.current.placeOrder({ + analyticsProperties: { marketId: 'market-1' }, + preview: createDefaultParams().preview as OrderPreview, + }); + }); + + expect(outcome).toEqual({ + status: 'error', + error: PREDICT_ERROR_CODES.PLACE_ORDER_FAILED, + }); + }); + }); + + describe('confirming state effect', () => { + it.each([ActiveOrderState.DEPOSITING, ActiveOrderState.PLACING_ORDER])( + 'sets isConfirming to true in %s state', + (state) => { + mockActiveOrder = { state }; + + renderHook(() => usePredictBuyActions(createDefaultParams())); + + expect(mockSetIsConfirming).toHaveBeenCalledWith(true); + }, + ); + + it.each([ActiveOrderState.PREVIEW, ActiveOrderState.PAY_WITH_ANY_TOKEN])( + 'sets isConfirming to false in %s state', + (state) => { + mockActiveOrder = { state }; + + renderHook(() => usePredictBuyActions(createDefaultParams())); + + expect(mockSetIsConfirming).toHaveBeenCalledWith(false); + }, + ); + }); + + describe('success effect', () => { + it('shows toast, cleans up and closes the screen in SUCCESS state', async () => { + mockActiveOrder = { state: ActiveOrderState.SUCCESS }; + + renderHook(() => usePredictBuyActions(createDefaultParams())); + + expect(mockInvalidateOrderQueries).toHaveBeenCalledTimes(1); + expect(mockShowOrderPlacedToast).toHaveBeenCalledTimes(1); + expect(mockOnPlaceOrderEnd).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop()); + }); + }); +}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.ts new file mode 100644 index 00000000000..6bc641596ac --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyActions.ts @@ -0,0 +1,183 @@ +import { StackActions, useNavigation } from '@react-navigation/native'; +import type { StackNavigationProp } from '@react-navigation/stack'; +import type { PredictNavigationParamList } from '../../../types/navigation'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { + ActiveOrderState, + OrderPreview, + PlaceOrderParams, +} from '../../../types'; +import useApprovalRequest from '../../../../../Views/confirmations/hooks/useApprovalRequest'; +import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; +import Engine from '../../../../../../core/Engine'; +import { useSelector } from 'react-redux'; +import { selectPredictWithAnyTokenEnabledFlag } from '../../../selectors/featureFlags'; +import { PredictTradeStatus } from '../../../constants/eventNames'; +import { usePredictTrading } from '../../../hooks/usePredictTrading'; +import { PlaceOrderOutcome } from '../../../hooks/usePredictPlaceOrder'; +import { PREDICT_ERROR_CODES } from '../../../constants/errors'; +import { useConfirmActions } from '../../../../../Views/confirmations/hooks/useConfirmActions'; + +interface UsePredictBuyActionsParams { + preview?: OrderPreview | null; + analyticsProperties: PlaceOrderParams['analyticsProperties']; + setIsConfirming: (value: boolean) => void; + showOrderPlacedToast: () => void; + invalidateOrderQueries: () => void; +} + +export const usePredictBuyActions = ({ + preview, + analyticsProperties, + setIsConfirming, + showOrderPlacedToast, + invalidateOrderQueries, +}: UsePredictBuyActionsParams) => { + const navigation = + useNavigation>(); + const { onConfirm: onApprovalConfirm, approvalRequest } = + useApprovalRequest(); + const { onReject } = useConfirmActions(); + const { activeOrder } = usePredictActiveOrder(); + const { placeOrder, initPayWithAnyToken } = usePredictTrading(); + const currentState = useMemo(() => activeOrder?.state, [activeOrder?.state]); + const { PredictController } = Engine.context; + const payWithAnyTokenEnabled = useSelector( + selectPredictWithAnyTokenEnabledFlag, + ); + + const hasInitializedPayWithAnyTokenRef = useRef(false); + + useEffect(() => { + const controller = Engine.context.PredictController; + + controller.trackPredictOrderEvent({ + status: PredictTradeStatus.INITIATED, + analyticsProperties, + sharePrice: analyticsProperties?.sharePrice, + }); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!payWithAnyTokenEnabled) { + return; + } + + const unsubscribe = navigation.addListener('transitionEnd', (e) => { + if (!e.data.closing && !hasInitializedPayWithAnyTokenRef.current) { + hasInitializedPayWithAnyTokenRef.current = true; + initPayWithAnyToken(); + } + }); + + return unsubscribe; + }, [navigation, initPayWithAnyToken, payWithAnyTokenEnabled]); + + useEffect(() => { + if (!payWithAnyTokenEnabled) { + return; + } + + return navigation.addListener('beforeRemove', () => { + onReject(undefined, true); + PredictController.onPlaceOrderEnd(); + }); + }, [navigation, payWithAnyTokenEnabled, PredictController, onReject]); + + const handlePlaceOrder = useCallback( + async (orderParams: PlaceOrderParams): Promise => { + try { + const result = await placeOrder(orderParams); + return { status: 'success', result }; + } catch (error) { + return { + status: 'error', + error: + error instanceof Error + ? error.message + : PREDICT_ERROR_CODES.PLACE_ORDER_FAILED, + }; + } + }, + [placeOrder], + ); + + const handleConfirm = useCallback(async () => { + setIsConfirming(true); + + // Only capture transactionId for PAY_WITH_ANY_TOKEN flow (deposit-order linking). + // Balance flow doesn't need it — passing undefined lets isCurrentActiveBuyOrder + // match without a strict transactionId check. + const transactionId = + currentState === ActiveOrderState.PAY_WITH_ANY_TOKEN + ? approvalRequest?.id + : undefined; + + if (currentState === ActiveOrderState.PAY_WITH_ANY_TOKEN) { + onApprovalConfirm({ + deleteAfterResult: true, + waitForResult: true, + handleErrors: false, + }); + } + if (!preview) { + return { + status: 'error', + error: PREDICT_ERROR_CODES.PREVIEW_NOT_AVAILABLE, + }; + } + + return handlePlaceOrder({ + analyticsProperties, + preview, + transactionId, + }); + }, [ + setIsConfirming, + approvalRequest, + currentState, + handlePlaceOrder, + analyticsProperties, + preview, + onApprovalConfirm, + ]); + + useEffect(() => { + if ( + currentState === ActiveOrderState.DEPOSITING || + currentState === ActiveOrderState.PLACING_ORDER + ) { + setIsConfirming(true); + } + + if ( + currentState === ActiveOrderState.PREVIEW || + currentState === ActiveOrderState.PAY_WITH_ANY_TOKEN + ) { + setIsConfirming(false); + } + }, [currentState, setIsConfirming]); + + useEffect(() => { + if (currentState === ActiveOrderState.SUCCESS) { + invalidateOrderQueries(); + showOrderPlacedToast(); + PredictController.onPlaceOrderEnd(); + navigation.dispatch(StackActions.pop()); + } + }, [ + PredictController, + currentState, + invalidateOrderQueries, + navigation, + setIsConfirming, + showOrderPlacedToast, + ]); + + return { + handleConfirm, + placeOrder: handlePlaceOrder, + }; +}; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.test.ts index b56d859f2ca..209f67c04cb 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.test.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.test.ts @@ -1,5 +1,4 @@ import { renderHook } from '@testing-library/react-native'; -import { formatPrice } from '../../../utils/format'; import { usePredictBuyAvailableBalance } from './usePredictBuyAvailableBalance'; let mockIsPredictBalanceSelected = true; @@ -29,10 +28,6 @@ jest.mock( }), ); -jest.mock('../../../utils/format', () => ({ - formatPrice: jest.fn((value: number) => `$${value.toFixed(2)}`), -})); - describe('usePredictBuyAvailableBalance', () => { beforeEach(() => { jest.clearAllMocks(); @@ -43,105 +38,60 @@ describe('usePredictBuyAvailableBalance', () => { }); describe('availableBalance', () => { - it('returns formatted Predict balance when isPredictBalanceSelected is true', () => { - // Arrange + it('returns Predict balance when isPredictBalanceSelected is true', () => { mockIsPredictBalanceSelected = true; mockBalance = 250.5; - // Act const { result } = renderHook(() => usePredictBuyAvailableBalance()); - // Assert - expect(result.current.availableBalance).toBe('$250.50'); + expect(result.current.availableBalance).toBe(250.5); }); - it('returns formatted payToken balanceUsd when isPredictBalanceSelected is false', () => { - // Arrange + it('returns predict balance plus payToken balanceUsd when isPredictBalanceSelected is false', () => { mockIsPredictBalanceSelected = false; + mockBalance = 100; mockPayToken = { balanceUsd: 150.75 }; - // Act const { result } = renderHook(() => usePredictBuyAvailableBalance()); - // Assert - expect(result.current.availableBalance).toBe('$150.75'); + expect(result.current.availableBalance).toBe(250.75); }); - it('returns "$0.00" when payToken has no balanceUsd and isPredictBalanceSelected is false', () => { - // Arrange + it('returns predict balance when payToken has no balanceUsd and isPredictBalanceSelected is false', () => { mockIsPredictBalanceSelected = false; + mockBalance = 100; mockPayToken = {}; - // Act const { result } = renderHook(() => usePredictBuyAvailableBalance()); - // Assert - expect(result.current.availableBalance).toBe('$0.00'); + expect(result.current.availableBalance).toBe(100); }); - it('returns "$0.00" when payToken is null and isPredictBalanceSelected is false', () => { - // Arrange + it('falls back to Predict balance when payToken is null', () => { mockIsPredictBalanceSelected = false; mockPayToken = null; - // Act const { result } = renderHook(() => usePredictBuyAvailableBalance()); - // Assert - expect(result.current.availableBalance).toBe('$0.00'); + expect(result.current.availableBalance).toBe(100); }); }); describe('isBalanceLoading', () => { it('returns isBalanceLoading from usePredictBalance', () => { - // Arrange mockIsBalanceLoading = true; - // Act const { result } = renderHook(() => usePredictBuyAvailableBalance()); - // Assert expect(result.current.isBalanceLoading).toBe(true); }); it('returns false when balance is not loading', () => { - // Arrange mockIsBalanceLoading = false; - // Act const { result } = renderHook(() => usePredictBuyAvailableBalance()); - // Assert expect(result.current.isBalanceLoading).toBe(false); }); }); - - describe('formatPrice', () => { - it('calls formatPrice with correct options when using Predict balance', () => { - // Arrange - mockIsPredictBalanceSelected = true; - mockBalance = 500; - - // Act - renderHook(() => usePredictBuyAvailableBalance()); - - // Assert - expect(formatPrice).toHaveBeenCalledWith(500, { - minimumDecimals: 2, - maximumDecimals: 2, - }); - }); - - it('does not call formatPrice when using payToken balance', () => { - // Arrange - mockIsPredictBalanceSelected = false; - mockPayToken = { balanceUsd: 100 }; - - // Act - renderHook(() => usePredictBuyAvailableBalance()); - - // Assert - expect(formatPrice).not.toHaveBeenCalled(); - }); - }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.ts index 1575573f080..38727367274 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.ts @@ -1,8 +1,7 @@ import { useMemo } from 'react'; -import { formatPrice } from '../../../utils/format'; +import { useTransactionPayToken } from '../../../../../Views/confirmations/hooks/pay/useTransactionPayToken'; import { usePredictBalance } from '../../../hooks/usePredictBalance'; import { usePredictPaymentToken } from '../../../hooks/usePredictPaymentToken'; -import { useTransactionPayToken } from '../../../../../Views/confirmations/hooks/pay/useTransactionPayToken'; export const usePredictBuyAvailableBalance = () => { const { isPredictBalanceSelected } = usePredictPaymentToken(); @@ -12,13 +11,10 @@ export const usePredictBuyAvailableBalance = () => { const availableBalance = useMemo( () => - isPredictBalanceSelected - ? formatPrice(balance, { - minimumDecimals: 2, - maximumDecimals: 2, - }) - : `$${Number(payToken?.balanceUsd ?? 0).toFixed(2)}`, - [isPredictBalanceSelected, balance, payToken?.balanceUsd], + isPredictBalanceSelected || !payToken + ? balance + : balance + Number(payToken?.balanceUsd ?? 0), + [isPredictBalanceSelected, payToken, balance], ); return { diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.test.ts deleted file mode 100644 index 68992207556..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { renderHook } from '@testing-library/react-native'; -import { BackHandler } from 'react-native'; -import Device from '../../../../../../util/device'; -import usePredictBuyBackSwipe from './usePredictBuyBackSwipe'; - -const mockAddListener = jest.fn(); -const mockUnsubscribe = jest.fn(); -const mockRemove = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - addListener: mockAddListener, - }), -})); - -jest.mock('../../../../../../util/device', () => ({ - __esModule: true, - default: { isAndroid: jest.fn() }, -})); - -jest.mock('react-native', () => { - const actual = jest.requireActual('react-native'); - return { - ...actual, - BackHandler: { - addEventListener: jest.fn(() => ({ remove: mockRemove })), - }, - }; -}); - -describe('usePredictBuyBackSwipe', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockAddListener.mockReturnValue(mockUnsubscribe); - }); - - describe('gestureEnd listener', () => { - it('registers gestureEnd listener on navigation', () => { - const onBack = jest.fn(); - - renderHook(() => usePredictBuyBackSwipe({ onBack })); - - expect(mockAddListener).toHaveBeenCalledWith( - 'gestureEnd', - expect.any(Function), - ); - }); - - it('calls onBack when gestureEnd fires', () => { - const onBack = jest.fn(); - mockAddListener.mockImplementation( - (event: string, callback: () => void) => { - if (event === 'gestureEnd') { - callback(); - } - return mockUnsubscribe; - }, - ); - - renderHook(() => usePredictBuyBackSwipe({ onBack })); - - expect(onBack).toHaveBeenCalled(); - }); - - it('unsubscribes gestureEnd listener on unmount', () => { - const onBack = jest.fn(); - - const { unmount } = renderHook(() => usePredictBuyBackSwipe({ onBack })); - - unmount(); - - expect(mockUnsubscribe).toHaveBeenCalled(); - }); - }); - - describe('BackHandler on Android', () => { - beforeEach(() => { - (Device.isAndroid as jest.Mock).mockReturnValue(true); - }); - - it('registers BackHandler on Android', () => { - const onBack = jest.fn(); - const addEventListenerSpy = jest.spyOn(BackHandler, 'addEventListener'); - - renderHook(() => usePredictBuyBackSwipe({ onBack })); - - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'hardwareBackPress', - expect.any(Function), - ); - }); - - it('calls onBack when hardware back pressed on Android', () => { - const onBack = jest.fn(); - let capturedCallback: (() => boolean | null | undefined) | null = null; - - jest - .spyOn(BackHandler, 'addEventListener') - .mockImplementation((_eventName, handler) => { - capturedCallback = handler; - return { remove: mockRemove }; - }); - - renderHook(() => usePredictBuyBackSwipe({ onBack })); - - if (capturedCallback) { - (capturedCallback as () => boolean)(); - } - - expect(onBack).toHaveBeenCalled(); - }); - - it('returns true from hardware back handler to prevent default', () => { - const onBack = jest.fn(); - let capturedCallback: (() => boolean | null | undefined) | null = null; - - jest - .spyOn(BackHandler, 'addEventListener') - .mockImplementation((_eventName, handler) => { - capturedCallback = handler; - return { remove: mockRemove }; - }); - - renderHook(() => usePredictBuyBackSwipe({ onBack })); - - let result = false; - if (capturedCallback) { - result = (capturedCallback as () => boolean)(); - } - - expect(result).toBe(true); - }); - - it('removes BackHandler subscription on unmount', () => { - const onBack = jest.fn(); - - jest - .spyOn(BackHandler, 'addEventListener') - .mockImplementation(() => ({ remove: mockRemove })); - - const { unmount } = renderHook(() => usePredictBuyBackSwipe({ onBack })); - - unmount(); - - expect(mockRemove).toHaveBeenCalled(); - }); - }); - - describe('BackHandler on iOS', () => { - beforeEach(() => { - (Device.isAndroid as jest.Mock).mockReturnValue(false); - }); - - it('does not register BackHandler on iOS', () => { - const onBack = jest.fn(); - const addEventListenerSpy = jest.spyOn(BackHandler, 'addEventListener'); - - renderHook(() => usePredictBuyBackSwipe({ onBack })); - - expect(addEventListenerSpy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.ts deleted file mode 100644 index 5005ec57367..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ParamListBase, useNavigation } from '@react-navigation/native'; -import { StackNavigationProp } from '@react-navigation/stack'; -import { useEffect } from 'react'; -import { BackHandler } from 'react-native'; -import Device from '../../../../../../util/device'; - -const usePredictBuyBackSwipe = ({ onBack }: { onBack: () => void }) => { - const navigation = useNavigation>(); - - useEffect(() => { - const unsubscribe = navigation.addListener('gestureEnd', () => { - onBack(); - }); - - return unsubscribe; - }, [navigation, onBack]); - - useEffect(() => { - if (Device.isAndroid()) { - const backHandlerSubscription = BackHandler.addEventListener( - 'hardwareBackPress', - () => { - onBack(); - return true; - }, - ); - - return () => { - backHandlerSubscription.remove(); - }; - } - }, [onBack]); -}; - -export default usePredictBuyBackSwipe; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.test.ts index cfa260031d2..82b4d3bdb0b 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.test.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.test.ts @@ -1,8 +1,10 @@ import { renderHook } from '@testing-library/react-native'; import { usePredictBuyConditions } from './usePredictBuyConditions'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; import { ActiveOrderState, OrderPreview } from '../../../types'; let mockIsBalanceLoading = false; +let mockAvailableBalance = 100; let mockActiveOrder: { state?: string } | null = null; let mockPayTotals: Record | null = null; let mockIsPayTotalsLoading = false; @@ -19,10 +21,14 @@ let mockSelectedPaymentToken: { chainId?: string; } | null = null; let mockIsDepositPending = false; +let mockInsufficientPayTokenBalanceAlert: { message: string } | null = null; +let mockPredictBalance = 0; +const mockResetSelectedPaymentToken = jest.fn(); jest.mock('./usePredictBuyAvailableBalance', () => ({ usePredictBuyAvailableBalance: () => ({ isBalanceLoading: mockIsBalanceLoading, + availableBalance: mockAvailableBalance, }), })); @@ -36,6 +42,13 @@ jest.mock('../../../hooks/usePredictPaymentToken', () => ({ usePredictPaymentToken: () => ({ isPredictBalanceSelected: mockIsPredictBalanceSelected, selectedPaymentToken: mockSelectedPaymentToken, + resetSelectedPaymentToken: mockResetSelectedPaymentToken, + }), +})); + +jest.mock('../../../hooks/usePredictBalance', () => ({ + usePredictBalance: () => ({ + data: mockPredictBalance, }), })); @@ -46,6 +59,15 @@ jest.mock('../../../hooks/usePredictDeposit', () => ({ }), })); +jest.mock( + '../../../../../Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert', + () => ({ + useInsufficientPayTokenBalanceAlert: () => [ + mockInsufficientPayTokenBalanceAlert, + ], + }), +); + jest.mock( '../../../../../Views/confirmations/hooks/pay/useTransactionPayData', () => ({ @@ -57,19 +79,32 @@ jest.mock( }), ); +jest.mock('@metamask/assets-controllers', () => ({ + getNativeTokenAddress: jest.fn( + () => '0x0000000000000000000000000000000000001010', + ), +})); + const defaultParams = { currentValue: 10, - preview: { rateLimited: false } as OrderPreview | null, + depositFee: 0, + preview: { + rateLimited: false, + maxAmountSpent: 10, + fees: { totalFee: 0.5 }, + } as OrderPreview | null, isPreviewCalculating: false, - isPlaceOrderLoading: false, isUserInputChange: false, isConfirming: false, + totalPayForPredictBalance: 0, + isInputFocused: false, }; describe('usePredictBuyConditions', () => { beforeEach(() => { jest.clearAllMocks(); mockIsBalanceLoading = false; + mockAvailableBalance = 100; mockActiveOrder = null; mockPayTotals = null; mockIsPayTotalsLoading = false; @@ -79,6 +114,12 @@ describe('usePredictBuyConditions', () => { mockIsPredictBalanceSelected = true; mockSelectedPaymentToken = null; mockIsDepositPending = false; + mockInsufficientPayTokenBalanceAlert = null; + mockPredictBalance = 0; + }); + + afterEach(() => { + jest.mocked(getNativeTokenAddress).mockClear(); }); describe('isBelowMinimum', () => { @@ -115,76 +156,138 @@ describe('usePredictBuyConditions', () => { }); }); - describe('isRateLimited', () => { - it('returns true when preview.rateLimited is true', () => { + describe('isInsufficientBalance', () => { + it('returns true when currentValue exceeds maxBetAmount', () => { + mockAvailableBalance = 5; + const { result } = renderHook(() => usePredictBuyConditions({ ...defaultParams, - preview: { rateLimited: true } as OrderPreview, + currentValue: 10, }), ); - expect(result.current.isRateLimited).toBe(true); + expect(result.current.isInsufficientBalance).toBe(true); }); - it('returns false when preview is null', () => { + it('returns false when currentValue is within maxBetAmount', () => { + mockAvailableBalance = 100; + const { result } = renderHook(() => - usePredictBuyConditions({ ...defaultParams, preview: null }), + usePredictBuyConditions({ + ...defaultParams, + currentValue: 10, + }), ); - expect(result.current.isRateLimited).toBe(false); + expect(result.current.isInsufficientBalance).toBe(false); }); - it('returns false when preview.rateLimited is false', () => { + it('returns false when currentValue equals maxBetAmount', () => { + mockAvailableBalance = 10.5; + const { result } = renderHook(() => usePredictBuyConditions({ ...defaultParams, - preview: { rateLimited: false } as OrderPreview, + currentValue: 10, + preview: { + rateLimited: false, + maxAmountSpent: 10, + fees: { totalFee: 0.5, totalFeePercentage: 5 }, + } as OrderPreview, }), ); - expect(result.current.isRateLimited).toBe(false); + expect(result.current.isInsufficientBalance).toBe(false); + }); + + it('returns false when currentValue is 0', () => { + mockAvailableBalance = 0; + + const { result } = renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + currentValue: 0, + }), + ); + + expect(result.current.isInsufficientBalance).toBe(false); }); }); - describe('isPlacingOrder', () => { - it('returns true when activeOrder state is PLACING_ORDER', () => { - mockActiveOrder = { state: ActiveOrderState.PLACING_ORDER }; + describe('maxBetAmount', () => { + it('returns balance divided by (1 + feeRate) when fees apply', () => { + mockAvailableBalance = 104; const { result } = renderHook(() => - usePredictBuyConditions(defaultParams), + usePredictBuyConditions({ + ...defaultParams, + preview: { + rateLimited: false, + fees: { totalFeePercentage: 4 }, + } as OrderPreview, + }), ); - expect(result.current.isPlacingOrder).toBe(true); + expect(result.current.maxBetAmount).toBe(100); }); - it('returns true when isPlaceOrderLoading is true', () => { + it('returns full available balance when fee rate is 0', () => { + mockAvailableBalance = 50; + const { result } = renderHook(() => usePredictBuyConditions({ ...defaultParams, - isPlaceOrderLoading: true, + preview: { + rateLimited: false, + fees: { totalFeePercentage: 0 }, + } as OrderPreview, }), ); - expect(result.current.isPlacingOrder).toBe(true); + expect(result.current.maxBetAmount).toBe(50); }); - it('returns true when activeOrder state is DEPOSITING', () => { - mockActiveOrder = { state: ActiveOrderState.DEPOSITING }; + it('returns full available balance when preview has no fees', () => { + mockAvailableBalance = 50; const { result } = renderHook(() => usePredictBuyConditions(defaultParams), ); - expect(result.current.isPlacingOrder).toBe(true); + expect(result.current.maxBetAmount).toBe(50); }); + }); - it('returns false when none of the placing conditions are met', () => { + describe('isRateLimited', () => { + it('returns true when preview.rateLimited is true', () => { const { result } = renderHook(() => - usePredictBuyConditions(defaultParams), + usePredictBuyConditions({ + ...defaultParams, + preview: { rateLimited: true } as OrderPreview, + }), ); - expect(result.current.isPlacingOrder).toBe(false); + expect(result.current.isRateLimited).toBe(true); + }); + + it('returns false when preview is null', () => { + const { result } = renderHook(() => + usePredictBuyConditions({ ...defaultParams, preview: null }), + ); + + expect(result.current.isRateLimited).toBe(false); + }); + + it('returns false when preview.rateLimited is false', () => { + const { result } = renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + preview: { rateLimited: false } as OrderPreview, + }), + ); + + expect(result.current.isRateLimited).toBe(false); }); }); @@ -213,17 +316,6 @@ describe('usePredictBuyConditions', () => { expect(result.current.canPlaceBet).toBe(false); }); - it('returns false when isPlaceOrderLoading is true', () => { - const { result } = renderHook(() => - usePredictBuyConditions({ - ...defaultParams, - isPlaceOrderLoading: true, - }), - ); - - expect(result.current.canPlaceBet).toBe(false); - }); - it('returns false when isBalanceLoading is true', () => { mockIsBalanceLoading = true; @@ -242,6 +334,16 @@ describe('usePredictBuyConditions', () => { expect(result.current.canPlaceBet).toBe(false); }); + it('returns false when isInsufficientBalance', () => { + mockAvailableBalance = 5; + + const { result } = renderHook(() => + usePredictBuyConditions({ ...defaultParams, currentValue: 10.5 }), + ); + + expect(result.current.canPlaceBet).toBe(false); + }); + it('returns false when isRateLimited', () => { const { result } = renderHook(() => usePredictBuyConditions({ @@ -264,6 +366,19 @@ describe('usePredictBuyConditions', () => { expect(result.current.canPlaceBet).toBe(false); }); + + it('returns false when external payment token balance is insufficient', () => { + mockIsPredictBalanceSelected = false; + mockInsufficientPayTokenBalanceAlert = { + message: 'Insufficient payment token balance', + }; + + const { result } = renderHook(() => + usePredictBuyConditions(defaultParams), + ); + + expect(result.current.canPlaceBet).toBe(false); + }); }); describe('isPayFeesLoading', () => { @@ -301,14 +416,25 @@ describe('usePredictBuyConditions', () => { expect(result.current.isPayFeesLoading).toBe(true); }); - it('returns true when activeOrder state is REDIRECTING', () => { - mockActiveOrder = { state: ActiveOrderState.REDIRECTING }; + it('returns false when activeOrder state does not affect pay fees loading', () => { + mockActiveOrder = { state: ActiveOrderState.DEPOSITING }; const { result } = renderHook(() => usePredictBuyConditions(defaultParams), ); - expect(result.current.isPayFeesLoading).toBe(true); + expect(result.current.isPayFeesLoading).toBe(false); + }); + + it('returns false when source amount has not been set yet', () => { + mockIsPredictBalanceSelected = false; + mockSelectedPaymentToken = { address: '0xabc', chainId: '0x1' }; + + const { result } = renderHook(() => + usePredictBuyConditions(defaultParams), + ); + + expect(result.current.isPayFeesLoading).toBe(false); }); }); @@ -364,7 +490,45 @@ describe('usePredictBuyConditions', () => { expect(result.current.isPayFeesLoading).toBe(true); }); - it('returns false when requiredTokens include selected token', () => { + it('returns false when Polygon native token quote uses zero address', () => { + mockIsPredictBalanceSelected = false; + mockSelectedPaymentToken = { + address: '0x0000000000000000000000000000000000001010', + chainId: '0x89', + }; + mockQuotes = [ + { + request: { + sourceTokenAddress: '0x0000000000000000000000000000000000000000', + sourceChainId: '0x89', + }, + }, + ]; + mockPayTotals = { total: '100' }; + + const { result } = renderHook(() => + usePredictBuyConditions(defaultParams), + ); + + expect(result.current.isPayFeesLoading).toBe(false); + expect(getNativeTokenAddress).toHaveBeenCalledWith('0x89'); + }); + + it('returns false when requiredTokens include selected token and quotes are unavailable', () => { + mockIsPredictBalanceSelected = false; + mockSelectedPaymentToken = { address: '0xabc', chainId: '0x1' }; + mockQuotes = null; + mockPayTotals = { total: '100' }; + mockRequiredTokens = [{ address: '0xABC', chainId: '0x1' }]; + + const { result } = renderHook(() => + usePredictBuyConditions(defaultParams), + ); + + expect(result.current.isPayFeesLoading).toBe(false); + }); + + it('returns false when requiredTokens include selected token but quotes are empty', () => { mockIsPredictBalanceSelected = false; mockSelectedPaymentToken = { address: '0xabc', chainId: '0x1' }; mockQuotes = []; @@ -462,4 +626,120 @@ describe('usePredictBuyConditions', () => { expect(result.current.isUserChangeTriggeringCalculation).toBe(false); }); }); + + describe('canSelectToken', () => { + it('returns true when the total exceeds predict balance', () => { + mockPredictBalance = 10; + + const { result } = renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 20, + }), + ); + + expect(result.current.canSelectToken).toBe(true); + }); + + it('returns false when predict balance covers the total', () => { + mockPredictBalance = 20; + + const { result } = renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 20, + }), + ); + + expect(result.current.canSelectToken).toBe(false); + }); + + it('returns true when predict balance is below the minimum bet', () => { + mockPredictBalance = 0.5; + + const { result } = renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 0, + }), + ); + + expect(result.current.canSelectToken).toBe(true); + }); + + it('returns false when predict balance equals the minimum bet and covers the total', () => { + mockPredictBalance = 1; + + const { result } = renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 1, + }), + ); + + expect(result.current.canSelectToken).toBe(false); + }); + }); + + describe('selected payment token reset effect', () => { + it('resets the selected token when predict balance covers the total and input is not focused', () => { + mockPredictBalance = 20; + mockIsPredictBalanceSelected = false; + + renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 20, + isInputFocused: false, + }), + ); + + expect(mockResetSelectedPaymentToken).toHaveBeenCalledTimes(1); + }); + + it('does not reset the selected token while the input is focused', () => { + mockPredictBalance = 20; + mockIsPredictBalanceSelected = false; + + renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 20, + isInputFocused: true, + }), + ); + + expect(mockResetSelectedPaymentToken).not.toHaveBeenCalled(); + }); + + it('does not reset the selected token when predict balance is already selected', () => { + mockPredictBalance = 20; + mockIsPredictBalanceSelected = true; + + renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 20, + isInputFocused: false, + }), + ); + + expect(mockResetSelectedPaymentToken).not.toHaveBeenCalled(); + }); + + it('does not reset the selected token when predict balance does not cover the total', () => { + mockPredictBalance = 10; + mockIsPredictBalanceSelected = false; + + renderHook(() => + usePredictBuyConditions({ + ...defaultParams, + totalPayForPredictBalance: 20, + isInputFocused: false, + }), + ); + + expect(mockResetSelectedPaymentToken).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.ts index 1e155118b6b..ea8b2c57ff3 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.ts @@ -1,8 +1,4 @@ -import { useMemo } from 'react'; -import { MINIMUM_BET } from '../../../constants/transactions'; -import { ActiveOrderState, OrderPreview } from '../../../types'; -import { usePredictBuyAvailableBalance } from './usePredictBuyAvailableBalance'; -import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; +import { useEffect, useMemo } from 'react'; import { useIsTransactionPayLoading, useIsTransactionPayQuoteLoading, @@ -10,36 +6,68 @@ import { useTransactionPayRequiredTokens, useTransactionPayTotals, } from '../../../../../Views/confirmations/hooks/pay/useTransactionPayData'; -import { usePredictPaymentToken } from '../../../hooks/usePredictPaymentToken'; +import { MINIMUM_BET } from '../../../constants/transactions'; import { usePredictDeposit } from '../../../hooks/usePredictDeposit'; +import { usePredictPaymentToken } from '../../../hooks/usePredictPaymentToken'; +import { OrderPreview } from '../../../types'; +import { usePredictBuyAvailableBalance } from './usePredictBuyAvailableBalance'; +import { useInsufficientPayTokenBalanceAlert } from '../../../../../Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; +import { Hex } from '@metamask/utils'; +import { EMPTY_ADDRESS } from '../../../../../../constants/transaction'; +import { usePredictBalance } from '../../../hooks/usePredictBalance'; interface UsePredictBuyConditionsParams { currentValue: number; preview?: OrderPreview | null; isPreviewCalculating: boolean; - isPlaceOrderLoading: boolean; isUserInputChange: boolean; isConfirming: boolean; + totalPayForPredictBalance: number; + isInputFocused: boolean; } +const normalizeQuoteComparableAddress = ( + address?: string, + chainId?: string, +) => { + if (!address || !chainId) { + return address?.toLowerCase(); + } + + const nativeTokenAddress = getNativeTokenAddress(chainId as Hex); + + return address.toLowerCase() === nativeTokenAddress.toLowerCase() + ? EMPTY_ADDRESS + : address.toLowerCase(); +}; + export const usePredictBuyConditions = ({ preview, currentValue, isPreviewCalculating, - isPlaceOrderLoading, isUserInputChange, isConfirming, + totalPayForPredictBalance, + isInputFocused, }: UsePredictBuyConditionsParams) => { - const { isBalanceLoading } = usePredictBuyAvailableBalance(); - const { activeOrder } = usePredictActiveOrder(); - const payTotals = useTransactionPayTotals(); + const { isBalanceLoading, availableBalance } = + usePredictBuyAvailableBalance(); const isPayTotalsLoading = useIsTransactionPayLoading(); const isPayQuoteLoading = useIsTransactionPayQuoteLoading(); + const { isDepositPending } = usePredictDeposit(); + const payTotals = useTransactionPayTotals(); const quotes = useTransactionPayQuotes(); const requiredTokens = useTransactionPayRequiredTokens(); - const { isPredictBalanceSelected, selectedPaymentToken } = - usePredictPaymentToken(); - const { isDepositPending } = usePredictDeposit(); + const { + isPredictBalanceSelected, + selectedPaymentToken, + resetSelectedPaymentToken, + } = usePredictPaymentToken(); + const { data: predictBalance = 0 } = usePredictBalance(); + + const [insufficientPayTokenBalanceAlert] = + useInsufficientPayTokenBalanceAlert(); const shouldWaitForPayFees = !isPredictBalanceSelected; @@ -53,26 +81,46 @@ export const usePredictBuyConditions = ({ [currentValue], ); - const isRateLimited = useMemo(() => preview?.rateLimited ?? false, [preview]); - - const isDepositing = useMemo( - () => activeOrder?.state === ActiveOrderState.DEPOSITING, - [activeOrder], - ); + const maxBetAmount = useMemo(() => { + const feeRate = (preview?.fees?.totalFeePercentage ?? 0) / 100; + return Math.max( + 0, + Math.floor((availableBalance / (1 + feeRate)) * 100) / 100, + ); + }, [availableBalance, preview?.fees?.totalFeePercentage]); - const isPlacingOrder = useMemo( + const isInsufficientBalance = useMemo( () => - activeOrder?.state === ActiveOrderState.PLACING_ORDER || - isPlaceOrderLoading || - isDepositing, - [activeOrder?.state, isPlaceOrderLoading, isDepositing], + isPredictBalanceSelected && + !isConfirming && + currentValue > 0 && + currentValue > maxBetAmount, + [isConfirming, isPredictBalanceSelected, currentValue, maxBetAmount], ); - const isRedirecting = useMemo( - () => activeOrder?.state === ActiveOrderState.REDIRECTING, - [activeOrder], + const isInsufficientPayTokenBalance = useMemo( + () => !isPredictBalanceSelected && !!insufficientPayTokenBalanceAlert, + [isPredictBalanceSelected, insufficientPayTokenBalanceAlert], ); + const isRateLimited = useMemo(() => preview?.rateLimited ?? false, [preview]); + + const isPaymentTokenRequired = useMemo(() => { + if (!selectedPaymentToken || !requiredTokens?.length) { + return false; + } + return requiredTokens.some( + (token) => + normalizeQuoteComparableAddress(token.address, token.chainId) === + normalizeQuoteComparableAddress( + selectedPaymentToken.address, + selectedPaymentToken.chainId, + ) && + token.chainId.toLowerCase() === + selectedPaymentToken.chainId?.toLowerCase(), + ); + }, [selectedPaymentToken, requiredTokens]); + // Workaround: TransactionPayController sets paymentToken and isLoading in // separate state updates, causing a render with stale totals + loading=false. // Compare quote source token with selected token to bridge the gap. @@ -88,13 +136,6 @@ export const usePredictBuyConditions = ({ return false; } if (!quotes?.length) { - const isPaymentTokenRequired = requiredTokens?.some( - (token) => - token.address.toLowerCase() === - selectedPaymentToken.address?.toLowerCase() && - token.chainId.toLowerCase() === - selectedPaymentToken.chainId?.toLowerCase(), - ); return !isPaymentTokenRequired; } const request = quotes[0]?.request; @@ -102,8 +143,14 @@ export const usePredictBuyConditions = ({ return false; } return ( - request.sourceTokenAddress?.toLowerCase() !== - selectedPaymentToken.address?.toLowerCase() || + normalizeQuoteComparableAddress( + request.sourceTokenAddress, + request.sourceChainId, + ) !== + normalizeQuoteComparableAddress( + selectedPaymentToken.address, + selectedPaymentToken.chainId, + ) || request.sourceChainId?.toLowerCase() !== selectedPaymentToken.chainId?.toLowerCase() ); @@ -113,20 +160,23 @@ export const usePredictBuyConditions = ({ selectedPaymentToken, quotes, payTotals, - requiredTokens, + isPaymentTokenRequired, ]); const isPayFeesLoading = useMemo( () => - isRedirecting || - (shouldWaitForPayFees && - (isPayTotalsLoading || isPayQuoteLoading || isQuotesStale)), + shouldWaitForPayFees && + (isPayTotalsLoading || + isPayQuoteLoading || + isQuotesStale || + (quotes?.length === 0 && !payTotals)), [ - isRedirecting, shouldWaitForPayFees, isPayTotalsLoading, isPayQuoteLoading, isQuotesStale, + payTotals, + quotes?.length, ], ); @@ -134,21 +184,21 @@ export const usePredictBuyConditions = ({ () => !isConfirming && !isBelowMinimum && + !isInsufficientBalance && !!preview && - !isPlaceOrderLoading && !isRateLimited && !isBalanceLoading && - !isRedirecting && - !isPayFeesLoading, + !isPayFeesLoading && + !isInsufficientPayTokenBalance, [ isConfirming, isBelowMinimum, + isInsufficientBalance, preview, - isPlaceOrderLoading, isRateLimited, isBalanceLoading, - isRedirecting, isPayFeesLoading, + isInsufficientPayTokenBalance, ], ); @@ -157,13 +207,38 @@ export const usePredictBuyConditions = ({ [isPreviewCalculating, isUserInputChange], ); + const canSelectToken = useMemo( + () => + totalPayForPredictBalance > predictBalance || + predictBalance < MINIMUM_BET, + [predictBalance, totalPayForPredictBalance], + ); + + useEffect(() => { + if ( + !isPredictBalanceSelected && + !isInputFocused && + predictBalance >= totalPayForPredictBalance + ) { + resetSelectedPaymentToken(); + } + }, [ + isInputFocused, + isPredictBalanceSelected, + predictBalance, + resetSelectedPaymentToken, + totalPayForPredictBalance, + ]); + return { isBelowMinimum, + isInsufficientBalance, + maxBetAmount, isRateLimited, - isPlacingOrder, canPlaceBet, isUserChangeTriggeringCalculation, isPayFeesLoading, isBalancePulsing, + canSelectToken, }; }; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.test.ts new file mode 100644 index 00000000000..d907e2a1f84 --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.test.ts @@ -0,0 +1,383 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { OrderPreview, Side } from '../../../types'; +import { getPlaceOrderErrorOutcome } from '../../../utils/predictErrorHandler'; +import { usePredictBuyError } from './usePredictBuyError'; + +const mockClearOrderError = jest.fn(); + +let mockActiveOrder: { error?: string } | null = null; +let mockIsBalanceLoading = false; +let mockIsPredictBalanceSelected = true; +let mockInsufficientPayTokenBalanceAlert: { message: string } | null = null; + +jest.mock('../../../hooks/usePredictActiveOrder', () => ({ + usePredictActiveOrder: () => ({ + activeOrder: mockActiveOrder, + clearOrderError: mockClearOrderError, + }), +})); + +jest.mock('../../../hooks/usePredictBalance', () => ({ + usePredictBalance: () => ({ + data: 0, + isLoading: false, + }), +})); + +jest.mock('../../../hooks/usePredictPaymentToken', () => ({ + usePredictPaymentToken: () => ({ + isPredictBalanceSelected: mockIsPredictBalanceSelected, + }), +})); + +jest.mock( + '../../../../../Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert', + () => ({ + useInsufficientPayTokenBalanceAlert: () => [ + mockInsufficientPayTokenBalanceAlert, + ], + }), +); + +jest.mock( + '../../../../../Views/confirmations/hooks/pay/useTransactionPayData', + () => ({ + useTransactionPayTotals: () => null, + }), +); + +jest.mock('./usePredictBuyAvailableBalance', () => ({ + usePredictBuyAvailableBalance: () => ({ + availableBalance: 1000, + isBalanceLoading: mockIsBalanceLoading, + }), +})); + +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, options?: Record) => { + if (key === 'predict.order.prediction_minimum_bet') { + return `Minimum bet: ${options?.amount}`; + } + if (key === 'predict.order.prediction_insufficient_funds') { + return `Not enough funds. You can use up to ${options?.amount}.`; + } + if (key === 'predict.order.no_funds_enough') { + return 'Not enough funds.'; + } + return key; + }), +})); + +jest.mock('../../../utils/format', () => ({ + formatPrice: jest.fn((value: number) => `$${value.toFixed(2)}`), +})); + +jest.mock('../../../constants/transactions', () => ({ + MINIMUM_BET: 1, +})); + +jest.mock('../../../utils/predictErrorHandler', () => ({ + getPlaceOrderErrorOutcome: jest.fn(), +})); + +const mockGetPlaceOrderErrorOutcome = + getPlaceOrderErrorOutcome as jest.MockedFunction< + typeof getPlaceOrderErrorOutcome + >; + +const createMockPreview = ( + overrides?: Partial, +): OrderPreview => ({ + marketId: 'market-1', + outcomeId: 'outcome-1', + outcomeTokenId: 'token-1', + timestamp: 1000000, + side: Side.BUY, + sharePrice: 0.5, + maxAmountSpent: 100, + minAmountReceived: 180, + slippage: 0.01, + tickSize: 0.01, + minOrderSize: 1, + negRisk: false, + rateLimited: false, + fees: { + totalFee: 5, + metamaskFee: 2, + providerFee: 3, + totalFeePercentage: 0.05, + collector: '0xCollector', + }, + ...overrides, +}); + +const defaultParams = { + preview: createMockPreview(), + previewError: null as string | null, + isConfirming: false, + isPlacingOrder: false, + isBelowMinimum: false, + isInsufficientBalance: false, + maxBetAmount: 100, + isPayFeesLoading: false, +}; + +describe('usePredictBuyError', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockActiveOrder = null; + mockIsBalanceLoading = false; + mockIsPredictBalanceSelected = true; + mockInsufficientPayTokenBalanceAlert = null; + }); + + describe('errorResult', () => { + it('returns undefined errorMessage when isBalanceLoading is true', () => { + mockIsBalanceLoading = true; + mockActiveOrder = { error: 'some error' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'parsed error', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.errorMessage).toBeUndefined(); + expect(mockGetPlaceOrderErrorOutcome).not.toHaveBeenCalled(); + }); + + it('returns undefined errorMessage when isPlacingOrder is true', () => { + mockActiveOrder = { error: 'some error' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'parsed error', + }); + + const { result } = renderHook(() => + usePredictBuyError({ ...defaultParams, isPlacingOrder: true }), + ); + + expect(result.current.errorMessage).toBeUndefined(); + expect(mockGetPlaceOrderErrorOutcome).not.toHaveBeenCalled(); + }); + + it('returns undefined errorMessage when isConfirming is true', () => { + mockActiveOrder = { error: 'some error' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'parsed error', + }); + + const { result } = renderHook(() => + usePredictBuyError({ ...defaultParams, isConfirming: true }), + ); + + expect(result.current.errorMessage).toBeUndefined(); + expect(mockGetPlaceOrderErrorOutcome).not.toHaveBeenCalled(); + }); + + it('returns undefined errorMessage when preview is null', () => { + mockActiveOrder = { error: 'some error' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'parsed error', + }); + + const { result } = renderHook(() => + usePredictBuyError({ ...defaultParams, preview: null }), + ); + + expect(result.current.errorMessage).toBeUndefined(); + expect(mockGetPlaceOrderErrorOutcome).not.toHaveBeenCalled(); + }); + + it('calls getPlaceOrderErrorOutcome when activeOrder has error and no blocking conditions', () => { + mockActiveOrder = { error: 'order failed' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'parsed error message', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(mockGetPlaceOrderErrorOutcome).toHaveBeenCalledWith({ + error: 'order failed', + orderParams: { preview: defaultParams.preview }, + }); + expect(result.current.errorMessage).toBe('parsed error message'); + }); + + it('returns the pay token balance alert message for external payment tokens', () => { + mockActiveOrder = { error: 'order failed' }; + mockIsPredictBalanceSelected = false; + mockInsufficientPayTokenBalanceAlert = { + message: 'Insufficient payment token balance', + }; + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(mockGetPlaceOrderErrorOutcome).not.toHaveBeenCalled(); + expect(result.current.errorMessage).toBe( + 'Insufficient payment token balance', + ); + }); + + it('returns undefined when activeOrder has no error', () => { + mockActiveOrder = {}; + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(mockGetPlaceOrderErrorOutcome).not.toHaveBeenCalled(); + expect(result.current.errorMessage).toBeUndefined(); + }); + }); + + describe('errorMessage', () => { + it('returns previewError when it exists (highest priority)', () => { + const { result } = renderHook(() => + usePredictBuyError({ + ...defaultParams, + previewError: 'Preview failed', + }), + ); + + expect(result.current.errorMessage).toBe('Preview failed'); + }); + + it('returns previewError even when other error conditions exist', () => { + mockActiveOrder = { error: 'order error' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'parsed error', + }); + + const { result } = renderHook(() => + usePredictBuyError({ + ...defaultParams, + previewError: 'Preview failed', + isBelowMinimum: true, + isInsufficientBalance: true, + }), + ); + + expect(result.current.errorMessage).toBe('Preview failed'); + }); + + it('returns minimum bet message when isBelowMinimum is true and no errorResult', () => { + const { result } = renderHook(() => + usePredictBuyError({ + ...defaultParams, + isBelowMinimum: true, + }), + ); + + expect(result.current.errorMessage).toBe('Minimum bet: $1.00'); + }); + + it('returns insufficient funds message with formatted max when maxBetAmount >= MINIMUM_BET', () => { + const { result } = renderHook(() => + usePredictBuyError({ + ...defaultParams, + isInsufficientBalance: true, + maxBetAmount: 50, + }), + ); + + expect(result.current.errorMessage).toBe( + 'Not enough funds. You can use up to $50.00.', + ); + }); + + it('returns generic no funds message when maxBetAmount < MINIMUM_BET', () => { + const { result } = renderHook(() => + usePredictBuyError({ + ...defaultParams, + isInsufficientBalance: true, + maxBetAmount: 0.5, + }), + ); + + expect(result.current.errorMessage).toBe('Not enough funds.'); + }); + + it('returns undefined when no error conditions exist', () => { + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('returns undefined when errorResult status is order_not_filled', () => { + mockActiveOrder = { error: 'order not filled' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'order_not_filled', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('returns error string when errorResult status is error', () => { + mockActiveOrder = { error: 'something broke' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'Order placement failed', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.errorMessage).toBe('Order placement failed'); + }); + }); + + describe('isOrderNotFilled', () => { + it('sets to true when errorResult status is order_not_filled', () => { + mockActiveOrder = { error: 'not filled' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'order_not_filled', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.isOrderNotFilled).toBe(true); + }); + + it('remains false when errorResult status is error', () => { + mockActiveOrder = { error: 'something broke' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'error', + error: 'Order placement failed', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.isOrderNotFilled).toBe(false); + }); + + it('remains false when no activeOrder error exists', () => { + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.isOrderNotFilled).toBe(false); + }); + }); + + describe('resetOrderNotFilled', () => { + it('calls clearOrderError and resets isOrderNotFilled to false', () => { + mockActiveOrder = { error: 'not filled' }; + mockGetPlaceOrderErrorOutcome.mockReturnValue({ + status: 'order_not_filled', + }); + + const { result } = renderHook(() => usePredictBuyError(defaultParams)); + + expect(result.current.isOrderNotFilled).toBe(true); + + act(() => { + result.current.resetOrderNotFilled(); + }); + + expect(mockClearOrderError).toHaveBeenCalledTimes(1); + expect(result.current.isOrderNotFilled).toBe(false); + }); + }); +}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.ts new file mode 100644 index 00000000000..a99a1a8cc55 --- /dev/null +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyError.ts @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { strings } from '../../../../../../../locales/i18n'; +import { MINIMUM_BET } from '../../../constants/transactions'; +import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; +import { OrderPreview } from '../../../types'; +import { formatPrice } from '../../../utils/format'; +import { getPlaceOrderErrorOutcome } from '../../../utils/predictErrorHandler'; +import { usePredictBuyAvailableBalance } from './usePredictBuyAvailableBalance'; +import { usePredictPaymentToken } from '../../../hooks/usePredictPaymentToken'; +import { useInsufficientPayTokenBalanceAlert } from '../../../../../Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert'; + +interface UsePredictBuyInfoParams { + preview?: OrderPreview | null; + previewError: string | null; + isConfirming: boolean; + isPlacingOrder: boolean; + isBelowMinimum: boolean; + isInsufficientBalance: boolean; + maxBetAmount: number; + isPayFeesLoading: boolean; +} + +export const usePredictBuyError = ({ + preview, + previewError, + isConfirming, + isPlacingOrder, + isBelowMinimum, + isInsufficientBalance, + maxBetAmount, + isPayFeesLoading, +}: UsePredictBuyInfoParams) => { + const { activeOrder, clearOrderError } = usePredictActiveOrder(); + const { isBalanceLoading } = usePredictBuyAvailableBalance(); + const [isOrderNotFilled, setIsOrderNotFilled] = useState(false); + const { isPredictBalanceSelected } = usePredictPaymentToken(); + const [insufficientPayTokenBalanceAlert] = + useInsufficientPayTokenBalanceAlert(); + + const errorResult = useMemo(() => { + if (isBalanceLoading || isPlacingOrder || isConfirming || !preview) { + return undefined; + } + + if ( + !isPayFeesLoading && + !isPredictBalanceSelected && + !!insufficientPayTokenBalanceAlert + ) { + return { + status: 'error', + error: insufficientPayTokenBalanceAlert.message, + }; + } + + return activeOrder?.error + ? getPlaceOrderErrorOutcome({ + error: activeOrder?.error, + orderParams: { preview }, + }) + : undefined; + }, [ + isBalanceLoading, + isPlacingOrder, + isConfirming, + preview, + isPayFeesLoading, + isPredictBalanceSelected, + insufficientPayTokenBalanceAlert, + activeOrder?.error, + ]); + + const errorMessage = useMemo(() => { + if (previewError) { + return previewError; + } + + if (isBelowMinimum) { + return strings('predict.order.prediction_minimum_bet', { + amount: formatPrice(MINIMUM_BET, { + minimumDecimals: 2, + maximumDecimals: 2, + }), + }); + } + + if (isInsufficientBalance) { + const formattedMax = formatPrice(maxBetAmount, { + minimumDecimals: 2, + maximumDecimals: 2, + }); + return maxBetAmount >= MINIMUM_BET + ? strings('predict.order.prediction_insufficient_funds', { + amount: formattedMax, + }) + : strings('predict.order.no_funds_enough'); + } + + if (!errorResult) { + return undefined; + } + + if (errorResult.status === 'order_not_filled') { + return undefined; + } + + if (errorResult.status === 'error') { + return errorResult.error; + } + + return undefined; + }, [ + previewError, + errorResult, + isBelowMinimum, + isInsufficientBalance, + maxBetAmount, + ]); + + const resetOrderNotFilled = useCallback(() => { + clearOrderError(); + setIsOrderNotFilled(false); + }, [clearOrderError]); + + useEffect(() => { + if (errorResult?.status === 'order_not_filled') { + setIsOrderNotFilled(true); + } + }, [errorResult]); + + return { + errorMessage, + isOrderNotFilled, + resetOrderNotFilled, + }; +}; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.test.ts index 3cb43022a85..566e98ecef3 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.test.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.test.ts @@ -11,6 +11,10 @@ let mockPayTotals: { }; } | null = null; let mockActiveOrder: { error?: string } | null = null; +let mockPredictBalance = 0; +let mockAvailableBalance = 1000; +let mockIsBalanceLoading = false; +let mockInsufficientPayTokenBalanceAlert: { message: string } | null = null; jest.mock('../../../hooks/usePredictPaymentToken', () => ({ usePredictPaymentToken: () => ({ @@ -31,6 +35,52 @@ jest.mock('../../../hooks/usePredictActiveOrder', () => ({ }), })); +jest.mock('../../../hooks/usePredictBalance', () => ({ + usePredictBalance: () => ({ + data: mockPredictBalance, + isLoading: false, + }), +})); + +jest.mock('./usePredictBuyAvailableBalance', () => ({ + usePredictBuyAvailableBalance: () => ({ + availableBalance: mockAvailableBalance, + isBalanceLoading: mockIsBalanceLoading, + }), +})); + +jest.mock( + '../../../../../Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert', + () => ({ + useInsufficientPayTokenBalanceAlert: () => [ + mockInsufficientPayTokenBalanceAlert, + ], + }), +); + +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, options?: Record) => { + if (key === 'predict.order.prediction_minimum_bet') { + return `Minimum bet: ${options?.amount}`; + } + if (key === 'predict.order.prediction_insufficient_funds') { + return `Not enough funds. You can use up to ${options?.amount}.`; + } + if (key === 'predict.order.no_funds_enough') { + return 'Not enough funds.'; + } + return key; + }), +})); + +jest.mock('../../../utils/format', () => ({ + formatPrice: jest.fn((value: number) => `$${value.toFixed(2)}`), +})); + +jest.mock('../../../constants/transactions', () => ({ + MINIMUM_BET: 1, +})); + const createMockPreview = ( overrides?: Partial, ): OrderPreview => ({ @@ -61,10 +111,8 @@ const defaultParams = { currentValue: 100, preview: createMockPreview(), previewError: null as string | null, - placeOrderError: null as string | null, - isOrderNotFilled: false, - isPlaceOrderLoading: false, isConfirming: false, + isPlacingOrder: false, }; describe('usePredictBuyInfo', () => { @@ -73,6 +121,10 @@ describe('usePredictBuyInfo', () => { mockIsPredictBalanceSelected = true; mockPayTotals = null; mockActiveOrder = null; + mockPredictBalance = 0; + mockAvailableBalance = 1000; + mockIsBalanceLoading = false; + mockInsufficientPayTokenBalanceAlert = null; }); describe('depositFee', () => { @@ -127,6 +179,76 @@ describe('usePredictBuyInfo', () => { expect(result.current.depositFee).toBe(2.0); }); + + it('returns 0 when there is an insufficient pay token balance alert', () => { + mockIsPredictBalanceSelected = false; + mockPayTotals = { + fees: { + provider: { usd: 1.5 }, + sourceNetwork: { estimate: { usd: 2.5 } }, + targetNetwork: { usd: 1.0 }, + }, + }; + mockInsufficientPayTokenBalanceAlert = { + message: 'Insufficient payment token balance', + }; + + const { result } = renderHook(() => usePredictBuyInfo(defaultParams)); + + expect(result.current.depositFee).toBe(0); + }); + + it('keeps the last accepted deposit fee while confirming', () => { + mockIsPredictBalanceSelected = false; + mockPayTotals = { + fees: { + provider: { usd: 1.5 }, + sourceNetwork: { estimate: { usd: 2.5 } }, + targetNetwork: { usd: 1.0 }, + }, + }; + + const { result, rerender } = renderHook( + (params: typeof defaultParams) => usePredictBuyInfo(params), + { + initialProps: { ...defaultParams, isConfirming: true }, + }, + ); + + expect(result.current.depositFee).toBe(5); + + mockPayTotals = {}; + + rerender({ ...defaultParams, isConfirming: true }); + + expect(result.current.depositFee).toBe(5); + }); + + it('clears the accepted deposit fee after confirming ends', () => { + mockIsPredictBalanceSelected = false; + mockPayTotals = { + fees: { + provider: { usd: 1.5 }, + sourceNetwork: { estimate: { usd: 2.5 } }, + targetNetwork: { usd: 1.0 }, + }, + }; + + const { result, rerender } = renderHook( + (params: typeof defaultParams) => usePredictBuyInfo(params), + { + initialProps: { ...defaultParams, isConfirming: true }, + }, + ); + + expect(result.current.depositFee).toBe(5); + + mockPayTotals = {}; + + rerender({ ...defaultParams, isConfirming: false }); + + expect(result.current.depositFee).toBe(0); + }); }); describe('total', () => { @@ -205,18 +327,26 @@ describe('usePredictBuyInfo', () => { }); }); - describe('rewardsFeeAmount', () => { - it('returns totalFee from preview fees', () => { + describe('depositAmount', () => { + it('returns the remaining amount needed after predict balance is applied', () => { + mockPredictBalance = 80; + + const { result } = renderHook(() => usePredictBuyInfo(defaultParams)); + + expect(result.current.depositAmount).toBe(25); + }); + + it('rounds the remaining amount up to 2 decimals when a deposit is still needed', () => { + mockPredictBalance = 0; const params = { ...defaultParams, - isPlaceOrderLoading: false, - previewError: null, + currentValue: 2, preview: createMockPreview({ fees: { - totalFee: 7, - metamaskFee: 3, - providerFee: 4, - totalFeePercentage: 0.07, + totalFee: 0.075, + metamaskFee: 0.035, + providerFee: 0.04, + totalFeePercentage: 4, collector: '0xCollector', }, }), @@ -224,111 +354,126 @@ describe('usePredictBuyInfo', () => { const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.rewardsFeeAmount).toBe(7); + expect(result.current.depositAmount).toBe(2.08); }); - it('returns undefined when isPlaceOrderLoading is true', () => { + it('rounds up even when the third decimal is below 5 so the deposit fully covers the shortfall', () => { + mockPredictBalance = 0; const params = { ...defaultParams, - isPlaceOrderLoading: true, - previewError: null, - }; - - const { result } = renderHook(() => usePredictBuyInfo(params)); - - expect(result.current.rewardsFeeAmount).toBeUndefined(); - }); - - it('returns undefined when previewError exists', () => { - const params = { - ...defaultParams, - isPlaceOrderLoading: false, - previewError: 'Preview failed', + currentValue: 2, + preview: createMockPreview({ + fees: { + totalFee: 0.074, + metamaskFee: 0.034, + providerFee: 0.04, + totalFeePercentage: 4, + collector: '0xCollector', + }, + }), }; const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.rewardsFeeAmount).toBeUndefined(); + expect(result.current.depositAmount).toBe(2.08); }); - }); - describe('errorMessage', () => { - it('returns undefined when isOrderNotFilled is true', () => { + it('rounds a tiny positive shortfall up to the minimum cent instead of zero', () => { + mockPredictBalance = 2.075889; const params = { ...defaultParams, - isOrderNotFilled: true, - previewError: 'Some error', - placeOrderError: 'Place error', + currentValue: 2, + preview: createMockPreview({ + fees: { + totalFee: 0.08, + metamaskFee: 0.04, + providerFee: 0.04, + totalFeePercentage: 4, + collector: '0xCollector', + }, + }), }; const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.errorMessage).toBeUndefined(); + expect(result.current.depositAmount).toBe(0.01); }); - it('returns undefined when isConfirming is true', () => { + it('returns the full preview total when predict balance already covers the bet', () => { + mockPredictBalance = 110; const params = { ...defaultParams, - isConfirming: true, - previewError: 'Some error', - placeOrderError: 'Place error', + currentValue: 1, + preview: createMockPreview({ + maxAmountSpent: 1, + fees: { + totalFee: 0.04, + metamaskFee: 0.02, + providerFee: 0.02, + totalFeePercentage: 4, + collector: '0xCollector', + }, + }), }; const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.errorMessage).toBeUndefined(); + expect(result.current.depositAmount).toBe(1.04); }); + }); - it('returns previewError as priority error', () => { - mockActiveOrder = { error: 'Active order error' }; - const params = { - ...defaultParams, - previewError: 'Preview error', - placeOrderError: 'Place order error', - }; - - const { result } = renderHook(() => usePredictBuyInfo(params)); + describe('totalPayForPredictBalance', () => { + it('returns the bet amount plus provider and MetaMask fees', () => { + const { result } = renderHook(() => usePredictBuyInfo(defaultParams)); - expect(result.current.errorMessage).toBe('Preview error'); + expect(result.current.totalPayForPredictBalance).toBe(105); }); + }); - it('returns placeOrderError when no previewError', () => { - mockActiveOrder = { error: 'Active order error' }; + describe('rewardsFeeAmount', () => { + it('returns totalFee from preview fees', () => { const params = { ...defaultParams, + isPlacingOrder: false, previewError: null, - placeOrderError: 'Place order error', + preview: createMockPreview({ + fees: { + totalFee: 7, + metamaskFee: 3, + providerFee: 4, + totalFeePercentage: 0.07, + collector: '0xCollector', + }, + }), }; const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.errorMessage).toBe('Place order error'); + expect(result.current.rewardsFeeAmount).toBe(7); }); - it('returns activeOrder.error as fallback', () => { - mockActiveOrder = { error: 'Active order error' }; + it('returns undefined when isPlacingOrder is true', () => { const params = { ...defaultParams, + isPlacingOrder: true, previewError: null, - placeOrderError: null, }; const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.errorMessage).toBe('Active order error'); + expect(result.current.rewardsFeeAmount).toBeUndefined(); }); - it('returns undefined when no errors exist', () => { - mockActiveOrder = null; + it('returns undefined when previewError exists', () => { const params = { ...defaultParams, - previewError: null, - placeOrderError: null, + isPlacingOrder: false, + previewError: 'Preview failed', }; const { result } = renderHook(() => usePredictBuyInfo(params)); - expect(result.current.errorMessage).toBeUndefined(); + expect(result.current.rewardsFeeAmount).toBeUndefined(); }); }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.ts index 9b0533f701e..5bf8151e987 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.ts @@ -1,46 +1,79 @@ import { BigNumber } from 'bignumber.js'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTransactionPayTotals } from '../../../../../Views/confirmations/hooks/pay/useTransactionPayData'; -import { OrderPreview } from '../../../types'; +import { usePredictBalance } from '../../../hooks/usePredictBalance'; import { usePredictPaymentToken } from '../../../hooks/usePredictPaymentToken'; -import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; +import { OrderPreview } from '../../../types'; +import { useInsufficientPayTokenBalanceAlert } from '../../../../../Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert'; interface UsePredictBuyInfoParams { currentValue: number; preview?: OrderPreview | null; previewError: string | null; - placeOrderError?: string | null; - isOrderNotFilled: boolean; - isPlaceOrderLoading: boolean; isConfirming: boolean; + isPlacingOrder: boolean; } export const usePredictBuyInfo = ({ preview, previewError, currentValue, - placeOrderError, - isOrderNotFilled, - isPlaceOrderLoading, isConfirming, + isPlacingOrder, }: UsePredictBuyInfoParams) => { const { isPredictBalanceSelected } = usePredictPaymentToken(); const payTotals = useTransactionPayTotals(); - const { activeOrder } = usePredictActiveOrder(); + const { data: predictBalance = 0 } = usePredictBalance(); + + const [insufficientPayTokenBalanceAlert] = + useInsufficientPayTokenBalanceAlert(); + + const [acceptedDepositFee, setAcceptedDepositFee] = useState(0); - const depositFee = useMemo(() => { - if (isPredictBalanceSelected || !payTotals?.fees) return 0; + const totalPayForPredictBalance = useMemo( + () => + currentValue + + (preview?.fees?.providerFee ?? 0) + + (preview?.fees?.metamaskFee ?? 0), + [currentValue, preview?.fees?.providerFee, preview?.fees?.metamaskFee], + ); + + const computedDepositFee = useMemo(() => { + if ( + isPredictBalanceSelected || + !payTotals?.fees || + insufficientPayTokenBalanceAlert + ) + return 0; const { provider, sourceNetwork, targetNetwork } = payTotals.fees; return new BigNumber(provider?.usd ?? 0) .plus(sourceNetwork?.estimate?.usd ?? 0) .plus(targetNetwork?.usd ?? 0) .toNumber(); - }, [isPredictBalanceSelected, payTotals]); + }, [ + insufficientPayTokenBalanceAlert, + isPredictBalanceSelected, + payTotals?.fees, + ]); + + useEffect(() => { + if (computedDepositFee > 0) { + setAcceptedDepositFee(computedDepositFee); + } + }, [computedDepositFee]); + + useEffect(() => { + if (!isConfirming) { + setAcceptedDepositFee(0); + } + }, [isConfirming]); + + const fallbackDepositFee = isConfirming ? acceptedDepositFee : 0; + const depositFee = + computedDepositFee > 0 ? computedDepositFee : fallbackDepositFee; const rewardsFeeAmount = - isPlaceOrderLoading || previewError - ? undefined - : (preview?.fees?.totalFee ?? 0); + isPlacingOrder || previewError ? undefined : (preview?.fees?.totalFee ?? 0); const { toWin, metamaskFee, providerFee, total } = useMemo( () => ({ @@ -48,43 +81,39 @@ export const usePredictBuyInfo = ({ isRateLimited: preview?.rateLimited ?? false, metamaskFee: preview?.fees?.metamaskFee ?? 0, providerFee: preview?.fees?.providerFee ?? 0, - total: - currentValue + - (preview?.fees?.providerFee ?? 0) + - (preview?.fees?.metamaskFee ?? 0) + - depositFee, + total: totalPayForPredictBalance + depositFee, }), [ - currentValue, depositFee, preview?.fees?.metamaskFee, preview?.fees?.providerFee, preview?.minAmountReceived, preview?.rateLimited, + totalPayForPredictBalance, ], ); - const errorMessage = useMemo( - () => - isOrderNotFilled || isConfirming - ? undefined - : (previewError ?? placeOrderError ?? activeOrder?.error), - [ - isOrderNotFilled, - isConfirming, - previewError, - placeOrderError, - activeOrder?.error, - ], - ); + const depositAmount = useMemo(() => { + const remainingAmount = new BigNumber(totalPayForPredictBalance) + .minus(predictBalance) + .decimalPlaces(2, BigNumber.ROUND_UP) + .toNumber(); + if (remainingAmount <= 0) { + return new BigNumber(totalPayForPredictBalance) + .decimalPlaces(2, BigNumber.ROUND_UP) + .toNumber(); + } + return remainingAmount; + }, [predictBalance, totalPayForPredictBalance]); return { toWin, metamaskFee, providerFee, depositFee, + depositAmount, total, rewardsFeeAmount, - errorMessage, + totalPayForPredictBalance, }; }; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts index 818be72b297..43c1a909ecc 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts @@ -1,14 +1,11 @@ import { renderHook, act } from '@testing-library/react-native'; import { usePredictBuyInputState } from './usePredictBuyInputState'; -let mockActiveOrder: { amount?: number; isInputFocused?: boolean } | null = - null; -const mockUpdateActiveOrder = jest.fn(); +const mockClearOrderError = jest.fn(); jest.mock('../../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ - activeOrder: mockActiveOrder, - updateActiveOrder: mockUpdateActiveOrder, + clearOrderError: mockClearOrderError, }), })); @@ -24,29 +21,10 @@ jest.mock('@react-navigation/native', () => ({ describe('usePredictBuyInputState', () => { beforeEach(() => { jest.clearAllMocks(); - mockActiveOrder = null; }); describe('currentValue', () => { - it('returns amount from activeOrder when set', () => { - mockActiveOrder = { amount: 42 }; - - const { result } = renderHook(() => usePredictBuyInputState()); - - expect(result.current.currentValue).toBe(42); - }); - - it('returns 0 when activeOrder is null', () => { - mockActiveOrder = null; - - const { result } = renderHook(() => usePredictBuyInputState()); - - expect(result.current.currentValue).toBe(0); - }); - - it('returns 0 when activeOrder.amount is undefined', () => { - mockActiveOrder = {}; - + it('initializes to 0', () => { const { result } = renderHook(() => usePredictBuyInputState()); expect(result.current.currentValue).toBe(0); @@ -54,23 +32,17 @@ describe('usePredictBuyInputState', () => { }); describe('setCurrentValue', () => { - it('calls updateActiveOrder with new amount', () => { - mockActiveOrder = { amount: 10 }; - + it('updates currentValue to the given number', () => { const { result } = renderHook(() => usePredictBuyInputState()); act(() => { result.current.setCurrentValue(20); }); - expect(mockUpdateActiveOrder).toHaveBeenCalledWith( - expect.objectContaining({ amount: 20 }), - ); + expect(result.current.currentValue).toBe(20); }); it('sets isUserInputChange to true when value changes and is greater than 0', () => { - mockActiveOrder = { amount: 5 }; - const { result } = renderHook(() => usePredictBuyInputState()); act(() => { @@ -80,76 +52,63 @@ describe('usePredictBuyInputState', () => { expect(result.current.isUserInputChange).toBe(true); }); - it('clears error when user input detected (amount > 0 and changed)', () => { - mockActiveOrder = { amount: 5 }; - + it('calls clearOrderError when user input detected (amount > 0 and changed)', () => { const { result } = renderHook(() => usePredictBuyInputState()); act(() => { result.current.setCurrentValue(10); }); - expect(mockUpdateActiveOrder).toHaveBeenCalledWith( - expect.objectContaining({ amount: 10, error: null }), - ); + expect(mockClearOrderError).toHaveBeenCalled(); }); it('handles updater function (receives previous value)', () => { - mockActiveOrder = { amount: 5 }; - const { result } = renderHook(() => usePredictBuyInputState()); + act(() => { + result.current.setCurrentValue(5); + }); + act(() => { result.current.setCurrentValue((prev) => prev + 5); }); - expect(mockUpdateActiveOrder).toHaveBeenCalledWith( - expect.objectContaining({ amount: 10 }), - ); + expect(result.current.currentValue).toBe(10); + expect(mockClearOrderError).toHaveBeenCalled(); }); - it('does not set isUserInputChange when value set to 0', () => { - mockActiveOrder = { amount: 5 }; - + it('does not call clearOrderError when value set to 0', () => { const { result } = renderHook(() => usePredictBuyInputState()); + act(() => { + result.current.setCurrentValue(5); + }); + mockClearOrderError.mockClear(); + act(() => { result.current.setCurrentValue(0); }); + expect(mockClearOrderError).not.toHaveBeenCalled(); expect(result.current.isUserInputChange).toBe(false); }); }); describe('isInputFocused', () => { - it('returns isInputFocused from activeOrder', () => { - mockActiveOrder = { isInputFocused: true }; - + it('initializes to true', () => { const { result } = renderHook(() => usePredictBuyInputState()); expect(result.current.isInputFocused).toBe(true); }); - it('returns false when activeOrder is null', () => { - mockActiveOrder = null; - - const { result } = renderHook(() => usePredictBuyInputState()); - - expect(result.current.isInputFocused).toBe(false); - }); - - it('setIsInputFocused calls updateActiveOrder with isInputFocused', () => { - mockActiveOrder = { isInputFocused: false }; - + it('updates isInputFocused via setIsInputFocused', () => { const { result } = renderHook(() => usePredictBuyInputState()); act(() => { - result.current.setIsInputFocused(true); + result.current.setIsInputFocused(false); }); - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - isInputFocused: true, - }); + expect(result.current.isInputFocused).toBe(false); }); }); @@ -173,19 +132,9 @@ describe('usePredictBuyInputState', () => { describe('currentValueUSDString', () => { it('initializes as empty string when currentValue is 0', () => { - mockActiveOrder = null; - const { result } = renderHook(() => usePredictBuyInputState()); expect(result.current.currentValueUSDString).toBe(''); }); - - it('initializes as string representation when currentValue exists', () => { - mockActiveOrder = { amount: 25 }; - - const { result } = renderHook(() => usePredictBuyInputState()); - - expect(result.current.currentValueUSDString).toBe('25'); - }); }); }); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts index ccd9406636c..a65dd005822 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts @@ -1,18 +1,10 @@ -import { SetStateAction, useCallback, useMemo, useRef, useState } from 'react'; - +import { SetStateAction, useCallback, useRef, useState } from 'react'; import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; -import { RouteProp, useRoute } from '@react-navigation/native'; -import { PredictNavigationParamList } from '../../../types/navigation'; export const usePredictBuyInputState = () => { - const { activeOrder, updateActiveOrder } = usePredictActiveOrder(); - - const route = - useRoute>(); - - const { isConfirming: initialIsConfirmingFromRoute = false } = route.params; + const { clearOrderError } = usePredictActiveOrder(); - const currentValue = activeOrder?.amount ?? 0; + const [currentValue, setCurrentValueState] = useState(0); const currentValueRef = useRef(currentValue); currentValueRef.current = currentValue; @@ -21,24 +13,18 @@ export const usePredictBuyInputState = () => { currentValue ? currentValue.toString() : '', ); - const isInputFocused = useMemo( - () => activeOrder?.isInputFocused ?? false, - [activeOrder], - ); + const [isInputFocused, setIsInputFocusedState] = useState(true); + const shouldSyncCurrentValueRef = useRef(false); + const shouldClearAmountErrorRef = useRef(false); + const shouldSyncInputFocusRef = useRef(false); - const setIsInputFocused = useCallback( - (_isInputFocused: boolean) => { - updateActiveOrder({ - isInputFocused: _isInputFocused, - }); - }, - [updateActiveOrder], - ); + const setIsInputFocused = useCallback((nextIsInputFocused: boolean) => { + shouldSyncInputFocusRef.current = true; + setIsInputFocusedState(nextIsInputFocused); + }, []); const [isUserInputChange, setIsUserInputChange] = useState(false); - const [isConfirming, setIsConfirming] = useState( - initialIsConfirmingFromRoute, - ); + const [isConfirming, setIsConfirming] = useState(false); const setCurrentValue = useCallback( (value: SetStateAction) => { @@ -50,16 +36,19 @@ export const usePredictBuyInputState = () => { const isUserInput = nextValue !== previousValue && nextValue > 0; + if (isUserInput) { + clearOrderError(); + } + if (nextValue !== previousValue) { setIsUserInputChange(isUserInput); } - updateActiveOrder({ - amount: nextValue, - ...(isUserInput ? { error: null } : {}), - }); + shouldSyncCurrentValueRef.current = true; + shouldClearAmountErrorRef.current = isUserInput; + setCurrentValueState(nextValue); }, - [updateActiveOrder], + [clearOrderError], ); return { diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.test.ts deleted file mode 100644 index d59c598d8c0..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.test.ts +++ /dev/null @@ -1,632 +0,0 @@ -import { renderHook, act } from '@testing-library/react-native'; -import { StackActions } from '@react-navigation/native'; -import { usePredictBuyActions } from './usePredictBuyPreviewActions'; -import { - ActiveOrderState, - OrderPreview, - PlaceOrderParams, -} from '../../../types'; -import { PREDICT_BALANCE_TOKEN_KEY } from '../../../constants/transactions'; -import { PlaceOrderOutcome } from '../../../hooks/usePredictPlaceOrder'; - -const mockNavigate = jest.fn(); -const mockDispatch = jest.fn(); -const mockOnReject = jest.fn(); -const mockOnApprovalConfirm = jest.fn(); -const mockTriggerPayWithAnyToken = jest.fn(); -const mockUpdateActiveOrder = jest.fn(); -const mockClearActiveOrder = jest.fn(); -const mockNavigateToBuyPreview = jest.fn(); -const mockResetSelectedPaymentToken = jest.fn(); -let mockActiveOrder: { batchId?: string | null } | null = null; -let mockRouteParams: Record = {}; - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockNavigate, - dispatch: mockDispatch, - }), - useRoute: () => ({ - params: mockRouteParams, - }), -})); - -jest.mock('../../../hooks/usePredictNavigation', () => ({ - usePredictNavigation: () => ({ - navigateToBuyPreview: mockNavigateToBuyPreview, - }), -})); - -jest.mock('../../../../../Views/confirmations/hooks/useConfirmActions', () => ({ - useConfirmActions: () => ({ - onReject: mockOnReject, - }), -})); - -jest.mock( - '../../../../../Views/confirmations/hooks/useApprovalRequest', - () => ({ - __esModule: true, - default: () => ({ - onConfirm: mockOnApprovalConfirm, - }), - }), -); - -jest.mock('../../../hooks/usePredictPayWithAnyToken', () => ({ - usePredictPayWithAnyToken: () => ({ - triggerPayWithAnyToken: mockTriggerPayWithAnyToken, - }), -})); - -jest.mock('../../../hooks/usePredictActiveOrder', () => ({ - usePredictActiveOrder: () => ({ - activeOrder: mockActiveOrder, - updateActiveOrder: mockUpdateActiveOrder, - clearActiveOrder: mockClearActiveOrder, - }), -})); - -jest.mock('../../../hooks/usePredictPaymentToken', () => ({ - usePredictPaymentToken: () => ({ - resetSelectedPaymentToken: mockResetSelectedPaymentToken, - }), -})); - -jest.mock('../../../../../../../locales/i18n', () => ({ - strings: (key: string) => key, -})); - -const mockPlaceOrder = jest.fn< - Promise, - [PlaceOrderParams] ->(); -const mockSetIsConfirming = jest.fn(); - -const defaultRouteParams = { - market: { id: 'market-1' }, - outcome: { id: 'outcome-1' }, - outcomeToken: { id: 'token-1' }, - entryPoint: 'predict_feed', - isConfirmation: false, - preview: null, -}; - -const createDefaultParams = (): Parameters[0] => ({ - currentValue: 100, - preview: { - minAmountReceived: 180, - fees: { totalFee: 5 }, - } as OrderPreview, - analyticsProperties: { marketId: 'market-1' }, - placeOrder: mockPlaceOrder, - depositAmount: 0, - setIsConfirming: mockSetIsConfirming, -}); - -describe('usePredictBuyActions', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockActiveOrder = null; - mockRouteParams = { ...defaultRouteParams }; - }); - - describe('handleBack', () => { - it('calls clearActiveOrder', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handleBack(); - }); - - expect(mockClearActiveOrder).toHaveBeenCalledTimes(1); - }); - - it('dispatches StackActions.pop', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handleBack(); - }); - - expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop()); - }); - }); - - describe('handleBackSwipe', () => { - it('calls clearActiveOrder', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handleBackSwipe(); - }); - - expect(mockClearActiveOrder).toHaveBeenCalledTimes(1); - }); - - it('dispatches StackActions.pop when not in confirmation mode', () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handleBackSwipe(); - }); - - expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop()); - }); - - it('does not dispatch pop when in confirmation mode', () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: true }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handleBackSwipe(); - }); - - expect(mockClearActiveOrder).toHaveBeenCalledTimes(1); - expect(mockDispatch).not.toHaveBeenCalled(); - }); - }); - - describe('handleTokenSelected', () => { - it('clears error on active order', async () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ tokenKey: 'some-token' }); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ error: null }); - }); - - it('triggers payWithAnyToken for non-predict-balance token when not in confirmation', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ - tokenKey: 'other-token', - }); - }); - - expect(mockTriggerPayWithAnyToken).toHaveBeenCalledWith( - expect.objectContaining({ - market: defaultRouteParams.market, - outcome: defaultRouteParams.outcome, - outcomeToken: defaultRouteParams.outcomeToken, - }), - ); - }); - - it('sets state to PAY_WITH_ANY_TOKEN for non-predict-balance token', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ - tokenKey: 'other-token', - }); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PAY_WITH_ANY_TOKEN, - }); - }); - - it('sets state to PREVIEW for predict-balance token in confirmation mode', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: true }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ - tokenKey: PREDICT_BALANCE_TOKEN_KEY, - }); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - }); - }); - - it('calls onReject in confirmation mode for predict-balance token', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: true }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ - tokenKey: PREDICT_BALANCE_TOKEN_KEY, - }); - }); - - expect(mockOnReject).toHaveBeenCalledWith(undefined, true); - }); - - it('does not trigger payWithAnyToken for predict-balance token in non-confirmation', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ - tokenKey: PREDICT_BALANCE_TOKEN_KEY, - }); - }); - - expect(mockTriggerPayWithAnyToken).not.toHaveBeenCalled(); - }); - - it('returns early for non-predict-balance token in confirmation mode', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: true }; - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleTokenSelected({ - tokenKey: 'other-token', - }); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ error: null }); - expect(mockOnReject).not.toHaveBeenCalled(); - expect(mockTriggerPayWithAnyToken).not.toHaveBeenCalled(); - }); - }); - - describe('handleConfirm', () => { - it('sets isConfirming to true', async () => { - mockPlaceOrder.mockResolvedValue({ - status: 'success', - result: { success: true, response: undefined }, - }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockSetIsConfirming).toHaveBeenCalledWith(true); - }); - - it('clears error on active order', async () => { - mockPlaceOrder.mockResolvedValue({ - status: 'success', - result: { success: true, response: undefined }, - }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ error: null }); - }); - - it('calls placeOrder with preview and analyticsProperties when not in confirmation', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - mockPlaceOrder.mockResolvedValue({ - status: 'success', - result: { success: true, response: undefined }, - }); - const params = createDefaultParams(); - const { result } = renderHook(() => usePredictBuyActions(params)); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockPlaceOrder).toHaveBeenCalledWith({ - preview: params.preview, - analyticsProperties: params.analyticsProperties, - }); - }); - - it('sets state to PLACING_ORDER before calling placeOrder', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - mockPlaceOrder.mockResolvedValue({ - status: 'success', - result: { success: true, response: undefined }, - }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - const placingOrderCall = mockUpdateActiveOrder.mock.calls.find( - (call: [Record]) => - call[0].state === ActiveOrderState.PLACING_ORDER, - ); - expect(placingOrderCall).toBeDefined(); - }); - - it('sets state back to PREVIEW on placeOrder error', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - mockPlaceOrder.mockResolvedValue({ - status: 'error', - error: 'Order failed', - }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - }); - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('sets state back to PREVIEW on order_not_filled', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - mockPlaceOrder.mockResolvedValue({ status: 'order_not_filled' }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - }); - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('calls onApprovalConfirm when isConfirmation is true', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: true }; - mockOnApprovalConfirm.mockResolvedValue(undefined); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockOnApprovalConfirm).toHaveBeenCalledWith({ - deleteAfterResult: true, - waitForResult: true, - handleErrors: false, - }); - expect(mockPlaceOrder).not.toHaveBeenCalled(); - }); - - it('resets state to PREVIEW on deposit_required', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - mockPlaceOrder.mockResolvedValue({ status: 'deposit_required' }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - }); - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('resets state to PREVIEW on deposit_in_progress', async () => { - mockRouteParams = { ...defaultRouteParams, isConfirmation: false }; - mockPlaceOrder.mockResolvedValue({ status: 'deposit_in_progress' }); - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleConfirm(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - }); - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('throws error when no preview available', async () => { - mockRouteParams = { - ...defaultRouteParams, - isConfirmation: false, - preview: null, - }; - const params = createDefaultParams(); - params.preview = undefined; - const { result } = renderHook(() => usePredictBuyActions(params)); - - await expect( - act(async () => { - await result.current.handleConfirm(); - }), - ).rejects.toThrow('Preview is required'); - }); - }); - - describe('handleDepositFailed', () => { - it('sets isConfirming to false', async () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleDepositFailed('Deposit failed'); - }); - - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('updates active order with error and PAY_WITH_ANY_TOKEN state', async () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleDepositFailed('Deposit failed'); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PAY_WITH_ANY_TOKEN, - error: 'Deposit failed', - batchId: null, - }); - }); - - it('triggers payWithAnyToken flow', async () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleDepositFailed(); - }); - - expect(mockTriggerPayWithAnyToken).toHaveBeenCalledWith({ - market: defaultRouteParams.market, - outcome: defaultRouteParams.outcome, - outcomeToken: defaultRouteParams.outcomeToken, - }); - }); - - it('clears batchId on failure', async () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleDepositFailed('error'); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith( - expect.objectContaining({ batchId: null }), - ); - }); - - it('uses default error message when none provided', async () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - await act(async () => { - await result.current.handleDepositFailed(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'predict.deposit.error_description', - }), - ); - }); - }); - - describe('handlePlaceOrderSuccess', () => { - it('sets isConfirming to false', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handlePlaceOrderSuccess(); - }); - - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('clears active order', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handlePlaceOrderSuccess(); - }); - - expect(mockClearActiveOrder).toHaveBeenCalledTimes(1); - }); - - it('dispatches StackActions.pop', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handlePlaceOrderSuccess(); - }); - - expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop()); - }); - }); - - describe('handlePlaceOrderError', () => { - it('sets isConfirming to false', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handlePlaceOrderError(); - }); - - expect(mockSetIsConfirming).toHaveBeenCalledWith(false); - }); - - it('sets state back to PREVIEW', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handlePlaceOrderError(); - }); - - expect(mockUpdateActiveOrder).toHaveBeenCalledWith({ - state: ActiveOrderState.PREVIEW, - }); - }); - - it('resets selected payment token', () => { - const { result } = renderHook(() => - usePredictBuyActions(createDefaultParams()), - ); - - act(() => { - result.current.handlePlaceOrderError(); - }); - - expect(mockResetSelectedPaymentToken).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.ts deleted file mode 100644 index 7cd1e90a914..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { - RouteProp, - StackActions, - useNavigation, - useRoute, -} from '@react-navigation/native'; -import { PredictNavigationParamList } from '../../../types/navigation'; -import { useCallback, useMemo, useState } from 'react'; -import { usePredictNavigation } from '../../../hooks/usePredictNavigation'; -import { useConfirmActions } from '../../../../../Views/confirmations/hooks/useConfirmActions'; -import { usePredictPayWithAnyToken } from '../../../hooks/usePredictPayWithAnyToken'; -import { PlaceOrderOutcome } from '../../../hooks/usePredictPlaceOrder'; -import { - ActiveOrderState, - OrderPreview, - PlaceOrderParams, -} from '../../../types'; -import { strings } from '../../../../../../../locales/i18n'; -import useApprovalRequest from '../../../../../Views/confirmations/hooks/useApprovalRequest'; -import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; -import { PREDICT_BALANCE_TOKEN_KEY } from '../../../constants/transactions'; -import { usePredictPaymentToken } from '../../../hooks/usePredictPaymentToken'; - -interface UsePredictBuyActionsParams { - currentValue: number; - preview?: OrderPreview | null; - analyticsProperties: PlaceOrderParams['analyticsProperties']; - placeOrder: (params: PlaceOrderParams) => Promise; - depositAmount: number; - setIsConfirming: (value: boolean) => void; -} - -export const usePredictBuyActions = ({ - preview: livePreview, - analyticsProperties, - placeOrder, - setIsConfirming, -}: UsePredictBuyActionsParams) => { - const route = - useRoute>(); - const navigation = useNavigation(); - const { onReject } = useConfirmActions(); - const { onConfirm: onApprovalConfirm } = useApprovalRequest(); - const { triggerPayWithAnyToken } = usePredictPayWithAnyToken(); - const { updateActiveOrder, clearActiveOrder } = usePredictActiveOrder(); - const { navigateToBuyPreview } = usePredictNavigation(); - const [isPreviewFromRouteUsed, setIsPreviewFromRouteUsed] = useState(false); - const { resetSelectedPaymentToken } = usePredictPaymentToken(); - const { activeOrder } = usePredictActiveOrder(); - - const batchId = useMemo(() => activeOrder?.batchId, [activeOrder?.batchId]); - - const { - market, - outcome, - outcomeToken, - entryPoint, - isConfirmation, - preview: previewFromRoute, - } = route.params; - - const redirectToBuyPreview = useCallback( - (params?: { includeTransaction?: boolean; isConfirming?: boolean }) => { - navigateToBuyPreview( - { - market, - outcome, - outcomeToken, - ...(livePreview ? { preview: { ...livePreview } } : {}), - animationEnabled: false, - entryPoint, - ...(params?.isConfirming ? { isConfirming: true } : {}), - }, - { replace: true }, - ); - }, - [ - entryPoint, - market, - navigateToBuyPreview, - outcome, - outcomeToken, - livePreview, - ], - ); - - const handleTokenSelected = useCallback( - async ({ tokenKey }: { tokenKey: string | null }) => { - updateActiveOrder({ error: null }); - if (isConfirmation) { - if (tokenKey !== PREDICT_BALANCE_TOKEN_KEY) { - return; - } - updateActiveOrder({ - state: ActiveOrderState.PREVIEW, - }); - redirectToBuyPreview(); - onReject(undefined, true); - return; - } - if (tokenKey !== PREDICT_BALANCE_TOKEN_KEY) { - updateActiveOrder({ - state: ActiveOrderState.PAY_WITH_ANY_TOKEN, - }); - triggerPayWithAnyToken({ - market, - outcome, - outcomeToken, - ...(livePreview ? { preview: { ...livePreview } } : {}), - }); - } - }, - [ - isConfirmation, - market, - onReject, - outcome, - outcomeToken, - livePreview, - redirectToBuyPreview, - triggerPayWithAnyToken, - updateActiveOrder, - ], - ); - - const handleDepositFailed = useCallback( - async (depositErrorMessage?: string) => { - setIsConfirming(false); - updateActiveOrder({ - state: ActiveOrderState.PAY_WITH_ANY_TOKEN, - error: - depositErrorMessage ?? strings('predict.deposit.error_description'), - batchId: null, - }); - triggerPayWithAnyToken({ - market, - outcome, - outcomeToken, - }); - }, - [ - setIsConfirming, - updateActiveOrder, - triggerPayWithAnyToken, - market, - outcome, - outcomeToken, - ], - ); - - const handleConfirm = useCallback(async () => { - setIsConfirming(true); - updateActiveOrder({ error: null }); - - if (isConfirmation) { - updateActiveOrder({ - state: ActiveOrderState.DEPOSITING, - }); - redirectToBuyPreview({ - isConfirming: true, - }); - await onApprovalConfirm({ - deleteAfterResult: true, - waitForResult: true, - handleErrors: false, - }); - return; - } - - if (!livePreview && !previewFromRoute) { - throw new Error('Preview is required'); - } - - const isFromPayWithAnyToken = batchId && !isPreviewFromRouteUsed; - const previewToUse = isFromPayWithAnyToken ? previewFromRoute : livePreview; - - if (!previewToUse) { - throw new Error('Preview is required'); - } - - if (isFromPayWithAnyToken) { - resetSelectedPaymentToken(); - setIsPreviewFromRouteUsed(true); - } - - updateActiveOrder({ - state: ActiveOrderState.PLACING_ORDER, - }); - const orderResult = await placeOrder({ - analyticsProperties, - preview: previewToUse, - }); - if ( - orderResult.status === 'error' || - orderResult.status === 'order_not_filled' || - orderResult.status === 'deposit_required' || - orderResult.status === 'deposit_in_progress' - ) { - setIsConfirming(false); - updateActiveOrder({ state: ActiveOrderState.PREVIEW }); - } - }, [ - setIsConfirming, - updateActiveOrder, - isConfirmation, - livePreview, - previewFromRoute, - batchId, - isPreviewFromRouteUsed, - placeOrder, - analyticsProperties, - redirectToBuyPreview, - onApprovalConfirm, - resetSelectedPaymentToken, - ]); - - const handleBack = useCallback(() => { - clearActiveOrder(); - navigation.dispatch(StackActions.pop()); - }, [clearActiveOrder, navigation]); - - const handleBackSwipe = useCallback(() => { - clearActiveOrder(); - if (isConfirmation) return; - navigation.dispatch(StackActions.pop()); - }, [clearActiveOrder, isConfirmation, navigation]); - - const handlePlaceOrderSuccess = useCallback(() => { - setIsConfirming(false); - clearActiveOrder(); - navigation.dispatch(StackActions.pop()); - }, [setIsConfirming, clearActiveOrder, navigation]); - - const handlePlaceOrderError = useCallback(() => { - setIsConfirming(false); - updateActiveOrder({ state: ActiveOrderState.PREVIEW }); - resetSelectedPaymentToken(); - }, [setIsConfirming, updateActiveOrder, resetSelectedPaymentToken]); - - return { - handleBack, - handleBackSwipe, - handleTokenSelected, - handleConfirm, - handleDepositFailed, - handlePlaceOrderSuccess, - handlePlaceOrderError, - }; -}; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.test.ts deleted file mode 100644 index 2e7df9b9ab4..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { renderHook } from '@testing-library/react-native'; -import { usePredictOrderTracking } from './usePredictOrderTracking'; -import { Result } from '../../../types'; - -describe('usePredictOrderTracking', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('onSuccess callback', () => { - it('calls onSuccess when result.success is true', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - const result = { success: true, response: undefined } as Result; - - // Act - renderHook(() => - usePredictOrderTracking({ - result, - onSuccess, - error: undefined, - onError, - }), - ); - - // Assert - expect(onSuccess).toHaveBeenCalledTimes(1); - expect(onError).not.toHaveBeenCalled(); - }); - - it('does not call onSuccess when result is null', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - - // Act - renderHook(() => - usePredictOrderTracking({ - result: null, - onSuccess, - error: undefined, - onError, - }), - ); - - // Assert - expect(onSuccess).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - }); - - it('does not call onSuccess when result.success is false', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - const result = { success: false, error: 'test' } as Result; - - // Act - renderHook(() => - usePredictOrderTracking({ - result, - onSuccess, - error: undefined, - onError, - }), - ); - - // Assert - expect(onSuccess).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - }); - - it('calls onSuccess only once on rerender with same result', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - const result = { success: true, response: undefined } as Result; - - // Act - const { rerender } = renderHook( - ({ result: r, onSuccess: os, error: e, onError: oe }) => - usePredictOrderTracking({ - result: r, - onSuccess: os, - error: e, - onError: oe, - }), - { - initialProps: { - result, - onSuccess, - error: undefined, - onError, - }, - }, - ); - - rerender({ - result, - onSuccess, - error: undefined, - onError, - }); - - // Assert - expect(onSuccess).toHaveBeenCalledTimes(1); - }); - }); - - describe('onError callback', () => { - it('calls onError when error is truthy string', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - const error = 'Something went wrong'; - - // Act - renderHook(() => - usePredictOrderTracking({ - result: null, - onSuccess, - error, - onError, - }), - ); - - // Assert - expect(onError).toHaveBeenCalledWith(error); - expect(onError).toHaveBeenCalledTimes(1); - expect(onSuccess).not.toHaveBeenCalled(); - }); - - it('does not call onError when error is undefined', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - - // Act - renderHook(() => - usePredictOrderTracking({ - result: null, - onSuccess, - error: undefined, - onError, - }), - ); - - // Assert - expect(onError).not.toHaveBeenCalled(); - expect(onSuccess).not.toHaveBeenCalled(); - }); - - it('calls onError only once on rerender with same error', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - const error = 'Network error'; - - // Act - const { rerender } = renderHook( - ({ result: r, onSuccess: os, error: e, onError: oe }) => - usePredictOrderTracking({ - result: r, - onSuccess: os, - error: e, - onError: oe, - }), - { - initialProps: { - result: null, - onSuccess, - error, - onError, - }, - }, - ); - - rerender({ - result: null, - onSuccess, - error, - onError, - }); - - // Assert - expect(onError).toHaveBeenCalledTimes(1); - }); - }); - - describe('combined scenarios', () => { - it('calls both onSuccess and onError when both result.success and error are present', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - const result = { success: true, response: undefined } as Result; - const error = 'Error occurred'; - - // Act - renderHook(() => - usePredictOrderTracking({ - result, - onSuccess, - error, - onError, - }), - ); - - // Assert - expect(onSuccess).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith(error); - expect(onError).toHaveBeenCalledTimes(1); - }); - - it('does not call either callback when result is null and error is undefined', () => { - // Arrange - const onSuccess = jest.fn(); - const onError = jest.fn(); - - // Act - renderHook(() => - usePredictOrderTracking({ - result: null, - onSuccess, - error: undefined, - onError, - }), - ); - - // Assert - expect(onSuccess).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.ts deleted file mode 100644 index bdebe825272..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect } from 'react'; -import { Result } from '../../../types'; - -export function usePredictOrderTracking({ - result, - onSuccess, - error, - onError, -}: { - result: Result | null; - error: string | undefined; - onSuccess: () => void; - onError: (error: string) => void; -}) { - useEffect(() => { - if (result?.success) { - onSuccess(); - } - }, [onSuccess, result]); - - useEffect(() => { - if (error) { - onError(error); - } - }, [onError, error]); -} diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.test.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.test.ts deleted file mode 100644 index 394340b1e6e..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { - TransactionMeta, - TransactionStatus, -} from '@metamask/transaction-controller'; -import { renderHookWithProvider } from '../../../../../../util/test/renderWithProvider'; -import { usePredictPayWithAnyTokenTracking } from './usePredictPayWithAnyTokenTracking'; -import { PREDICTION_ERROR_TRANSACTION_BATCH_ID } from '../../../constants/transactions'; - -let mockActiveOrder: { batchId?: string; error?: string } | null = null; -let mockRouteParams: Record = { isConfirmation: false }; - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useRoute: () => ({ - params: mockRouteParams, - }), -})); - -jest.mock('../../../hooks/usePredictActiveOrder', () => ({ - usePredictActiveOrder: () => ({ - activeOrder: mockActiveOrder, - }), -})); - -function runHook(params: { - onConfirm?: () => void; - onFail?: (errorMessage?: string) => void; - transactions?: TransactionMeta[]; -}) { - return renderHookWithProvider( - () => - usePredictPayWithAnyTokenTracking({ - onConfirm: params.onConfirm, - onFail: params.onFail, - }), - { - state: { - engine: { - backgroundState: { - TransactionController: { - transactions: params.transactions ?? [], - }, - }, - }, - }, - }, - ); -} - -describe('usePredictPayWithAnyTokenTracking', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockActiveOrder = null; - mockRouteParams = { isConfirmation: false }; - }); - - describe('status detection', () => { - it('returns isConfirmed true when transaction with matching batchId is confirmed', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.confirmed, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.isConfirmed).toBe(true); - expect(result.current.isFailed).toBe(false); - expect(result.current.isProcessing).toBe(false); - }); - - it('returns isFailed true when transaction with matching batchId failed', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.failed, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.isFailed).toBe(true); - expect(result.current.isConfirmed).toBe(false); - }); - - it('returns isFailed true when transaction is rejected', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.rejected, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.isFailed).toBe(true); - expect(result.current.isConfirmed).toBe(false); - }); - - it('returns neutral state when batchId is undefined', () => { - mockActiveOrder = {}; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.confirmed, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.isConfirmed).toBe(false); - expect(result.current.isFailed).toBe(false); - expect(result.current.isProcessing).toBe(false); - expect(result.current.errorMessage).toBeUndefined(); - }); - - it('returns isProcessing true when transaction is signed', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.signed, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.isProcessing).toBe(true); - expect(result.current.isConfirmed).toBe(false); - expect(result.current.isFailed).toBe(false); - }); - - it('returns isProcessing true when transaction is submitted', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.submitted, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.isProcessing).toBe(true); - expect(result.current.isConfirmed).toBe(false); - expect(result.current.isFailed).toBe(false); - }); - - it('returns error message from transaction.error.message', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.failed, - error: { message: 'Transaction reverted' }, - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.errorMessage).toBe('Transaction reverted'); - }); - - it('returns error message from transaction.errormsg as fallback', () => { - mockActiveOrder = { batchId: 'batch-1' }; - - const { result } = runHook({ - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.failed, - errormsg: 'Fallback error message', - } as unknown as TransactionMeta, - ], - }); - - expect(result.current.errorMessage).toBe('Fallback error message'); - }); - }); - - describe('controller error handling', () => { - it('returns isFailed true when activeOrder has error and batchId is PREDICTION_ERROR_TRANSACTION_BATCH_ID', () => { - mockActiveOrder = { - batchId: PREDICTION_ERROR_TRANSACTION_BATCH_ID, - error: 'Controller error occurred', - }; - - const { result } = runHook({ - transactions: [], - }); - - expect(result.current.isFailed).toBe(true); - }); - }); - - describe('onConfirm callback', () => { - it('calls onConfirm when transaction becomes confirmed', () => { - mockActiveOrder = { batchId: 'batch-1' }; - const onConfirm = jest.fn(); - - runHook({ - onConfirm, - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.confirmed, - } as unknown as TransactionMeta, - ], - }); - - expect(onConfirm).toHaveBeenCalledTimes(1); - }); - - it('calls onConfirm only once across rerenders', () => { - mockActiveOrder = { batchId: 'batch-1' }; - const onConfirm = jest.fn(); - - const { rerender } = runHook({ - onConfirm, - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.confirmed, - } as unknown as TransactionMeta, - ], - }); - - rerender(undefined); - - expect(onConfirm).toHaveBeenCalledTimes(1); - }); - }); - - describe('onFail callback', () => { - it('calls onFail with error message when transaction fails', () => { - mockActiveOrder = { batchId: 'batch-1' }; - const onFail = jest.fn(); - - runHook({ - onFail, - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.failed, - error: { message: 'Transaction failed on-chain' }, - } as unknown as TransactionMeta, - ], - }); - - expect(onFail).toHaveBeenCalledTimes(1); - expect(onFail).toHaveBeenCalledWith('Transaction failed on-chain'); - }); - - it('calls onFail only once across rerenders', () => { - mockActiveOrder = { batchId: 'batch-1' }; - const onFail = jest.fn(); - - const { rerender } = runHook({ - onFail, - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.failed, - error: { message: 'fail' }, - } as unknown as TransactionMeta, - ], - }); - - rerender(undefined); - - expect(onFail).toHaveBeenCalledTimes(1); - }); - - it('calls onFail with activeOrder.error when controller error occurs', () => { - mockActiveOrder = { - batchId: PREDICTION_ERROR_TRANSACTION_BATCH_ID, - error: 'Controller error occurred', - }; - const onFail = jest.fn(); - - runHook({ - onFail, - transactions: [], - }); - - expect(onFail).toHaveBeenCalledTimes(1); - expect(onFail).toHaveBeenCalledWith('Controller error occurred'); - }); - }); - - describe('batchId change tracking', () => { - it('resets callback dedup refs when batchId changes', () => { - mockActiveOrder = { batchId: 'batch-1' }; - const onConfirm = jest.fn(); - - const { rerender } = runHook({ - onConfirm, - transactions: [ - { - id: 'tx-1', - batchId: 'batch-1', - status: TransactionStatus.confirmed, - } as unknown as TransactionMeta, - { - id: 'tx-2', - batchId: 'batch-2', - status: TransactionStatus.confirmed, - } as unknown as TransactionMeta, - ], - }); - - expect(onConfirm).toHaveBeenCalledTimes(1); - - mockActiveOrder = { batchId: 'batch-2' }; - rerender(undefined); - - expect(onConfirm).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.ts b/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.ts deleted file mode 100644 index 258f923959b..00000000000 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { TransactionStatus } from '@metamask/transaction-controller'; -import { useEffect, useMemo, useRef } from 'react'; -import { useSelector } from 'react-redux'; -import { RootState } from '../../../../../../reducers'; -import { selectTransactionsByBatchId } from '../../../../../../selectors/transactionController'; -import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder'; -import { RouteProp, useRoute } from '@react-navigation/native'; -import { PredictNavigationParamList } from '../../../types/navigation'; -import { PREDICTION_ERROR_TRANSACTION_BATCH_ID } from '../../../constants/transactions'; - -interface UsePredictPayWithAnyTokenTrackingParams { - onConfirm?: () => void; - onFail?: (errorMessage?: string) => void; -} - -function getTransactionErrorMessage( - transactionMeta: { - error?: unknown; - errormsg?: unknown; - } | null, -) { - const errorMessage = - typeof transactionMeta?.error === 'object' && - transactionMeta?.error && - 'message' in transactionMeta.error && - typeof transactionMeta.error.message === 'string' - ? transactionMeta.error.message - : undefined; - - if (errorMessage && errorMessage.trim() !== '') { - return errorMessage; - } - - if ( - typeof transactionMeta?.errormsg === 'string' && - transactionMeta.errormsg.trim() !== '' - ) { - return transactionMeta.errormsg; - } - - return undefined; -} - -export function usePredictPayWithAnyTokenTracking({ - onConfirm, - onFail, -}: UsePredictPayWithAnyTokenTrackingParams) { - const hasCalledConfirmRef = useRef(false); - const hasCalledFailRef = useRef(false); - - const route = - useRoute>(); - - const { isConfirmation } = route.params; - - const { activeOrder } = usePredictActiveOrder(); - - const batchId = useMemo(() => activeOrder?.batchId, [activeOrder?.batchId]); - const error = useMemo(() => activeOrder?.error, [activeOrder?.error]); - - const trackedBatchIdRef = useRef(batchId); - - const transactions = useSelector((state: RootState) => - batchId ? selectTransactionsByBatchId(state, batchId) : null, - ); - - const transactionMeta = useMemo(() => transactions?.[0], [transactions]); - - const status = transactionMeta?.status; - const errorMessage = getTransactionErrorMessage(transactionMeta ?? null); - const isConfirmed = status === TransactionStatus.confirmed; - const isControllerError = - !!error && batchId === PREDICTION_ERROR_TRANSACTION_BATCH_ID; - const isFailed = - isControllerError || - status === TransactionStatus.failed || - status === TransactionStatus.rejected; - - const isProcessing = - status === TransactionStatus.signed || - status === TransactionStatus.submitted; - - useEffect(() => { - if (trackedBatchIdRef.current === batchId) { - return; - } - - trackedBatchIdRef.current = batchId; - hasCalledConfirmRef.current = false; - hasCalledFailRef.current = false; - }, [batchId]); - - useEffect(() => { - if (!batchId || !isConfirmed || !onConfirm || hasCalledConfirmRef.current) { - return; - } - - hasCalledConfirmRef.current = true; - onConfirm(); - }, [batchId, isConfirmed, onConfirm]); - - useEffect(() => { - if (isFailed && !hasCalledFailRef.current && onFail) { - hasCalledFailRef.current = true; - onFail(error ?? errorMessage); - } - }, [batchId, isFailed, onFail, error, errorMessage, isConfirmation]); - - return { - isConfirmed, - isFailed, - errorMessage, - isProcessing, - status, - }; -} diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 568630de21a..15658ddc66b 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -60,7 +60,6 @@ jest.mock('../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ initializeActiveOrder: jest.fn(), activeOrder: null, - updateActiveOrder: jest.fn(), clearActiveOrder: jest.fn(), }), })); diff --git a/app/core/Engine/controllers/predict-controller/index.test.ts b/app/core/Engine/controllers/predict-controller/index.test.ts index d18368cacc3..ba9ebe54b54 100644 --- a/app/core/Engine/controllers/predict-controller/index.test.ts +++ b/app/core/Engine/controllers/predict-controller/index.test.ts @@ -73,6 +73,7 @@ describe('predict controller init', () => { withdrawTransaction: null, selectedPaymentToken: null, accountMeta: {}, + activeBuyOrder: null, }; initRequestMock.persistedState = { diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 9d2aad7e876..6e8410c5364 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -733,7 +733,7 @@ "pendingDeposits": {}, "pendingClaims": {}, "withdrawTransaction": null, - "activeOrder": null, + "activeBuyOrder": null, "selectedPaymentToken": null, "accountMeta": {} }, diff --git a/locales/languages/en.json b/locales/languages/en.json index b3fd7405bcb..b724e2df93b 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2317,6 +2317,7 @@ "cashing_out_subtitle": "Estimated {{time}} seconds", "placing_prediction": "Placing a prediction", "prediction_placed": "Prediction placed", + "prediction_failed": "Failed to place prediction", "order_failed": "Order failed", "payments_made_in_usdc": "All payments are made in USDC", "prediction_insufficient_funds": "Not enough funds. You can use up to {{amount}}.", @@ -2330,6 +2331,7 @@ "order_failed_title": "Order failed", "order_failed_body": "There wasn't enough liquidity at this price. Want to try again?", "try_again": "Try again", + "view": "View", "yes_buy": "Yes, buy", "yes_sell": "Yes, sell" }, @@ -2384,7 +2386,8 @@ "unknown_error": "An unknown error occurred", "order_not_fully_filled": "Failed to fill your order", "buy_order_not_fully_filled": "Not enough shares available at market price to place your order right now.", - "sell_order_not_fully_filled": "There isn't enough demand at market price to cash out right now." + "sell_order_not_fully_filled": "There isn't enough demand at market price to cash out right now.", + "preview_not_available": "No preview available" }, "game_details_footer": { "pick_a_winner": "Pick a winner", From e52b1aa62e5ef55ff044acfad7dd068039e8e56f Mon Sep 17 00:00:00 2001 From: Christopher Ferreira <104831203+christopherferreira9@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:28:27 +0100 Subject: [PATCH 3/7] revert(perps): add Perps Withdraw confirmation flow and post-quote config (#28105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts MetaMask/metamask-mobile#28046 which was blocking E2E. --- > [!NOTE] > **Medium Risk** > Reverts perps-withdraw-specific confirmation/pay behavior and downgrades `@metamask/transaction-pay-controller`, which can subtly affect transaction-pay quoting/config and related UI/metrics. > > **Overview** > Reverts the perps withdraw confirmation integration by removing `TransactionType.perpsWithdraw` special-casing across confirmations (alert banner/footer visibility, back-navigation, insufficient-balance ignoring, bridge-fee tooltip messaging, custom-amount calculations, and pay metrics/tests). > > Simplifies `useTransactionPayPostQuote` to always set `refundTo` via the Predict Safe proxy and removes the Hyperliquid/perps-withdraw config path. > > Adjusts CI to tighten the iOS JS bundle size threshold (54 → 53) and downgrades `@metamask/transaction-pay-controller` (with corresponding lockfile/state snapshot updates, including removal of some persisted state fields like `providerAutoSelected`/`tokenWarnings`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d3227825266c5fc66da45b87af670f1b8d235af4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/ci.yml | 2 +- .../components/confirm/confirm-component.tsx | 1 - .../components/footer/footer.tsx | 1 - .../bridge-fee-row/bridge-fee-row.test.tsx | 20 - .../rows/bridge-fee-row/bridge-fee-row.tsx | 28 +- .../useInsufficientBalanceAlert.test.ts | 13 +- .../alerts/useInsufficientBalanceAlert.ts | 5 +- .../pay/useTransactionPayMetrics.test.ts | 23 - .../hooks/pay/useTransactionPayMetrics.ts | 7 - .../pay/useTransactionPayPostQuote.test.ts | 54 -- .../hooks/pay/useTransactionPayPostQuote.ts | 36 +- .../transactions/useTransactionConfirm.ts | 1 - .../useTransactionCustomAmount.test.ts | 42 - .../useTransactionCustomAmount.ts | 8 +- .../ramps-controller-init.test.ts | 1 - .../KeyringSnapPermissions.test.ts | 1 - .../logs/__snapshots__/index.test.ts.snap | 4 - app/util/test/initial-background-state.json | 4 +- locales/languages/en.json | 3 - package.json | 2 +- yarn.lock | 724 +++++++----------- 21 files changed, 305 insertions(+), 675 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9db7039365..62e889b089d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,7 +214,7 @@ jobs: NODE_OPTIONS: --max_old_space_size=12288 - name: Check bundle size - run: ./scripts/js-bundle-stats.sh ios/main.jsbundle 54 + run: ./scripts/js-bundle-stats.sh ios/main.jsbundle 53 - name: Upload iOS bundle uses: actions/upload-artifact@v4 diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx index d56982b74c8..2315e4f1cfd 100755 --- a/app/components/Views/confirmations/components/confirm/confirm-component.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.tsx @@ -41,7 +41,6 @@ const TRANSACTION_TYPES_DISABLE_SCROLL = [TransactionType.predictClaim]; const TRANSACTION_TYPES_DISABLE_ALERT_BANNER = [ TransactionType.perpsDeposit, TransactionType.perpsDepositAndOrder, - TransactionType.perpsWithdraw, TransactionType.predictDeposit, TransactionType.predictWithdraw, ]; diff --git a/app/components/Views/confirmations/components/footer/footer.tsx b/app/components/Views/confirmations/components/footer/footer.tsx index 9417561b8c9..046843e0d0c 100644 --- a/app/components/Views/confirmations/components/footer/footer.tsx +++ b/app/components/Views/confirmations/components/footer/footer.tsx @@ -43,7 +43,6 @@ import { useQRHardwareContext } from '../../context/qr-hardware-context'; const HIDE_FOOTER_BY_DEFAULT_TYPES = [ TransactionType.perpsDeposit, TransactionType.perpsDepositAndOrder, - TransactionType.perpsWithdraw, TransactionType.predictDeposit, TransactionType.predictWithdraw, TransactionType.musdConversion, diff --git a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx index c95c6d58751..da16d172c01 100644 --- a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx @@ -142,26 +142,6 @@ describe('BridgeFeeRow', () => { expect(getByText('$1.23')).toBeOnTheScreen(); }); - it('renders tooltip for perps withdraw', async () => { - const { getByTestId } = render({ - type: TransactionType.perpsWithdraw, - }); - - await act(async () => { - fireEvent.press(getByTestId('info-row-tooltip-open-btn')); - }); - - expect(getByTestId('info-row-tooltip-open-btn')).toBeDefined(); - }); - - it('renders fee for perps withdraw', () => { - const { getByText } = render({ - type: TransactionType.perpsWithdraw, - }); - - expect(getByText('$1.23')).toBeDefined(); - }); - it('renders metamask fee in tooltip', async () => { useTransactionTotalsMock.mockReturnValue({ fees: { diff --git a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.tsx b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.tsx index f846fcd1695..8a30fde4ec0 100644 --- a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.tsx +++ b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.tsx @@ -67,16 +67,11 @@ function TransactionFeeRow({ const feeTotalUsd = useMemo(() => { if (!totals?.fees) return ''; - const metaMask = totals.fees.metaMask.usd ?? 0; - const provider = totals.fees.provider.usd; - const sourceNetwork = totals.fees.sourceNetwork.estimate.usd; - const targetNetwork = totals.fees.targetNetwork.usd; - return formatFiat( - new BigNumber(metaMask) - .plus(provider) - .plus(sourceNetwork) - .plus(targetNetwork), + new BigNumber(totals.fees.metaMask.usd ?? 0) + .plus(totals.fees.provider.usd) + .plus(totals.fees.sourceNetwork.estimate.usd) + .plus(totals.fees.targetNetwork.usd), ); }, [totals, formatFiat]); @@ -134,18 +129,13 @@ function Tooltip({ hasTransactionType(transactionMeta, [ TransactionType.predictDeposit, TransactionType.predictWithdraw, - TransactionType.perpsWithdraw, ]) ) { - if (hasTransactionType(transactionMeta, [TransactionType.perpsWithdraw])) { - message = strings('confirm.tooltip.perps_withdraw.transaction_fee'); - } else if ( - hasTransactionType(transactionMeta, [TransactionType.predictWithdraw]) - ) { - message = strings('confirm.tooltip.predict_withdraw.transaction_fee'); - } else { - message = strings('confirm.tooltip.predict_deposit.transaction_fee'); - } + message = hasTransactionType(transactionMeta, [ + TransactionType.predictWithdraw, + ]) + ? strings('confirm.tooltip.predict_withdraw.transaction_fee') + : strings('confirm.tooltip.predict_deposit.transaction_fee'); } if (hasTransactionType(transactionMeta, [TransactionType.musdConversion])) { diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts index 09dba85068a..9afea68a95a 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts @@ -268,7 +268,7 @@ describe('useInsufficientBalanceAlert', () => { expect(result.current[0].key).toBe(AlertKeys.InsufficientBalance); }); - it('returns empty array if transaction type is predictWithdraw', () => { + it('returns empty array if transaction type ignored', () => { mockUseTransactionMetadataRequest.mockReturnValue({ ...mockTransaction, type: TransactionType.predictWithdraw, @@ -279,17 +279,6 @@ describe('useInsufficientBalanceAlert', () => { expect(result.current).toStrictEqual([]); }); - it('returns empty array if transaction type is perpsWithdraw', () => { - mockUseTransactionMetadataRequest.mockReturnValue({ - ...mockTransaction, - type: TransactionType.perpsWithdraw, - } as unknown as TransactionMeta); - - const { result } = renderHook(() => useInsufficientBalanceAlert()); - - expect(result.current).toStrictEqual([]); - }); - it('returns empty array when using pay source amounts', () => { useTransactionPayHasSourceAmountMock.mockReturnValue(true); diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts index a42c6b09438..813c9eb69e3 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts @@ -16,10 +16,7 @@ import { selectUseTransactionSimulations } from '../../../../../selectors/prefer import { useHasInsufficientBalance } from '../useHasInsufficientBalance'; import { useIsTransactionPayLoading } from '../pay/useTransactionPayData'; -const IGNORE_TYPES = [ - TransactionType.perpsWithdraw, - TransactionType.predictWithdraw, -]; +const IGNORE_TYPES = [TransactionType.predictWithdraw]; export const useInsufficientBalanceAlert = ({ ignoreGasFeeToken, diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts index 0e8bc5aec45..a814749b891 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts @@ -268,29 +268,6 @@ describe('useTransactionPayMetrics', () => { }); }); - it('includes perps withdraw properties', async () => { - useTransactionPayTokenMock.mockReturnValue({ - payToken: PAY_TOKEN_MOCK, - setPayToken: noop, - } as ReturnType); - - useTransactionPayQuotesMock.mockReturnValue([QUOTE_MOCK]); - - runHook({ type: TransactionType.perpsWithdraw }); - - await act(async () => noop()); - - expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ - id: transactionIdMock, - params: { - properties: expect.objectContaining({ - mm_pay_use_case: 'perps_withdraw', - }), - sensitiveProperties: {}, - }, - }); - }); - it('includes dust property for non-native quote', async () => { useTransactionPayTokenMock.mockReturnValue({ payToken: PAY_TOKEN_MOCK, diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts index 77688c7dba1..809bcbd4a08 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts @@ -106,13 +106,6 @@ export function useTransactionPayMetrics() { properties.mm_pay_use_case = 'predict_withdraw'; } - if ( - payToken && - hasTransactionType(transactionMeta, [TransactionType.perpsWithdraw]) - ) { - properties.mm_pay_use_case = 'perps_withdraw'; - } - if (payToken) { const sendingAmountUsd = Number(primaryRequiredToken?.amountUsd ?? '0'); properties.mm_pay_sending_value_usd = sendingAmountUsd; diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.test.ts index 51678cbdec8..9da1a56aa96 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.test.ts @@ -1,6 +1,5 @@ import { renderHook } from '@testing-library/react-hooks'; import type { Hex } from '@metamask/utils'; -import { TransactionType } from '@metamask/transaction-controller'; import { useTransactionPayPostQuote } from './useTransactionPayPostQuote'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { useTransactionPayWithdraw } from './useTransactionPayWithdraw'; @@ -183,57 +182,4 @@ describe('useTransactionPayPostQuote', () => { expect(setTransactionConfigMock).toHaveBeenCalledTimes(2); }); - - it('sets isHyperliquidSource=true and no refundTo for perpsWithdraw', () => { - useTransactionMetadataRequestMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - txParams: { from: FROM_MOCK }, - type: TransactionType.perpsWithdraw, - } as never); - useTransactionPayWithdrawMock.mockReturnValue({ - isWithdraw: true, - canSelectWithdrawToken: true, - }); - - renderHook(() => useTransactionPayPostQuote()); - - const callback = setTransactionConfigMock.mock.calls[0][1]; - const config = {} as { - isPostQuote?: boolean; - refundTo?: Hex; - isHyperliquidSource?: boolean; - }; - callback(config); - - expect(config.isPostQuote).toBe(true); - expect(config.isHyperliquidSource).toBe(true); - expect(config.refundTo).toBeUndefined(); - expect(computeProxyAddressMock).not.toHaveBeenCalled(); - }); - - it('does not set isHyperliquidSource for non-perps withdrawals', () => { - useTransactionMetadataRequestMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - txParams: { from: FROM_MOCK }, - type: TransactionType.predictWithdraw, - } as never); - useTransactionPayWithdrawMock.mockReturnValue({ - isWithdraw: true, - canSelectWithdrawToken: true, - }); - - renderHook(() => useTransactionPayPostQuote()); - - const callback = setTransactionConfigMock.mock.calls[0][1]; - const config = {} as { - isPostQuote?: boolean; - refundTo?: Hex; - isHyperliquidSource?: boolean; - }; - callback(config); - - expect(config.isPostQuote).toBe(true); - expect(config.refundTo).toBe(PROXY_ADDRESS_MOCK); - expect(config.isHyperliquidSource).toBeUndefined(); - }); }); diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.ts index 2c69db22f97..99100868fb0 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayPostQuote.ts @@ -1,11 +1,9 @@ import { useEffect, useRef } from 'react'; import Engine from '../../../../../core/Engine'; import { createProjectLogger, type Hex } from '@metamask/utils'; -import { TransactionType } from '@metamask/transaction-controller'; import { useTransactionPayWithdraw } from './useTransactionPayWithdraw'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { computeProxyAddress } from '../../../../UI/Predict/providers/polymarket/safe/utils'; -import { hasTransactionType } from '../../utils/transaction'; const log = createProjectLogger('transaction-pay-post-quote'); @@ -27,9 +25,6 @@ export function useTransactionPayPostQuote(): void { const { canSelectWithdrawToken } = useTransactionPayWithdraw(); const transactionMeta = useTransactionMetadataRequest(); const transactionId = transactionMeta?.id; - const isPerpsWithdraw = hasTransactionType(transactionMeta, [ - TransactionType.perpsWithdraw, - ]); useEffect(() => { if ( @@ -43,44 +38,21 @@ export function useTransactionPayPostQuote(): void { try { const { TransactionPayController } = Engine.context; const from = transactionMeta?.txParams?.from as Hex | undefined; - - // Predict withdrawals refund to the Safe proxy address. - // Perps withdrawals don't use refundTo -- funds go HyperCore -> Relay directly. - const refundTo = isPerpsWithdraw - ? undefined - : from - ? computeProxyAddress(from) - : undefined; + const refundTo = from ? computeProxyAddress(from) : undefined; TransactionPayController.setTransactionConfig(transactionId, (config) => { config.isPostQuote = true; - - if (refundTo) { - config.refundTo = refundTo; - } - - if (isPerpsWithdraw) { - config.isHyperliquidSource = true; - } + config.refundTo = refundTo; }); isSet.current = transactionId; - log('Initialized post-quote transaction', { - transactionId, - refundTo, - isPerpsWithdraw, - }); + log('Initialized post-quote transaction', { transactionId, refundTo }); } catch (error) { log('Error initializing post-quote transaction', { error, transactionId, }); } - }, [ - canSelectWithdrawToken, - isPerpsWithdraw, - transactionId, - transactionMeta?.txParams?.from, - ]); + }, [canSelectWithdrawToken, transactionId, transactionMeta?.txParams?.from]); } diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts index ef291228aaa..b2bb0ac1e15 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts @@ -21,7 +21,6 @@ import { useMusdConfirmNavigation } from '../../../../UI/Earn/hooks/useMusdConfi const log = createProjectLogger('transaction-confirm'); export const GO_BACK_TYPES = [ - TransactionType.perpsWithdraw, TransactionType.predictClaim, TransactionType.predictDeposit, TransactionType.predictWithdraw, diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts index 5cc60535e67..e92a4bcf3af 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts @@ -445,48 +445,6 @@ describe('useTransactionCustomAmount', () => { expect(result.current.amountFiat).toBe('8642.46'); }); - it('to percentage of perps available balance', async () => { - (Engine.context as Record).PerpsController = { - state: { - accountState: { - availableBalance: '500.00', - }, - }, - }; - - const { result } = runHook({ - transactionMeta: { - type: TransactionType.perpsWithdraw, - }, - }); - - await act(async () => { - result.current.updatePendingAmountPercentage(50); - }); - - expect(result.current.amountFiat).toBe('250'); - }); - - it('returns 0 for perps withdraw when no available balance', async () => { - (Engine.context as Record).PerpsController = { - state: { - accountState: {}, - }, - }; - - const { result } = runHook({ - transactionMeta: { - type: TransactionType.perpsWithdraw, - }, - }); - - await act(async () => { - result.current.updatePendingAmountPercentage(100); - }); - - expect(result.current.amountFiat).toBe('0'); - }); - it('sets isMax to false when percentage is not 100 and isMaxAmount is true', async () => { useTransactionPayIsMaxAmountMock.mockReturnValue(true); diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts index 2b705750a9a..a7ea81c8c3f 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts @@ -13,13 +13,13 @@ import { useParams } from '../../../../../util/navigation/navUtils'; import { debounce } from 'lodash'; import { hasTransactionType } from '../../utils/transaction'; import { usePredictBalance } from '../../../../UI/Predict/hooks/usePredictBalance'; -import Engine from '../../../../../core/Engine'; import { useTransactionPayIsMaxAmount, useTransactionPayTotals, } from '../pay/useTransactionPayData'; import { useTransactionPayHasSourceAmount } from '../pay/useTransactionPayHasSourceAmount'; import { useConfirmationMetricEvents } from '../metrics/useConfirmationMetricEvents'; +import Engine from '../../../../../core/Engine'; export const MAX_LENGTH = 28; const DEBOUNCE_DELAY = 500; @@ -198,12 +198,6 @@ function useTokenBalance(tokenUsdRate: number) { .multipliedBy(tokenUsdRate) .toNumber(); - if (hasTransactionType(transactionMeta, [TransactionType.perpsWithdraw])) { - const perpsState = Engine.context.PerpsController?.state; - const availableBalance = perpsState?.accountState?.availableBalance; - return availableBalance ? parseFloat(availableBalance) : 0; - } - return hasTransactionType(transactionMeta, [TransactionType.predictWithdraw]) ? predictBalanceUsd : payTokenBalanceUsd; diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts index 02071fc9773..89918d75251 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts @@ -135,7 +135,6 @@ describe('ramps controller init', () => { isLoading: false, error: null, }, - providerAutoSelected: false, providers: { data: [], selected: null, diff --git a/app/core/SnapKeyring/KeyringSnapPermissions.test.ts b/app/core/SnapKeyring/KeyringSnapPermissions.test.ts index a3dbae7a0e2..473cc037125 100644 --- a/app/core/SnapKeyring/KeyringSnapPermissions.test.ts +++ b/app/core/SnapKeyring/KeyringSnapPermissions.test.ts @@ -19,7 +19,6 @@ describe('keyringSnapPermissionsBuilder', () => { subjectCacheLimit: 100, messenger: { registerActionHandler: jest.fn(), - registerMethodActionHandlers: jest.fn(), registerInitialEventPayload: jest.fn(), publish: jest.fn(), // TODO: Replace `any` with type diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 243e84b7d6e..cfac57977e4 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -65,7 +65,6 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "quotesLastFetched": null, "quotesLoadingStatus": null, "quotesRefreshCount": 0, - "tokenWarnings": [], }, "BridgeStatusController": { "txHistory": {}, @@ -653,7 +652,6 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "isLoading": false, "selected": null, }, - "providerAutoSelected": false, "providers": { "data": [], "error": null, @@ -886,7 +884,6 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "quotesLastFetched": null, "quotesLoadingStatus": null, "quotesRefreshCount": 0, - "tokenWarnings": [], }, "BridgeStatusController": { "txHistory": {}, @@ -1474,7 +1471,6 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "isLoading": false, "selected": null, }, - "providerAutoSelected": false, "providers": { "data": [], "error": null, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 6e8410c5364..cdb627b5918 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -624,8 +624,7 @@ "quotesInitialLoadTime": null, "quotesLastFetched": null, "quotesLoadingStatus": null, - "quotesRefreshCount": 0, - "tokenWarnings": [] + "quotesRefreshCount": 0 }, "BridgeStatusController": { "txHistory": {} @@ -749,7 +748,6 @@ "isLoading": false, "error": null }, - "providerAutoSelected": false, "providers": { "data": [], "selected": null, diff --git a/locales/languages/en.json b/locales/languages/en.json index b724e2df93b..456f78e0b05 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6534,9 +6534,6 @@ "predict_deposit": { "transaction_fee": "We'll swap your tokens for USDC.e on Polygon, the network used by Predictions. Swap providers may charge a fee, but MetaMask won't." }, - "perps_withdraw": { - "transaction_fee": "MetaMask will swap to your desired token for you. No MetaMask fee applies when you swap to mUSD." - }, "predict_withdraw": { "transaction_fee": "MetaMask will swap to your desired token for you. No MetaMask fee applies when you swap to mUSD." }, diff --git a/package.json b/package.json index a4248fd12a6..9e3b56428a4 100644 --- a/package.json +++ b/package.json @@ -312,7 +312,7 @@ "@metamask/superstruct": "^3.2.1", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/transaction-controller": "^63.3.0", - "@metamask/transaction-pay-controller": "^19.0.0", + "@metamask/transaction-pay-controller": "^17.1.0", "@metamask/tron-wallet-snap": "1.24.0", "@metamask/utils": "^11.8.1", "@myx-trade/sdk": "^0.1.265", diff --git a/yarn.lock b/yarn.lock index 1066b35803a..6e5d302e800 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7587,31 +7587,6 @@ __metadata: languageName: node linkType: hard -"@metamask/account-tree-controller@npm:^7.0.0": - version: 7.0.0 - resolution: "@metamask/account-tree-controller@npm:7.0.0" - dependencies: - "@metamask/accounts-controller": "npm:^37.1.1" - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/keyring-controller": "npm:^25.1.1" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/multichain-account-service": "npm:^8.0.1" - "@metamask/profile-sync-controller": "npm:^28.0.2" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.9.0" - fast-deep-equal: "npm:^3.1.3" - lodash: "npm:^4.17.21" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/d5383892f0962e7ea9d6215992a0a7de918cdf4009cc53f51baa0dae3d4f4e428d3ca3a6931e0919b3ac9ac9d1f1ec7f3ecae211bdafca00c34b1edd35046e27 - languageName: node - linkType: hard - "@metamask/accounts-controller@npm:37.0.0": version: 37.0.0 resolution: "@metamask/accounts-controller@npm:37.0.0" @@ -7642,15 +7617,15 @@ __metadata: languageName: node linkType: hard -"@metamask/address-book-controller@npm:^7.0.1, @metamask/address-book-controller@npm:^7.1.0, @metamask/address-book-controller@npm:^7.1.1": - version: 7.1.1 - resolution: "@metamask/address-book-controller@npm:7.1.1" +"@metamask/address-book-controller@npm:^7.0.1, @metamask/address-book-controller@npm:^7.1.0": + version: 7.1.0 + resolution: "@metamask/address-book-controller@npm:7.1.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/0e61958eab6c7f1472d270156398c819c648c9a3a093ed63abaadd7554330befa8149b929c8cb803f4b959fd0ceb71b42bf67b8680ea296f5e224df9b0216b44 + checksum: 10/0c2feddcfcd16e535bc6af2268917a8327ad4c54f6ae6c6df4cfe1ddcda2045e5984ae42e8cb7b9a32e7b5604bfcc70c72015b3756fe9773cb2d18542d33f5b4 languageName: node linkType: hard @@ -7707,113 +7682,92 @@ __metadata: languageName: node linkType: hard -"@metamask/approval-controller@npm:^9.0.0, @metamask/approval-controller@npm:^9.0.1": - version: 9.0.1 - resolution: "@metamask/approval-controller@npm:9.0.1" +"@metamask/approval-controller@npm:^9.0.0": + version: 9.0.0 + resolution: "@metamask/approval-controller@npm:9.0.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" nanoid: "npm:^3.3.8" - checksum: 10/980e7ded7022a887c11693226922f9814d160c93fe5297380addafebe9b6e9191ba3acc7bf54775c8c8eeb7e07bcfcaaf79cc90361ff18fa04c1d449eab2ed33 + checksum: 10/3eea0d1f291c159f096ed74d029531af529dc1e94bf1246ce3718bf91c11510fb3a52348eae5547b18af799213beee48f3cfe7d701909e9e527d6d4fe33e0152 languageName: node linkType: hard -"@metamask/assets-controller@npm:^3.0.0, @metamask/assets-controller@npm:^3.2.1": - version: 3.2.1 - resolution: "@metamask/assets-controller@npm:3.2.1" +"@metamask/assets-controller@npm:^2.4.0": + version: 2.4.0 + resolution: "@metamask/assets-controller@npm:2.4.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/account-tree-controller": "npm:^7.0.0" - "@metamask/assets-controllers": "npm:^103.0.0" - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/client-controller": "npm:^1.0.1" + "@metamask/account-tree-controller": "npm:^5.0.1" + "@metamask/assets-controllers": "npm:^101.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/client-controller": "npm:^1.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/core-backend": "npm:^6.2.1" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/keyring-controller": "npm:^25.1.1" + "@metamask/core-backend": "npm:^6.1.1" + "@metamask/keyring-api": "npm:^21.5.0" + "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/keyring-internal-api": "npm:^10.0.0" "@metamask/keyring-snap-client": "npm:^8.2.0" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/network-enablement-controller": "npm:^5.0.1" - "@metamask/permission-controller": "npm:^12.3.0" - "@metamask/polling-controller": "npm:^16.0.4" - "@metamask/preferences-controller": "npm:^23.1.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/transaction-controller": "npm:^63.3.1" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/network-enablement-controller": "npm:^5.0.0" + "@metamask/permission-controller": "npm:^12.2.1" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/preferences-controller": "npm:^23.0.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/snaps-utils": "npm:^11.7.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.1.2" lodash: "npm:^4.17.21" p-limit: "npm:^3.1.0" - checksum: 10/017cb5d9546e468ad9890686c077d062fbf68a5a28f49f5fab091e9fbdd51b26d61d2b8f171fd04e9fbdd7e8eea2317d17b101c05244c91fd935afddbbf21102 + checksum: 10/7c9d489736617508de3464539f1d2370b720c83d0b62e17ddd9593f8de0fbe2f1f97c65ff86c76793114b79f209c25d975ab169adf1a256ac5116d9f26e86db0 languageName: node linkType: hard -"@metamask/assets-controllers@npm:^101.0.1": - version: 101.0.1 - resolution: "@metamask/assets-controllers@npm:101.0.1" +"@metamask/assets-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "@metamask/assets-controller@npm:3.0.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/abi-utils": "npm:^2.0.3" "@metamask/account-tree-controller": "npm:^5.0.1" - "@metamask/accounts-controller": "npm:^37.0.0" - "@metamask/approval-controller": "npm:^9.0.0" + "@metamask/assets-controllers": "npm:^101.0.1" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/contract-metadata": "npm:^2.4.0" + "@metamask/client-controller": "npm:^1.0.0" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/core-backend": "npm:^6.2.0" - "@metamask/eth-query": "npm:^4.0.0" "@metamask/keyring-api": "npm:^21.5.0" "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/keyring-internal-api": "npm:^10.0.0" + "@metamask/keyring-snap-client": "npm:^8.2.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^7.1.0" "@metamask/network-controller": "npm:^30.0.0" "@metamask/network-enablement-controller": "npm:^5.0.0" "@metamask/permission-controller": "npm:^12.2.1" - "@metamask/phishing-controller": "npm:^17.0.0" "@metamask/polling-controller": "npm:^16.0.3" "@metamask/preferences-controller": "npm:^23.0.0" - "@metamask/profile-sync-controller": "npm:^28.0.0" - "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/snaps-controllers": "npm:^17.2.0" - "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" - "@metamask/storage-service": "npm:^1.0.0" "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" - "@types/bn.js": "npm:^5.1.5" - "@types/uuid": "npm:^8.3.0" async-mutex: "npm:^0.5.0" - bitcoin-address-validation: "npm:^2.2.3" - bn.js: "npm:^5.2.1" - immer: "npm:^9.0.6" + bignumber.js: "npm:^9.1.2" lodash: "npm:^4.17.21" - multiformats: "npm:^9.9.0" - reselect: "npm:^5.1.1" - single-call-balance-checker-abi: "npm:^1.0.0" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/183443bcc72fa08eabda0a7c7d853bc97fb18afb89fca9440fce4fcdd7bf5d34dfdaa3de7ea0497a9c5312faaf60565ad67f23ff2c5166ed732ba523701659e9 + p-limit: "npm:^3.1.0" + checksum: 10/8f5984c11b3efa899871a79d5017475c4622a9dd23a1a8983122dc6c75b46089d71b40808d7a7b29cb8acfa3c476bbfa8c49abecfbd64e11c7f82bfc790ba0d2 languageName: node linkType: hard -"@metamask/assets-controllers@npm:^103.0.0": - version: 103.0.0 - resolution: "@metamask/assets-controllers@npm:103.0.0" +"@metamask/assets-controllers@npm:^101.0.0, @metamask/assets-controllers@npm:^101.0.1": + version: 101.0.1 + resolution: "@metamask/assets-controllers@npm:101.0.1" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7822,32 +7776,32 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/account-tree-controller": "npm:^7.0.0" - "@metamask/accounts-controller": "npm:^37.1.1" - "@metamask/approval-controller": "npm:^9.0.1" - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/account-tree-controller": "npm:^5.0.1" + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/approval-controller": "npm:^9.0.0" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/contract-metadata": "npm:^2.4.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/core-backend": "npm:^6.2.1" + "@metamask/core-backend": "npm:^6.2.0" "@metamask/eth-query": "npm:^4.0.0" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/keyring-controller": "npm:^25.1.1" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/keyring-api": "npm:^21.5.0" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^8.0.1" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/network-enablement-controller": "npm:^5.0.1" - "@metamask/permission-controller": "npm:^12.3.0" - "@metamask/phishing-controller": "npm:^17.1.0" - "@metamask/polling-controller": "npm:^16.0.4" - "@metamask/preferences-controller": "npm:^23.1.0" - "@metamask/profile-sync-controller": "npm:^28.0.2" + "@metamask/multichain-account-service": "npm:^7.1.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/network-enablement-controller": "npm:^5.0.0" + "@metamask/permission-controller": "npm:^12.2.1" + "@metamask/phishing-controller": "npm:^17.0.0" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/preferences-controller": "npm:^23.0.0" + "@metamask/profile-sync-controller": "npm:^28.0.0" "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/storage-service": "npm:^1.0.1" - "@metamask/transaction-controller": "npm:^63.3.1" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/snaps-sdk": "npm:^10.3.0" + "@metamask/snaps-utils": "npm:^11.7.0" + "@metamask/storage-service": "npm:^1.0.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" @@ -7863,7 +7817,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/dfa1e0dcf91f94f365046086915aa52f004b43550bdd9f4c97f7a32da7a554f8c091c5d6457384710ea7b5f263720782b28a4d28c8600267b0f96f49737b72a5 + checksum: 10/183443bcc72fa08eabda0a7c7d853bc97fb18afb89fca9440fce4fcdd7bf5d34dfdaa3de7ea0497a9c5312faaf60565ad67f23ff2c5166ed732ba523701659e9 languageName: node linkType: hard @@ -7918,14 +7872,14 @@ __metadata: languageName: node linkType: hard -"@metamask/base-controller@npm:^9.0.0, @metamask/base-controller@npm:^9.0.1": - version: 9.0.1 - resolution: "@metamask/base-controller@npm:9.0.1" +"@metamask/base-controller@npm:^9.0.0": + version: 9.0.0 + resolution: "@metamask/base-controller@npm:9.0.0" dependencies: - "@metamask/messenger": "npm:^1.0.0" - "@metamask/utils": "npm:^11.9.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.8.1" immer: "npm:^9.0.6" - checksum: 10/bc5052c9a38c21a52003e9a79de1f609ff127d939c87eb7b9ebe01cdf05ce2a9ee8e4635dd96f193e9951983e9554d9381af303fbadaae740445ffb2424698e8 + checksum: 10/27554d34ec85c4b585b87850c90dfeaaf9c7e6430f2ab2fa80a1ec06ccc17641e118afab7ad765a0b7255ffef37bc9f6ca5065d459228a2dc660bc463293310d languageName: node linkType: hard @@ -7949,36 +7903,36 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^69.1.1, @metamask/bridge-controller@npm:^69.2.3": - version: 69.2.3 - resolution: "@metamask/bridge-controller@npm:69.2.3" +"@metamask/bridge-controller@npm:^69.1.1": + version: 69.1.1 + resolution: "@metamask/bridge-controller@npm:69.1.1" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" "@ethersproject/constants": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^37.1.1" - "@metamask/assets-controller": "npm:^3.2.1" - "@metamask/assets-controllers": "npm:^103.0.0" - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/assets-controller": "npm:^2.4.0" + "@metamask/assets-controllers": "npm:^101.0.0" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/gas-fee-controller": "npm:^26.1.1" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/gas-fee-controller": "npm:^26.1.0" + "@metamask/keyring-api": "npm:^21.5.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-network-controller": "npm:^3.0.6" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/polling-controller": "npm:^16.0.4" - "@metamask/profile-sync-controller": "npm:^28.0.2" - "@metamask/remote-feature-flag-controller": "npm:^4.2.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/transaction-controller": "npm:^63.3.1" + "@metamask/multichain-network-controller": "npm:^3.0.5" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/profile-sync-controller": "npm:^28.0.0" + "@metamask/remote-feature-flag-controller": "npm:^4.1.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" reselect: "npm:^5.1.1" uuid: "npm:^8.3.2" - checksum: 10/9a346e147101c18bf5d0f09695d4405c45f80149e301fa27f92b57a138058127cdd5036818312fe02c71611a10b375aaa19455a81748d96ad6e7b55c6756901e + checksum: 10/fac684a9caac65c336464affd8647d34ab0e4ccdddb3db95ffc741c454732df2eae52db6d9d6980ee04e38bd696d09e4c40fce30911bdea6c51fc5b91cf06320 languageName: node linkType: hard @@ -8005,29 +7959,6 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^70.0.3": - version: 70.0.3 - resolution: "@metamask/bridge-status-controller@npm:70.0.3" - dependencies: - "@metamask/accounts-controller": "npm:^37.1.1" - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/bridge-controller": "npm:^69.2.3" - "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/gas-fee-controller": "npm:^26.1.1" - "@metamask/keyring-controller": "npm:^25.1.1" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/polling-controller": "npm:^16.0.4" - "@metamask/profile-sync-controller": "npm:^28.0.2" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^63.3.1" - "@metamask/utils": "npm:^11.9.0" - bignumber.js: "npm:^9.1.2" - uuid: "npm:^8.3.2" - checksum: 10/d8a553dab473aa904244661253beabbbc936b7323f72d7973b3287fb48711cc19072305a6c9a83419b625bcb008cc471e9d4c510e7dd4ff368c9f75d13176b3b - languageName: node - linkType: hard - "@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A69.0.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch": version: 69.0.0 resolution: "@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A69.0.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch::version=69.0.0&hash=41006d" @@ -8101,13 +8032,13 @@ __metadata: languageName: node linkType: hard -"@metamask/client-controller@npm:^1.0.1": - version: 1.0.1 - resolution: "@metamask/client-controller@npm:1.0.1" +"@metamask/client-controller@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/client-controller@npm:1.0.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/messenger": "npm:^1.0.0" - checksum: 10/7a3db2c30c6217a018a7cc36cb40b0be21ec66d1816327619ca91e843657ea684a2194fd27a6d58cb2f9e6f5736e44277c9eb026e9606dd2ae522be68affc912 + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + checksum: 10/79a81ebae21fbe41cc110c5f8593751aebd64c3df98e23c4ec1b2129fa4d86d301afc3a8aa8ff65fc0c917f65c171aa6c90bce00753ec1378966cfca95f89d9c languageName: node linkType: hard @@ -8135,16 +8066,6 @@ __metadata: languageName: node linkType: hard -"@metamask/connectivity-controller@npm:^0.2.0": - version: 0.2.0 - resolution: "@metamask/connectivity-controller@npm:0.2.0" - dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/messenger": "npm:^1.0.0" - checksum: 10/586c80931341375c713aa5a474725ec88174e6e885d5f75d23b6458b734ec0e912a8c47996bea94a82f9549c9598ba982ac99aaeccac1fd92002c35a38f5397f - languageName: node - linkType: hard - "@metamask/contract-metadata@npm:^2.4.0": version: 2.5.0 resolution: "@metamask/contract-metadata@npm:2.5.0" @@ -8421,22 +8342,22 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-middleware@npm:^23.0.0, @metamask/eth-json-rpc-middleware@npm:^23.1.0, @metamask/eth-json-rpc-middleware@npm:^23.1.1": - version: 23.1.1 - resolution: "@metamask/eth-json-rpc-middleware@npm:23.1.1" +"@metamask/eth-json-rpc-middleware@npm:^23.0.0, @metamask/eth-json-rpc-middleware@npm:^23.1.0": + version: 23.1.0 + resolution: "@metamask/eth-json-rpc-middleware@npm:23.1.0" dependencies: "@metamask/eth-block-tracker": "npm:^15.0.1" - "@metamask/eth-json-rpc-provider": "npm:^6.0.1" + "@metamask/eth-json-rpc-provider": "npm:^6.0.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/json-rpc-engine": "npm:^10.2.4" - "@metamask/message-manager": "npm:^14.1.1" + "@metamask/json-rpc-engine": "npm:^10.2.1" + "@metamask/message-manager": "npm:^14.1.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" klona: "npm:^2.0.6" pify: "npm:^5.0.0" safe-stable-stringify: "npm:^2.4.3" - checksum: 10/229859120744e22cd0da50e3af316521ddb1f470dd4e2ae9797145d7694d0355d847f1453a1280788359d5cb5ba78bdfc6b038be85287918e7a530cbaf24d1d4 + checksum: 10/3d122e04edfc2c317b80f465dace316e4201dcc2c9e5d191bd46e62c5cd1396855685a2caed8e6189bb858ac91ddde59afb266b0028e12354e4b61ab499bc3e3 languageName: node linkType: hard @@ -8466,15 +8387,15 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-provider@npm:^6.0.0, @metamask/eth-json-rpc-provider@npm:^6.0.1": - version: 6.0.1 - resolution: "@metamask/eth-json-rpc-provider@npm:6.0.1" +"@metamask/eth-json-rpc-provider@npm:^6.0.0": + version: 6.0.0 + resolution: "@metamask/eth-json-rpc-provider@npm:6.0.0" dependencies: - "@metamask/json-rpc-engine": "npm:^10.2.4" + "@metamask/json-rpc-engine": "npm:^10.2.0" "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/utils": "npm:^11.9.0" + "@metamask/utils": "npm:^11.8.1" nanoid: "npm:^3.3.8" - checksum: 10/06078a9e43b02f35387a3ccfe09733c7eeac2a732dee1f1be53254fc05719e230776b8512b13702a178fb692088fc7da46f727c5064550d65f51ac59d44f9d83 + checksum: 10/ab7cf6139af7e5d2f26406c22651d4eb103a1fbc95f7274307a35878ae7ad26d51440b56575401286d5d4e57f4f39690c44d31b4088b64cf87ccf6c2d9322436 languageName: node linkType: hard @@ -8684,16 +8605,16 @@ __metadata: languageName: node linkType: hard -"@metamask/gas-fee-controller@npm:^26.0.2, @metamask/gas-fee-controller@npm:^26.0.3, @metamask/gas-fee-controller@npm:^26.1.0, @metamask/gas-fee-controller@npm:^26.1.1": - version: 26.1.1 - resolution: "@metamask/gas-fee-controller@npm:26.1.1" +"@metamask/gas-fee-controller@npm:^26.0.2, @metamask/gas-fee-controller@npm:^26.0.3, @metamask/gas-fee-controller@npm:^26.1.0": + version: 26.1.0 + resolution: "@metamask/gas-fee-controller@npm:26.1.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-unit": "npm:^0.3.0" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/polling-controller": "npm:^16.0.4" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/polling-controller": "npm:^16.0.3" "@metamask/utils": "npm:^11.9.0" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" @@ -8701,7 +8622,7 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@babel/runtime": ^7.0.0 - checksum: 10/6697364e4f624fee18c9b195003db1e551c572f63069f824bb9d48c0d968b862e8a9f6df6155cf5cc1227f055c7dce641dbf2cadad4df0ebbab6c9f358ce88f3 + checksum: 10/a376b8a6349461ef1aceda258af6d766832e3e89adde5dc9d0bf95d9624c498e76270ed06fd91a52aeffeb77c4d948fd742f38c721808a77383f8b39e3246359 languageName: node linkType: hard @@ -8759,9 +8680,9 @@ __metadata: languageName: node linkType: hard -"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.0, @metamask/json-rpc-engine@npm:^10.2.1, @metamask/json-rpc-engine@npm:^10.2.3, @metamask/json-rpc-engine@npm:^10.2.4": - version: 10.2.4 - resolution: "@metamask/json-rpc-engine@npm:10.2.4" +"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.0, @metamask/json-rpc-engine@npm:^10.2.1, @metamask/json-rpc-engine@npm:^10.2.2, @metamask/json-rpc-engine@npm:^10.2.3": + version: 10.2.3 + resolution: "@metamask/json-rpc-engine@npm:10.2.3" dependencies: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" @@ -8769,7 +8690,7 @@ __metadata: "@types/deep-freeze-strict": "npm:^1.1.0" deep-freeze-strict: "npm:^1.1.1" klona: "npm:^2.0.6" - checksum: 10/b207dd2a9a44674c141c2e027c082974464a37beada98a27e80fe59c9bd44e2c2a992edf8a8d7e3ed461fa27ed372c95d4e27df18752b558c10bf540b7fe7bcd + checksum: 10/8895ffcfc0dbf5542476dfd9771cb288feaf6fd7e9628e02c10232b3b8f0feabe3a0ad3e3480e3260a69aaafcf8f58d1d89410e7f43e97a08350b3ec3e767b1d languageName: node linkType: hard @@ -8811,7 +8732,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:^21.0.0, @metamask/keyring-api@npm:^21.4.0, @metamask/keyring-api@npm:^21.5.0, @metamask/keyring-api@npm:^21.6.0": +"@metamask/keyring-api@npm:^21.0.0, @metamask/keyring-api@npm:^21.2.0, @metamask/keyring-api@npm:^21.4.0, @metamask/keyring-api@npm:^21.5.0, @metamask/keyring-api@npm:^21.6.0": version: 21.6.0 resolution: "@metamask/keyring-api@npm:21.6.0" dependencies: @@ -8828,26 +8749,26 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^25.0.0, @metamask/keyring-controller@npm:^25.1.0, @metamask/keyring-controller@npm:^25.1.1": - version: 25.1.1 - resolution: "@metamask/keyring-controller@npm:25.1.1" +"@metamask/keyring-controller@npm:^25.0.0, @metamask/keyring-controller@npm:^25.1.0": + version: 25.1.0 + resolution: "@metamask/keyring-controller@npm:25.1.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/browser-passworder": "npm:^6.0.0" "@metamask/eth-hd-keyring": "npm:^13.0.0" "@metamask/eth-sig-util": "npm:^8.2.0" "@metamask/eth-simple-keyring": "npm:^11.0.0" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/keyring-internal-api": "npm:^10.0.0" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/keyring-api": "npm:^21.0.0" + "@metamask/keyring-internal-api": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" async-mutex: "npm:^0.5.0" ethereumjs-wallet: "npm:^1.0.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" ulid: "npm:^2.3.0" - checksum: 10/01fe91c90b12c083dafcb003e9df91e0746ab5b53df4559294006be7483c6bf720396200bc7ae1b6a88cdeb4f9d1478a4ee23816a48f35c50f24f51289d29ea9 + checksum: 10/e81fccb901ea3627b97e725a789832eb1e1f2ae61bfc00eaee6ce5717d65a39c73d6c683c8643b87de1ce6d98db76fc3e60004acb0a4b5ea21f05a404204f708 languageName: node linkType: hard @@ -8862,6 +8783,17 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-internal-api@npm:^9.0.0": + version: 9.1.1 + resolution: "@metamask/keyring-internal-api@npm:9.1.1" + dependencies: + "@metamask/keyring-api": "npm:^21.2.0" + "@metamask/keyring-utils": "npm:^3.1.0" + "@metamask/superstruct": "npm:^3.1.0" + checksum: 10/ab0fb8e153a02d3d0acf739d77356a1c60e0a7bf998dcbba9468f9f231605beaed472d8bff27dc56323d0a2529167336499e23dcad911fa8c3e37999ed14d2d1 + languageName: node + linkType: hard + "@metamask/keyring-internal-snap-client@npm:^9.0.0": version: 9.0.0 resolution: "@metamask/keyring-internal-snap-client@npm:9.0.0" @@ -8931,19 +8863,19 @@ __metadata: languageName: node linkType: hard -"@metamask/message-manager@npm:^14.1.0, @metamask/message-manager@npm:^14.1.1": - version: 14.1.1 - resolution: "@metamask/message-manager@npm:14.1.1" +"@metamask/message-manager@npm:^14.1.0": + version: 14.1.0 + resolution: "@metamask/message-manager@npm:14.1.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.16.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/utils": "npm:^11.9.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.8.1" "@types/uuid": "npm:^8.3.0" jsonschema: "npm:^1.4.1" uuid: "npm:^8.3.2" - checksum: 10/1e1ca365378e7ba762a150805121053d0360ae7230818ed48521702e2d7a32bc8233c3ef470c269fd4cbe16454674e328a5ebda22133ffd7b1190b04897e81d4 + checksum: 10/0bbea914096b9213fc16283dfe7e79436f2ea21946bcd717440071b7faf36d19a08fef122d5e91f000c586e7e5e909de99d1d2d0e76c1b1b3d42e45ff78474f3 languageName: node linkType: hard @@ -9028,36 +8960,6 @@ __metadata: languageName: node linkType: hard -"@metamask/multichain-account-service@npm:^8.0.1": - version: 8.0.1 - resolution: "@metamask/multichain-account-service@npm:8.0.1" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@metamask/accounts-controller": "npm:^37.1.1" - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/eth-snap-keyring": "npm:^19.0.0" - "@metamask/key-tree": "npm:^10.1.1" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/keyring-controller": "npm:^25.1.1" - "@metamask/keyring-internal-api": "npm:^10.0.0" - "@metamask/keyring-snap-client": "npm:^8.2.0" - "@metamask/keyring-utils": "npm:^3.1.0" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.9.0" - async-mutex: "npm:^0.5.0" - lodash: "npm:^4.17.21" - peerDependencies: - "@metamask/account-api": ^1.0.0 - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/7ac3c38db8afd47593cd7ed4cc95de99195ccd2b2903281382be83990b2a47fa2f03de77e325c1ea3c7fd96367e3eac20039994d95154d478bebacd61215140a - languageName: node - linkType: hard - "@metamask/multichain-api-client@npm:^0.10.1": version: 0.10.1 resolution: "@metamask/multichain-api-client@npm:0.10.1" @@ -9084,22 +8986,22 @@ __metadata: languageName: node linkType: hard -"@metamask/multichain-network-controller@npm:^3.0.4, @metamask/multichain-network-controller@npm:^3.0.5, @metamask/multichain-network-controller@npm:^3.0.6": - version: 3.0.6 - resolution: "@metamask/multichain-network-controller@npm:3.0.6" +"@metamask/multichain-network-controller@npm:^3.0.4, @metamask/multichain-network-controller@npm:^3.0.5": + version: 3.0.5 + resolution: "@metamask/multichain-network-controller@npm:3.0.5" dependencies: - "@metamask/accounts-controller": "npm:^37.1.0" - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/keyring-api": "npm:^21.6.0" + "@metamask/keyring-api": "npm:^21.5.0" "@metamask/keyring-internal-api": "npm:^10.0.0" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/network-controller": "npm:^30.0.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" "@solana/addresses": "npm:^2.0.0" lodash: "npm:^4.17.21" - checksum: 10/c7e937851b8c944b30c3eafa7d2cfd8d62df9a0278583933912f3c5e1c971f072c5c7e0882677cfe251efd3885e2a0a547c404bd7c2efcf6757c6601431b42a3 + checksum: 10/d1648a28ff412900e59bf3bab5e020ff4d67e899d0a91cf4447c8e2a3e658438ebf9f78d4625ee29f8ef7f552ee41b6d57525e4bd57f12aa21687bfcfe654ba5 languageName: node linkType: hard @@ -9191,20 +9093,20 @@ __metadata: languageName: node linkType: hard -"@metamask/network-controller@npm:^30.0.0, @metamask/network-controller@npm:^30.0.1": - version: 30.0.1 - resolution: "@metamask/network-controller@npm:30.0.1" +"@metamask/network-controller@npm:^30.0.0": + version: 30.0.0 + resolution: "@metamask/network-controller@npm:30.0.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/connectivity-controller": "npm:^0.2.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/connectivity-controller": "npm:^0.1.0" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/eth-block-tracker": "npm:^15.0.1" "@metamask/eth-json-rpc-infura": "npm:^10.3.0" - "@metamask/eth-json-rpc-middleware": "npm:^23.1.1" - "@metamask/eth-json-rpc-provider": "npm:^6.0.1" + "@metamask/eth-json-rpc-middleware": "npm:^23.1.0" + "@metamask/eth-json-rpc-provider": "npm:^6.0.0" "@metamask/eth-query": "npm:^4.0.0" - "@metamask/json-rpc-engine": "npm:^10.2.4" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/json-rpc-engine": "npm:^10.2.2" + "@metamask/messenger": "npm:^0.3.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/swappable-obj-proxy": "npm:^2.3.0" "@metamask/utils": "npm:^11.9.0" @@ -9215,7 +9117,7 @@ __metadata: reselect: "npm:^5.1.1" uri-js: "npm:^4.4.1" uuid: "npm:^8.3.2" - checksum: 10/8679db3ef1c1b39931c0b9133ff26eb55a4385e55e3519253cb51bed38b0ab46ea2e9112f689a7229f5cf0883135aef64d5719cb28870637d6a7c4f1e714d346 + checksum: 10/3f16a1be8f35995147580c23d5fa977c7ac5e231662ac43a75ea8bedea6b2039261bcfaac399a1a9f3dc310eed1f1f4896dcb0ff634add2597da519432789710 languageName: node linkType: hard @@ -9237,21 +9139,21 @@ __metadata: languageName: node linkType: hard -"@metamask/network-enablement-controller@npm:^5.0.0, @metamask/network-enablement-controller@npm:^5.0.1": - version: 5.0.1 - resolution: "@metamask/network-enablement-controller@npm:5.0.1" +"@metamask/network-enablement-controller@npm:^5.0.0": + version: 5.0.0 + resolution: "@metamask/network-enablement-controller@npm:5.0.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/keyring-api": "npm:^21.6.0" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/multichain-network-controller": "npm:^3.0.6" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/keyring-api": "npm:^21.5.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/multichain-network-controller": "npm:^3.0.5" + "@metamask/network-controller": "npm:^30.0.0" "@metamask/slip44": "npm:^4.3.0" - "@metamask/transaction-controller": "npm:^63.3.1" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" reselect: "npm:^5.1.1" - checksum: 10/bd674ed536ad001fead7414e95736234bfc9d9aa1756882fd1c6cf331604bc4d5906e92cf5505957657aa85c2224d190b04da74fe02d9eb1f433360122652a70 + checksum: 10/8741db7961c7e4c5a08a46653407b0e147b194b0fc3009fa2959d47b0306c7d1715673211964e34991884f966a61a759d2a3c3d2bf7ca93323661f92d85fb187 languageName: node linkType: hard @@ -9318,22 +9220,22 @@ __metadata: languageName: node linkType: hard -"@metamask/permission-controller@npm:^12.0.0, @metamask/permission-controller@npm:^12.1.1, @metamask/permission-controller@npm:^12.2.0, @metamask/permission-controller@npm:^12.2.1, @metamask/permission-controller@npm:^12.3.0": - version: 12.3.0 - resolution: "@metamask/permission-controller@npm:12.3.0" +"@metamask/permission-controller@npm:^12.0.0, @metamask/permission-controller@npm:^12.1.1, @metamask/permission-controller@npm:^12.2.0, @metamask/permission-controller@npm:^12.2.1": + version: 12.2.1 + resolution: "@metamask/permission-controller@npm:12.2.1" dependencies: - "@metamask/approval-controller": "npm:^9.0.1" - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/approval-controller": "npm:^9.0.0" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/json-rpc-engine": "npm:^10.2.4" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/json-rpc-engine": "npm:^10.2.3" + "@metamask/messenger": "npm:^0.3.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" "@types/deep-freeze-strict": "npm:^1.1.0" deep-freeze-strict: "npm:^1.1.1" immer: "npm:^9.0.6" nanoid: "npm:^3.3.8" - checksum: 10/a5fe9f2bab8c2d41cd829cd6c1af970e71da97eac42de17071c10f90d975e9135a4e6987ed6b2f3ea2209b1c6c51b822508f800225fda2207cdc598c16ea77dd + checksum: 10/610ed3acb63ca256592319c6f775e8888102c06304e46a95faf75abe898f0bf715a6254c6784a3964c0c379082cb7f1d1acfcf7db4af9bae9797f662944c3ebc languageName: node linkType: hard @@ -9354,35 +9256,35 @@ __metadata: languageName: node linkType: hard -"@metamask/phishing-controller@npm:^17.0.0, @metamask/phishing-controller@npm:^17.1.0": - version: 17.1.0 - resolution: "@metamask/phishing-controller@npm:17.1.0" +"@metamask/phishing-controller@npm:^17.0.0": + version: 17.0.0 + resolution: "@metamask/phishing-controller@npm:17.0.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/transaction-controller": "npm:^63.3.1" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@noble/hashes": "npm:^1.8.0" "@types/punycode": "npm:^2.1.0" ethereum-cryptography: "npm:^2.1.2" fastest-levenshtein: "npm:^1.0.16" punycode: "npm:^2.1.1" - checksum: 10/e320a060b482296e4b8820c98e5266fee9080a31666a1320338c22e95b2aeb785574d523afabc6df9aa516a2e2267ea261e42856030a929f197c9edd36bc57c5 + checksum: 10/a1917ad63feb5c6287b7a191f78750d6455239909b0df5d07a965279638ccccb67de73d2f3cbe5596252e14b394565978bb86aa52e0adf388059d031531d0e93 languageName: node linkType: hard -"@metamask/polling-controller@npm:^16.0.0, @metamask/polling-controller@npm:^16.0.3, @metamask/polling-controller@npm:^16.0.4": - version: 16.0.4 - resolution: "@metamask/polling-controller@npm:16.0.4" +"@metamask/polling-controller@npm:^16.0.0, @metamask/polling-controller@npm:^16.0.3": + version: 16.0.3 + resolution: "@metamask/polling-controller@npm:16.0.3" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/network-controller": "npm:^30.0.0" "@metamask/utils": "npm:^11.9.0" "@types/uuid": "npm:^8.3.0" fast-json-stable-stringify: "npm:^2.1.0" uuid: "npm:^8.3.2" - checksum: 10/c656f78f010103c65ae2018e75ef2c51c3b915bc9dd2624bdd7b06a327704a2428d0d0ec78c2570425cc611e7a7b85bfcaa9f0f0d2d16cfbc686e0e9fe3f29c2 + checksum: 10/31182b6d62fa949bf8bee834a65aba819e52ce77c208faebb33f6e3982834e87877e29d2d93952264988ea309d110b003adf69f358dc5820eeab7ab3c09f924f languageName: node linkType: hard @@ -9396,13 +9298,13 @@ __metadata: languageName: node linkType: hard -"@metamask/preferences-controller@npm:^23.0.0, @metamask/preferences-controller@npm:^23.1.0": - version: 23.1.0 - resolution: "@metamask/preferences-controller@npm:23.1.0" +"@metamask/preferences-controller@npm:^23.0.0": + version: 23.0.0 + resolution: "@metamask/preferences-controller@npm:23.0.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/messenger": "npm:^1.0.0" - checksum: 10/61fe1115546ea0c1e45143c2227d7907930ae881c3f14fe6bfb260d9d4e6fdfc84e3f46af69454daeef7dcbb7d02fdb204baf0c29bf05e18212479b6e96721a0 + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + checksum: 10/3dd5aa99cf781ffdc364d577536f009eaa0cdb57e8ae85ae6d311b51a358986194d8f745280518f86f1bc4b41094a89162ffad8b6c544178c0d892fe430ebf10 languageName: node linkType: hard @@ -9457,17 +9359,17 @@ __metadata: languageName: node linkType: hard -"@metamask/profile-sync-controller@npm:^28.0.0, @metamask/profile-sync-controller@npm:^28.0.2": - version: 28.0.2 - resolution: "@metamask/profile-sync-controller@npm:28.0.2" +"@metamask/profile-sync-controller@npm:^28.0.0": + version: 28.0.0 + resolution: "@metamask/profile-sync-controller@npm:28.0.0" dependencies: - "@metamask/address-book-controller": "npm:^7.1.1" - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/keyring-controller": "npm:^25.1.1" - "@metamask/messenger": "npm:^1.0.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" + "@metamask/address-book-controller": "npm:^7.0.1" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/snaps-sdk": "npm:^10.3.0" + "@metamask/snaps-utils": "npm:^11.7.0" "@metamask/utils": "npm:^11.9.0" "@noble/ciphers": "npm:^1.3.0" "@noble/hashes": "npm:^1.8.0" @@ -9477,7 +9379,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/37cd8032f673436ff7a7b759287731691e864fb94b8a8b16f819de6956652bb51c4b020c1df5213113899feb5c36f03c98a35d8dce3629370725f94964ba5585 + checksum: 10/008f66cea003cbf4d6d8b827daf7e943ff2b1ef9ec7bcbc749a8a57860c410d44e624149a8400e82034055ffcd2a74a760a633592b558188916dfe360b1287df languageName: node linkType: hard @@ -9523,14 +9425,14 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:^12.0.1, @metamask/ramps-controller@npm:^12.1.0": - version: 12.1.0 - resolution: "@metamask/ramps-controller@npm:12.1.0" +"@metamask/ramps-controller@npm:^12.0.1": + version: 12.0.1 + resolution: "@metamask/ramps-controller@npm:12.0.1" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/messenger": "npm:^1.0.0" - checksum: 10/ae1f3f4cb4dd4493ed4d75220a54ce8e5878b16e3b31dbafdf5ae0256cf3f9b1ef9aad146e61609485c4a682a119fd0fdadc2489862bd6d1c480878f2dc3c409 + "@metamask/messenger": "npm:^0.3.0" + checksum: 10/a7f9428cb824bd0175ee1cc603d77c650fa7a23c7183e2cc0a0f21ee9b6378d80bbd1e496654e40d2edcfc840e60dd4a09d80feacb1746087a67b66761e1e6c7 languageName: node linkType: hard @@ -9616,16 +9518,16 @@ __metadata: languageName: node linkType: hard -"@metamask/remote-feature-flag-controller@npm:^4.0.0, @metamask/remote-feature-flag-controller@npm:^4.1.0, @metamask/remote-feature-flag-controller@npm:^4.2.0": - version: 4.2.0 - resolution: "@metamask/remote-feature-flag-controller@npm:4.2.0" +"@metamask/remote-feature-flag-controller@npm:^4.0.0, @metamask/remote-feature-flag-controller@npm:^4.1.0": + version: 4.1.0 + resolution: "@metamask/remote-feature-flag-controller@npm:4.1.0" dependencies: - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" uuid: "npm:^8.3.2" - checksum: 10/bb8d4cf6d90cb895d994d471d53429ed816c2b60aef85f62b0731536cc2822cda3795b16ed5bd77cf87a934bcecda7a021024f4c224d5cda866d717db76d3400 + checksum: 10/30122c316e788adc2abb6875eefef189946e2af469c1b217f8617ade17693666cde896e043fcb2a65874b2e62d4499b05456345dd2425dee6f9ea92f1f2d12e3 languageName: node linkType: hard @@ -9886,49 +9788,6 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-controllers@npm:^19.0.0": - version: 19.0.0 - resolution: "@metamask/snaps-controllers@npm:19.0.0" - dependencies: - "@metamask/approval-controller": "npm:^9.0.0" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/json-rpc-engine": "npm:^10.2.3" - "@metamask/json-rpc-middleware-stream": "npm:^8.0.8" - "@metamask/key-tree": "npm:^10.1.1" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/object-multiplex": "npm:^2.1.0" - "@metamask/permission-controller": "npm:^12.2.1" - "@metamask/post-message-stream": "npm:^10.0.0" - "@metamask/rpc-errors": "npm:^7.0.3" - "@metamask/snaps-registry": "npm:^4.0.0" - "@metamask/snaps-rpc-methods": "npm:^15.0.1" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/storage-service": "npm:^1.0.0" - "@metamask/superstruct": "npm:^3.2.1" - "@metamask/utils": "npm:^11.10.0" - "@xstate/fsm": "npm:^2.0.0" - async-mutex: "npm:^0.5.0" - concat-stream: "npm:^2.0.0" - cron-parser: "npm:^4.5.0" - fast-deep-equal: "npm:^3.1.3" - get-npm-tarball-url: "npm:^2.0.3" - immer: "npm:^9.0.21" - luxon: "npm:^3.5.0" - nanoid: "npm:^3.3.10" - readable-stream: "npm:^3.6.2" - readable-web-to-node-stream: "npm:^3.0.2" - semver: "npm:^7.5.4" - tar-stream: "npm:^3.1.7" - peerDependencies: - "@metamask/snaps-execution-environments": ^11.0.2 - peerDependenciesMeta: - "@metamask/snaps-execution-environments": - optional: true - checksum: 10/95d4522877aee8d320ace7de396255a827efab6b63ee81a4dfa34d595d65c3e429d586f6895aa70e170201b907b6bf3c7fb33f5bd683768873f92f58817792d3 - languageName: node - linkType: hard - "@metamask/snaps-execution-environments@npm:^11.0.1": version: 11.0.1 resolution: "@metamask/snaps-execution-environments@npm:11.0.1" @@ -9976,20 +9835,20 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-rpc-methods@npm:^15.0.0, @metamask/snaps-rpc-methods@npm:^15.0.1": - version: 15.0.1 - resolution: "@metamask/snaps-rpc-methods@npm:15.0.1" +"@metamask/snaps-rpc-methods@npm:^15.0.0": + version: 15.0.0 + resolution: "@metamask/snaps-rpc-methods@npm:15.0.0" dependencies: "@metamask/key-tree": "npm:^10.1.1" - "@metamask/permission-controller": "npm:^12.2.1" + "@metamask/permission-controller": "npm:^12.2.0" "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" + "@metamask/snaps-utils": "npm:^12.1.1" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.10.0" "@noble/hashes": "npm:^1.7.1" async-mutex: "npm:^0.5.0" - checksum: 10/40353ead6a12def2cb301fd4fc35c8dfb6783fc4d8ebc52ad2b9d6453d64f1c0f69a619d1e3c240250542c44cfea7f2fd0461ff73907c3327588fc1c409b942e + checksum: 10/178db2fa6cc4fced381bc5b034fc2d2ac465d63f143255c53118fb1d3aa98381d88e5c3a87ccfce75bf8689972da3087258734fc83ec9483d4296034fbac21e7 languageName: node linkType: hard @@ -10038,15 +9897,15 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-utils@npm:^12.1.0, @metamask/snaps-utils@npm:^12.1.1, @metamask/snaps-utils@npm:^12.1.2": - version: 12.1.2 - resolution: "@metamask/snaps-utils@npm:12.1.2" +"@metamask/snaps-utils@npm:^12.1.0, @metamask/snaps-utils@npm:^12.1.1": + version: 12.1.1 + resolution: "@metamask/snaps-utils@npm:12.1.1" dependencies: "@babel/core": "npm:^7.23.2" "@babel/types": "npm:^7.23.0" "@metamask/key-tree": "npm:^10.1.1" "@metamask/messenger": "npm:^0.3.0" - "@metamask/permission-controller": "npm:^12.2.1" + "@metamask/permission-controller": "npm:^12.2.0" "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/slip44": "npm:^4.4.0" "@metamask/snaps-registry": "npm:^4.0.0" @@ -10058,14 +9917,14 @@ __metadata: cron-parser: "npm:^4.5.0" fast-deep-equal: "npm:^3.1.3" fast-json-stable-stringify: "npm:^2.1.0" - fast-xml-parser: "npm:^5.5.6" + fast-xml-parser: "npm:^5.3.8" luxon: "npm:^3.5.0" marked: "npm:^12.0.1" rfdc: "npm:^1.3.0" semver: "npm:^7.5.4" ses: "npm:^1.15.0" validate-npm-package-name: "npm:^5.0.0" - checksum: 10/cf36670f9946e2ab737d7fd1fd5be5c0e915c66b57e76d5f4bfc509061420f79b442b353c52a94c5f953fceba75f910ebd512592956a62e831f4eb43d0b0a40f + checksum: 10/acaefe9d78766e7af7c939a7fd6a80ba4f4d7559862b2594d831d00054eb9c05537332c8f2d912ff5170732aceef42a359431f37794e8f2f4fd23151a1b6e271 languageName: node linkType: hard @@ -10097,13 +9956,13 @@ __metadata: languageName: node linkType: hard -"@metamask/storage-service@npm:^1.0.0, @metamask/storage-service@npm:^1.0.1": - version: 1.0.1 - resolution: "@metamask/storage-service@npm:1.0.1" +"@metamask/storage-service@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/storage-service@npm:1.0.0" dependencies: - "@metamask/messenger": "npm:^1.0.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/3ec18b85ae80d13c4928be327abb1ee0548a6c44afdb7f709434a6621c876c3de95e145ca2603bdf178772982c76f546ec1cac58f28c0a9c74e020342d171349 + checksum: 10/506b681f9f678102f8dd700d3c0531a35894d2a810431bdbcaaf1089d6dcfdb869ee3118b0375012498ba20e4fe8d2682d2695082268bb1dab3b774c9044d329 languageName: node linkType: hard @@ -10251,9 +10110,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^63.0.0, @metamask/transaction-controller@npm:^63.3.0, @metamask/transaction-controller@npm:^63.3.1": - version: 63.3.1 - resolution: "@metamask/transaction-controller@npm:63.3.1" +"@metamask/transaction-controller@npm:^63.0.0, @metamask/transaction-controller@npm:^63.3.0": + version: 63.3.0 + resolution: "@metamask/transaction-controller@npm:63.3.0" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -10262,18 +10121,18 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@ethersproject/wallet": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^37.1.0" - "@metamask/approval-controller": "npm:^9.0.1" - "@metamask/base-controller": "npm:^9.0.1" + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/approval-controller": "npm:^9.0.0" + "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/core-backend": "npm:^6.2.1" + "@metamask/core-backend": "npm:^6.2.0" "@metamask/eth-query": "npm:^4.0.0" - "@metamask/gas-fee-controller": "npm:^26.1.1" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/gas-fee-controller": "npm:^26.1.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/network-controller": "npm:^30.0.0" "@metamask/nonce-tracker": "npm:^6.0.0" - "@metamask/remote-feature-flag-controller": "npm:^4.2.0" + "@metamask/remote-feature-flag-controller": "npm:^4.1.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" async-mutex: "npm:^0.5.0" @@ -10286,7 +10145,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/0e661e59a00595258d01a3de9dbee7529899234f2f10d315cbfd92cccfdc692af5c3f573b8dcbe7b2fc05a35e060c9f6b5f573cb38d046657b7fb79a4d24d6dc + checksum: 10/e9616ee54fad77bc5df47f4dd41aad3f423174b505c8a743f0097fdd87d5a6d5d5a3223fca16a940f306d0c4d918c76e345e00a7e76a646a44e00d12f88e15fb languageName: node linkType: hard @@ -10329,32 +10188,31 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^19.0.0": - version: 19.0.0 - resolution: "@metamask/transaction-pay-controller@npm:19.0.0" +"@metamask/transaction-pay-controller@npm:^17.1.0": + version: 17.1.0 + resolution: "@metamask/transaction-pay-controller@npm:17.1.0" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^3.2.1" - "@metamask/assets-controllers": "npm:^103.0.0" - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/bridge-controller": "npm:^69.2.3" - "@metamask/bridge-status-controller": "npm:^70.0.3" + "@metamask/assets-controller": "npm:^2.4.0" + "@metamask/assets-controllers": "npm:^101.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/bridge-controller": "npm:^69.1.1" + "@metamask/bridge-status-controller": "npm:^69.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/gas-fee-controller": "npm:^26.1.1" - "@metamask/messenger": "npm:^1.0.0" + "@metamask/gas-fee-controller": "npm:^26.1.0" + "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/ramps-controller": "npm:^12.1.0" - "@metamask/remote-feature-flag-controller": "npm:^4.2.0" - "@metamask/transaction-controller": "npm:^63.3.1" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/remote-feature-flag-controller": "npm:^4.1.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/121bbc55501b4e9e9ab633356637e2098ffa0aa8745b768ef04902067629e143cd495d7a222ae0e51f051b956d34349200424337a5cba4d456c2b417cbb0c355 + checksum: 10/79d8cb54d010551c63cd34f5dd9d4d0b3fab3186ae770133a573c528b16598c2421241219801a4c81bf3dc38805c95b5c8a680bfe5d070dece50cf4dac891799 languageName: node linkType: hard @@ -29758,12 +29616,10 @@ __metadata: languageName: node linkType: hard -"fast-xml-builder@npm:^1.1.4": - version: 1.1.4 - resolution: "fast-xml-builder@npm:1.1.4" - dependencies: - path-expression-matcher: "npm:^1.1.3" - checksum: 10/32937866aaf5a90e69d1f4ee6e15e875248d5b5d2afd70277e9e8323074de4980cef24575a591b8e43c29f405d5f12377b3bad3842dc412b0c5c17a3eaee4b6b +"fast-xml-builder@npm:^1.0.0": + version: 1.0.0 + resolution: "fast-xml-builder@npm:1.0.0" + checksum: 10/06c04d80545e5c9f4d1d6cca00567b5cc09953a92c6328fa48cfb4d7f42630313b8c2bb62e9cb81accee7bb5e1c5312fcae06c3d20dbe52d969a5938233316da languageName: node linkType: hard @@ -29778,16 +29634,15 @@ __metadata: languageName: node linkType: hard -"fast-xml-parser@npm:^5.3.3, fast-xml-parser@npm:^5.5.6": - version: 5.5.9 - resolution: "fast-xml-parser@npm:5.5.9" +"fast-xml-parser@npm:^5.3.3, fast-xml-parser@npm:^5.3.8": + version: 5.4.2 + resolution: "fast-xml-parser@npm:5.4.2" dependencies: - fast-xml-builder: "npm:^1.1.4" - path-expression-matcher: "npm:^1.2.0" - strnum: "npm:^2.2.2" + fast-xml-builder: "npm:^1.0.0" + strnum: "npm:^2.1.2" bin: fxparser: src/cli/cli.js - checksum: 10/5f1a1a8b524406af21e9adb24f846b0da6b629c86b1eeedb54757cc293c24ed4f79ff9570b82206265b6951d68acd2dc93e74687ea5d7da0beafa09536cee73f + checksum: 10/12585d5dd77113411d01cf41818cfecbbaf8f3d9e8448b1c35f50a7eb51205408bc8db27af5733173a77f96f72d7e121d9e675674f71334569157c77845aba39 languageName: node linkType: hard @@ -35808,7 +35663,7 @@ __metadata: "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/transaction-controller": "npm:^63.3.0" - "@metamask/transaction-pay-controller": "npm:^19.0.0" + "@metamask/transaction-pay-controller": "npm:^17.1.0" "@metamask/tron-wallet-snap": "npm:1.24.0" "@metamask/utils": "npm:^11.8.1" "@myx-trade/sdk": "npm:^0.1.265" @@ -38847,13 +38702,6 @@ __metadata: languageName: node linkType: hard -"path-expression-matcher@npm:^1.1.3, path-expression-matcher@npm:^1.2.0": - version: 1.2.0 - resolution: "path-expression-matcher@npm:1.2.0" - checksum: 10/eab23babd9a97d6cf4841a99825c3e990b70b2b29ea6529df9fb6a1f3953befbc68e9e282a373d7a75aff5dc6542d05a09ee2df036ff9bfddf5e1627b769875b - languageName: node - linkType: hard - "path-extra@npm:^1.0.2": version: 1.0.3 resolution: "path-extra@npm:1.0.3" @@ -44604,10 +44452,10 @@ __metadata: languageName: node linkType: hard -"strnum@npm:^2.2.2": - version: 2.2.2 - resolution: "strnum@npm:2.2.2" - checksum: 10/c55813cfded750dc84556b4881ffc7cee91382ff15a48f1fba0ff7a678e1640ed96ca40806fbd55724940fd7d51cf752469b2d862e196e4adefb6c7d5d9cd73b +"strnum@npm:^2.1.2": + version: 2.2.0 + resolution: "strnum@npm:2.2.0" + checksum: 10/2969dbc8441f5af1b55db1d2fcea64a8f912de18515b57f85574e66bdb8f30ae76c419cf1390b343d72d687e2aea5aca82390f18b9e0de45d6bcc6d605eb9385 languageName: node linkType: hard From b67ef7f9876b84f43318dace80312b09f108effa Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Mon, 30 Mar 2026 15:34:42 -0300 Subject: [PATCH 4/7] refactor(ramp): migrate Ramp UI primitives to design system (phase 1) (#28048) ## **Description** This PR migrates Ramp **Buy** UI under `app/components/UI/Ramp/Views/` and `app/components/UI/Ramp/components/` (excluding Deposit/Aggregator-only flows where noted) from `app/component-library` primitives to **`@metamask/design-system-react-native`** for `Text`, `Icon`, `Button`, and related enums (`TextVariant`, `TextColor`, `IconColor`, `FontWeight`, `ButtonVariant`, etc.). **Motivation:** Align Ramp with the MetaMask mobile design system for consistent typography, icons, and buttons. **Scope notes:** - **Deferred** (follow-up): Bottom sheets, Toast wiring, composite list rows (avatars/badges), and some `BannerAlert` usages remain on component-library where a full migration would require larger sheet/toast work. - **Cross-touch:** Minimal updates in Deposit `ConfigurationModal`, `TokenSelectorModal`, and Aggregator `SettingsModal` for shared types (e.g. `IconName`/`IconColor` for toasts and `MenuItem`). - **BuildQuote amount:** Hero amount uses the same **dynamic font scaling** pattern as `PredictAmountDisplay` (`getFontSizeForInputLength`, `lineHeight = fontSize + 10`, `tracking-tight`) with **regular** weight for a lighter display. ## **Changelog** CHANGELOG entry: Updated Ramp on-ramp screens to use the MetaMask design system for typography, icons, and primary actions. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Ramp buy flow design system migration Scenario: Build quote amount displays and keypad still work Given the user opens Buy and reaches the amount (Build Quote) screen When they enter an amount using the keypad and optional quick amounts Then the amount displays with readable typography and quotes continue to load as before Scenario: Settings and payment flows still open Given the user is on the Build Quote screen When they open settings or payment selection where migrated components are used Then screens render without crashes and primary actions match expected behavior ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **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] > **Medium Risk** > Medium risk because it broadly changes rendering primitives (Text/Icon/Button), style tokens, and toast/icon enums across critical Ramp flows, which can introduce UI regressions even if logic is mostly unchanged. > > **Overview** > Updates Ramp Buy/Deposit UI to use `@metamask/design-system-react-native` primitives and tokens for `Text`, `Icon`, `Button`, and related enums, including updating tests/mocks/snapshots to match the new render output. > > Refines the `BuildQuote` hero amount display by introducing `getFontSizeForInputLength`-based dynamic sizing (and cursor sizing) instead of `adjustsFontSizeToFit`, and switches some color/variant mappings to design-system equivalents while keeping Toast icon color enums on the component-library where required. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fd362d78719e46dbc7730fda8d1328052a40d17b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/Modals/Settings/SettingsModal.tsx | 2 +- .../__snapshots__/SettingsModal.test.tsx.snap | 84 +- .../ConfigurationModal.test.tsx | 5 +- .../ConfigurationModal/ConfigurationModal.tsx | 14 +- .../ConfigurationModal.test.tsx.snap | 117 +- .../TokenSelectorModal/TokenSelectorModal.tsx | 11 +- .../TokenSelectorModal.test.tsx.snap | 180 +- .../Views/BuildQuote/BuildQuote.styles.ts | 9 +- .../UI/Ramp/Views/BuildQuote/BuildQuote.tsx | 39 +- .../__snapshots__/BuildQuote.test.tsx.snap | 820 ++- .../getFontSizeForInputLength.test.ts | 22 + .../BuildQuote/getFontSizeForInputLength.ts | 22 + .../ErrorDetailsModal/ErrorDetailsModal.tsx | 13 +- .../ErrorDetailsModal.test.tsx.snap | 75 +- .../QuoteDisplay.test.tsx | 31 +- .../PaymentSelectionModal/QuoteDisplay.tsx | 7 +- .../SettingsModal/SettingsModal.test.tsx | 5 +- .../Modals/SettingsModal/SettingsModal.tsx | 14 +- .../__snapshots__/SettingsModal.test.tsx.snap | 66 +- .../NativeFlow/AdditionalVerification.tsx | 31 +- .../UI/Ramp/Views/NativeFlow/BankDetails.tsx | 68 +- .../UI/Ramp/Views/NativeFlow/BasicInfo.tsx | 43 +- .../UI/Ramp/Views/NativeFlow/EnterAddress.tsx | 26 +- .../UI/Ramp/Views/NativeFlow/EnterEmail.tsx | 38 +- .../Ramp/Views/NativeFlow/KycProcessing.tsx | 59 +- .../Ramp/Views/NativeFlow/OrderProcessing.tsx | 33 +- .../UI/Ramp/Views/NativeFlow/OtpCode.tsx | 39 +- .../Ramp/Views/NativeFlow/VerifyIdentity.tsx | 54 +- .../AdditionalVerification.test.tsx.snap | 161 +- .../__snapshots__/BankDetails.test.tsx.snap | 1975 +++++--- .../__snapshots__/BasicInfo.test.tsx.snap | 564 ++- .../__snapshots__/EnterAddress.test.tsx.snap | 312 +- .../__snapshots__/EnterEmail.test.tsx.snap | 139 +- .../__snapshots__/KycProcessing.test.tsx.snap | 48 +- .../OrderProcessing.test.tsx.snap | 100 +- .../__snapshots__/OtpCode.test.tsx.snap | 340 +- .../VerifyIdentity.test.tsx.snap | 287 +- .../Ramp/Views/OrderDetails/OrderContent.tsx | 24 +- .../Ramp/Views/OrderDetails/OrderDetails.tsx | 17 +- .../RegionSelector/RegionSelector.tsx | 90 +- .../RegionSelector.test.tsx.snap | 4413 ++++++++++------- .../Views/TokenSelection/TokenSelection.tsx | 14 +- .../TokenSelection.test.tsx.snap | 1989 +++++--- .../EligibilityFailedModal.tsx | 37 +- .../EligibilityFailedModal.test.tsx.snap | 232 +- .../components/MenuItem/MenuItem.test.tsx | 2 +- .../UI/Ramp/components/MenuItem/MenuItem.tsx | 24 +- .../__snapshots__/MenuItem.test.tsx.snap | 168 +- .../PaymentMethodPill/PaymentMethodPill.tsx | 32 +- .../PaymentMethodPill.test.tsx.snap | 99 +- .../components/QuickAmounts/QuickAmounts.tsx | 2 +- .../RampUnsupportedModal.tsx | 30 +- .../RampUnsupportedModal.test.tsx.snap | 132 +- .../TokenListItem/TokenListItem.tsx | 20 +- .../__snapshots__/TokenListItem.test.tsx.snap | 72 +- .../TokenNetworkFilterBar.styles.ts | 3 - .../TokenNetworkFilterBar.tsx | 66 +- .../TokenNetworkFilterBar.test.tsx.snap | 1611 ++++-- .../TruncatedError/TruncatedError.tsx | 21 +- .../TruncatedError.test.tsx.snap | 212 +- 60 files changed, 9650 insertions(+), 5513 deletions(-) create mode 100644 app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.test.ts create mode 100644 app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.ts diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx index 37c7426971a..d6f9d54ec9d 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/SettingsModal.tsx @@ -5,7 +5,7 @@ import BottomSheet, { BottomSheetRef, } from '../../../../../../../component-library/components/BottomSheets/BottomSheet'; import BottomSheetHeader from '../../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import { IconName } from '../../../../../../../component-library/components/Icons/Icon'; +import { IconName } from '@metamask/design-system-react-native'; import Routes from '../../../../../../../constants/navigation/Routes'; import { createNavigationDetails } from '../../../../../../../util/navigation/navUtils'; import MenuItem from '../../../../components/MenuItem'; diff --git a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap index e74adcf69d6..56d468e0e5e 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/Modals/Settings/__snapshots__/SettingsModal.test.tsx.snap @@ -565,17 +565,18 @@ exports[`SettingsModal renders snapshot correctly 1`] = ` testID="listitemcolumn" > View order history @@ -652,17 +657,18 @@ exports[`SettingsModal renders snapshot correctly 1`] = ` testID="listitemcolumn" > More ways to buy @@ -699,13 +709,17 @@ exports[`SettingsModal renders snapshot correctly 1`] = ` Switch to the new version diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx index e29aec970d3..55e2c1b7a68 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Linking } from 'react-native'; +import { IconColor } from '../../../../../../../component-library/components/Icons/Icon'; import ConfigurationModal from './ConfigurationModal'; import { renderScreen } from '../../../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../../../util/test/initial-root-state'; @@ -194,7 +195,7 @@ describe('ConfigurationModal', () => { variant: 'Icon', labelOptions: [{ label: 'Successfully logged out' }], iconName: 'CheckBold', - iconColor: 'Success', + iconColor: IconColor.Success, hasNoTimeout: false, }); }); @@ -215,7 +216,7 @@ describe('ConfigurationModal', () => { variant: 'Icon', labelOptions: [{ label: 'Error logging out' }], iconName: 'CircleX', - iconColor: 'Error', + iconColor: IconColor.Error, hasNoTimeout: false, }); }); diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx index 5e04149a32c..06de34cb0fb 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/ConfigurationModal.tsx @@ -3,9 +3,10 @@ import { Linking } from 'react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../../../component-library/components/BottomSheets/BottomSheet'; +import { IconName } from '@metamask/design-system-react-native'; import { - IconName, - IconColor, + IconName as ComponentLibraryIconName, + IconColor as ComponentLibraryIconColor, } from '../../../../../../../component-library/components/Icons/Icon'; import { createNavigationDetails } from '../../../../../../../util/navigation/navUtils'; @@ -87,8 +88,9 @@ function ConfigurationModal() { labelOptions: [ { label: strings('deposit.configuration_modal.logged_out_success') }, ], - iconName: IconName.CheckBold, - iconColor: IconColor.Success, + iconName: ComponentLibraryIconName.CheckBold, + // Toast still renders component-library Icon; use its IconColor enum, not DS tokens. + iconColor: ComponentLibraryIconColor.Success, hasNoTimeout: false, }); } catch (error) { @@ -98,8 +100,8 @@ function ConfigurationModal() { labelOptions: [ { label: strings('deposit.configuration_modal.logged_out_error') }, ], - iconName: IconName.CircleX, - iconColor: IconColor.Error, + iconName: ComponentLibraryIconName.CircleX, + iconColor: ComponentLibraryIconColor.Error, hasNoTimeout: false, }); } diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap index 192f369902b..0616ccc2771 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/Modals/ConfigurationModal/__snapshots__/ConfigurationModal.test.tsx.snap @@ -565,17 +565,18 @@ exports[`ConfigurationModal render matches snapshot 1`] = ` testID="listitemcolumn" > View order history @@ -652,17 +657,18 @@ exports[`ConfigurationModal render matches snapshot 1`] = ` testID="listitemcolumn" > Contact support @@ -739,17 +749,18 @@ exports[`ConfigurationModal render matches snapshot 1`] = ` testID="listitemcolumn" > More ways to buy @@ -786,13 +801,17 @@ exports[`ConfigurationModal render matches snapshot 1`] = ` Switch to the classic version diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx index 804fa2adc88..31229485397 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/TokenSelectorModal.tsx @@ -31,7 +31,7 @@ import { useDepositCryptoCurrencyNetworkName } from '../../../hooks/useDepositCr import { DepositCryptoCurrency } from '@consensys/native-ramps-sdk'; import Routes from '../../../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../../../locales/i18n'; -import { useTheme } from '../../../../../../../util/theme'; +import { TextColor } from '@metamask/design-system-react-native'; import useAnalytics from '../../../../hooks/useAnalytics'; import { getRampRoutingDecision } from '../../../../../../../reducers/fiatOrders'; @@ -58,7 +58,6 @@ function TokenSelectorModal() { screenHeight, }); - const { colors } = useTheme(); const trackEvent = useAnalytics(); const getNetworkName = useDepositCryptoCurrencyNetworkName(); const rampRoutingDecision = useSelector(getRampRoutingDecision); @@ -140,14 +139,10 @@ function TokenSelectorModal() { token={token} isSelected={selectedCryptoCurrency?.assetId === token.assetId} onPress={() => handleSelectAssetIdCallback(token.assetId)} - textColor={colors.text.alternative} + textColor={TextColor.TextAlternative} /> ), - [ - colors.text.alternative, - handleSelectAssetIdCallback, - selectedCryptoCurrency?.assetId, - ], + [handleSelectAssetIdCallback, selectedCryptoCurrency?.assetId], ); const renderEmptyList = useCallback( diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/__snapshots__/TokenSelectorModal.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/__snapshots__/TokenSelectorModal.test.tsx.snap index 08d15c5992c..1f0b32370c8 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/__snapshots__/TokenSelectorModal.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/__snapshots__/TokenSelectorModal.test.tsx.snap @@ -3347,13 +3347,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] USD Coin @@ -3361,13 +3365,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] USDC @@ -3578,13 +3586,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] Tether USD @@ -3592,13 +3604,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] USDT @@ -3794,13 +3810,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] Bitcoin @@ -3808,13 +3828,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] BTC @@ -4010,13 +4034,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] Ethereum @@ -4024,13 +4052,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] ETH @@ -4226,13 +4258,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] USD Coin @@ -4240,13 +4276,17 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] USDC diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.styles.ts b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.styles.ts index 361d8438c30..5409b498cb1 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.styles.ts +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.styles.ts @@ -13,12 +13,6 @@ const styleSheet = (params: { theme: Theme }) => { gap: 16, flex: 1, }, - mainAmount: { - textAlign: 'center', - fontSize: 64, - lineHeight: 64 + 8, - fontWeight: '400', - }, amountContainer: { alignItems: 'center', gap: 16, @@ -29,9 +23,8 @@ const styleSheet = (params: { theme: Theme }) => { }, cursor: { width: 2, - height: 48, marginHorizontal: 1, - marginBottom: 12, + alignSelf: 'center', backgroundColor: theme.colors.primary.default, }, actionSection: { diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index b1460c3f7b1..85b3733c69f 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -26,11 +26,11 @@ import { reportRampsError } from '../../utils/reportRampsError'; import Keypad, { type KeypadChangeData, Keys } from '../../../../Base/Keypad'; import PaymentMethodPill from '../../components/PaymentMethodPill'; import QuickAmounts from '../../components/QuickAmounts'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; -import { + FontWeight, Button, ButtonVariant, ButtonSize, @@ -42,6 +42,7 @@ import HeaderCompactStandard from '../../../../../component-library/components-t import Routes from '../../../../../constants/navigation/Routes'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './BuildQuote.styles'; +import { getFontSizeForInputLength } from './getFontSizeForInputLength'; import { useFormatters } from '../../../../hooks/useFormatters'; import { useTokenNetworkInfo } from '../../hooks/useTokenNetworkInfo'; import { @@ -312,6 +313,13 @@ function BuildQuote() { }, [currency, formatCurrency]); const quickAmounts = userRegion?.country?.quickAmounts ?? [50, 100, 200, 400]; + const amountDisplayString = useMemo( + () => `${currencyPrefix}${amount}${currencySuffix}`, + [currencyPrefix, currencySuffix, amount], + ); + const amountFontSize = getFontSizeForInputLength(amountDisplayString.length); + const amountLineHeight = amountFontSize + 10; + /* * Tracks RAMPS_SCREEN_VIEWED * @returns {void} @@ -829,31 +837,38 @@ function BuildQuote() { {currencyPrefix} {amount} {currencySuffix ? ( {currencySuffix} @@ -906,7 +921,7 @@ function BuildQuote() { ) : ( selectedProvider && ( {strings('fiat_on_ramp.powered_by_provider', { diff --git a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index b6df390f00e..2ea1337f785 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -298,16 +298,20 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when quote is n @@ -318,6 +322,7 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when quote is n collapsable={false} style={ { + "height": 66, "opacity": 1, } } @@ -326,74 +331,56 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when quote is n - + Debit/Credit Card - + @@ -416,19 +403,24 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when quote is n numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > An unexpected error occurred. @@ -447,17 +439,18 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when quote is n onPress={[Function]} > @@ -1004,16 +997,20 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakChe @@ -1024,6 +1021,7 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakChe collapsable={false} style={ { + "height": 66, "opacity": 1, } } @@ -1032,74 +1030,56 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakChe - + Debit/Credit Card - + @@ -1122,19 +1102,24 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakChe numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > Network error @@ -1153,17 +1138,18 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakChe onPress={[Function]} > @@ -1710,16 +1696,20 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakRou @@ -1730,6 +1720,7 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakRou collapsable={false} style={ { + "height": 66, "opacity": 1, } } @@ -1738,74 +1729,56 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakRou - + Debit/Credit Card - + @@ -1828,19 +1801,24 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakRou numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > Routing failed @@ -1859,17 +1837,18 @@ exports[`BuildQuote handleNativeProviderContinue sets rampsError when transakRou onPress={[Function]} > @@ -2416,16 +2395,20 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg @@ -2436,6 +2419,7 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg collapsable={false} style={ { + "height": 66, "opacity": 1, } } @@ -2444,74 +2428,56 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg - + Debit/Credit Card - + @@ -2534,19 +2500,24 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > No widget URL available for provider @@ -2565,17 +2536,18 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg onPress={[Function]} > @@ -3122,16 +3094,20 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg @@ -3142,6 +3118,7 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg collapsable={false} style={ { + "height": 66, "opacity": 1, } } @@ -3150,74 +3127,56 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg - + Debit/Credit Card - + @@ -3240,19 +3199,24 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > Network request failed @@ -3271,17 +3235,18 @@ exports[`BuildQuote handleWidgetProviderContinue sets rampsError when getBuyWidg onPress={[Function]} > @@ -3828,16 +3793,20 @@ exports[`BuildQuote quoteFetchError tracks RAMPS_QUOTE_ERROR and shows BannerAle @@ -3848,6 +3817,7 @@ exports[`BuildQuote quoteFetchError tracks RAMPS_QUOTE_ERROR and shows BannerAle collapsable={false} style={ { + "height": 66, "opacity": 1, } } @@ -3856,74 +3826,56 @@ exports[`BuildQuote quoteFetchError tracks RAMPS_QUOTE_ERROR and shows BannerAle - + Debit/Credit Card - + @@ -3991,13 +3943,17 @@ exports[`BuildQuote quoteFetchError tracks RAMPS_QUOTE_ERROR and shows BannerAle Powered by MoonPay diff --git a/app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.test.ts b/app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.test.ts new file mode 100644 index 00000000000..d1e9a476cc9 --- /dev/null +++ b/app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.test.ts @@ -0,0 +1,22 @@ +import { getFontSizeForInputLength } from './getFontSizeForInputLength'; + +describe('getFontSizeForInputLength', () => { + it.each([ + [0, 60], + [7, 60], + [8, 60], + [9, 48], + [10, 48], + [11, 32], + [12, 32], + [13, 24], + [14, 24], + [15, 18], + [17, 18], + [18, 18], + [19, 12], + [30, 12], + ])('returns correct size for content length %i', (length, expected) => { + expect(getFontSizeForInputLength(length)).toBe(expected); + }); +}); diff --git a/app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.ts b/app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.ts new file mode 100644 index 00000000000..8f6e786ee4e --- /dev/null +++ b/app/components/UI/Ramp/Views/BuildQuote/getFontSizeForInputLength.ts @@ -0,0 +1,22 @@ +/** + * Matches Predict amount display scaling — see PredictAmountDisplay.tsx + * (getFontSizeForInputLength + lineHeight = fontSize + 10). + */ +export function getFontSizeForInputLength(contentLength: number): number { + if (contentLength <= 8) { + return 60; + } + if (contentLength <= 10) { + return 48; + } + if (contentLength <= 12) { + return 32; + } + if (contentLength <= 14) { + return 24; + } + if (contentLength <= 18) { + return 18; + } + return 12; +} diff --git a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.tsx b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.tsx index 38e3a4b62b0..021be32772f 100644 --- a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.tsx @@ -10,17 +10,16 @@ import { Button, ButtonVariant, ButtonBaseSize, + Icon, + IconName, + IconSize, + IconColor, } from '@metamask/design-system-react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; -import Icon, { - IconName, - IconSize, - IconColor, -} from '../../../../../../component-library/components/Icons/Icon'; -import { useStyles } from '../../../../../../component-library/hooks'; +import { useStyles } from '../../../../../hooks/useStyles'; import { createNavigationDetails, useParams, @@ -102,7 +101,7 @@ function ErrorDetailsModal() { {strings('deposit.errors.error_details_title')} diff --git a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap index acdb5568719..b24160cd985 100644 --- a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap @@ -358,17 +358,18 @@ exports[`ErrorDetailsModal renders correctly and matches snapshot 1`] = ` } > { - const { View } = jest.requireActual('react-native'); + const ReactActual = jest.requireActual('react'); + const { View } = + jest.requireActual('react-native'); return { - Skeleton: ({ width, height }: { width: number; height: number }) => ( - - ), + Skeleton: ({ width, height }: { width: number; height: number }) => + ReactActual.createElement(View, { + testID: 'skeleton', + style: { width, height }, + }), }; }); -jest.mock('../../../../../../component-library/components/Icons/Icon', () => { - const { View } = jest.requireActual('react-native'); +jest.mock('@metamask/design-system-react-native', () => { + const ReactActual = jest.requireActual('react'); + const { View } = + jest.requireActual('react-native'); + const actual = jest.requireActual< + typeof import('@metamask/design-system-react-native') + >('@metamask/design-system-react-native'); return { - __esModule: true, - IconName: { Warning: 'Warning' }, - IconSize: { Sm: '16' }, - IconColor: { Warning: 'Warning' }, - default: ({ testID }: { testID?: string }) => ( - - ), + ...actual, + Icon: ({ testID }: { testID?: string }) => + ReactActual.createElement(View, { testID: testID ?? 'icon' }), }; }); diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/QuoteDisplay.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/QuoteDisplay.tsx index 4eb25dea5a8..d04fe87a740 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/QuoteDisplay.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/QuoteDisplay.tsx @@ -6,12 +6,11 @@ import { TextVariant, TextColor, FontWeight, -} from '@metamask/design-system-react-native'; -import Icon, { + Icon, IconName, IconSize, IconColor, -} from '../../../../../../component-library/components/Icons/Icon'; +} from '@metamask/design-system-react-native'; import { Skeleton } from '../../../../../../component-library/components/Skeleton'; import { strings } from '../../../../../../../locales/i18n'; @@ -68,7 +67,7 @@ const QuoteDisplay: React.FC = ({ ); diff --git a/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.test.tsx b/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.test.tsx index e80ea5a968b..807e30fa915 100644 --- a/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { IconColor } from '../../../../../../component-library/components/Icons/Icon'; import SettingsModal from './SettingsModal'; import InAppBrowser from 'react-native-inappbrowser-reborn'; import { renderScreen } from '../../../../../../util/test/renderWithProvider'; @@ -291,7 +292,7 @@ describe('SettingsModal', () => { variant: 'Icon', labelOptions: [{ label: 'Successfully logged out' }], iconName: 'CheckBold', - iconColor: 'Success', + iconColor: IconColor.Success, hasNoTimeout: false, }); }); @@ -315,7 +316,7 @@ describe('SettingsModal', () => { variant: 'Icon', labelOptions: [{ label: 'Error logging out' }], iconName: 'CircleX', - iconColor: 'Error', + iconColor: IconColor.Error, hasNoTimeout: false, }); }); diff --git a/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx b/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx index 31d2560c975..297117e06ed 100644 --- a/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx @@ -9,9 +9,10 @@ import InAppBrowser from 'react-native-inappbrowser-reborn'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; +import { IconName } from '@metamask/design-system-react-native'; import { - IconName, - IconColor, + IconName as ComponentLibraryIconName, + IconColor as ComponentLibraryIconColor, } from '../../../../../../component-library/components/Icons/Icon'; import { createNavigationDetails } from '../../../../../../util/navigation/navUtils'; import Routes from '../../../../../../constants/navigation/Routes'; @@ -166,8 +167,9 @@ function SettingsModal() { ), }, ], - iconName: IconName.CheckBold, - iconColor: IconColor.Success, + iconName: ComponentLibraryIconName.CheckBold, + // Toast still renders component-library Icon; use its IconColor enum, not DS tokens. + iconColor: ComponentLibraryIconColor.Success, hasNoTimeout: false, }); } catch (error) { @@ -181,8 +183,8 @@ function SettingsModal() { ), }, ], - iconName: IconName.CircleX, - iconColor: IconColor.Error, + iconName: ComponentLibraryIconName.CircleX, + iconColor: ComponentLibraryIconColor.Error, hasNoTimeout: false, }); } diff --git a/app/components/UI/Ramp/Views/Modals/SettingsModal/__snapshots__/SettingsModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/SettingsModal/__snapshots__/SettingsModal.test.tsx.snap index 876cadc62ae..b3de2e506ca 100644 --- a/app/components/UI/Ramp/Views/Modals/SettingsModal/__snapshots__/SettingsModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/SettingsModal/__snapshots__/SettingsModal.test.tsx.snap @@ -610,17 +610,18 @@ exports[`SettingsModal render matches snapshot 1`] = ` testID="listitemcolumn" > View order history @@ -697,17 +702,18 @@ exports[`SettingsModal render matches snapshot 1`] = ` testID="listitemcolumn" > Contact support diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx index 00a4377856f..20eb987884b 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx @@ -1,20 +1,20 @@ import React, { useCallback } from 'react'; import { Image } from 'react-native'; -import Text from '../../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../../component-library/hooks'; +import { + Text, + TextVariant, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from '../../Deposit/Views/AdditionalVerification/AdditionalVerification.styles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { getDepositNavbarOptions } from '../../../Navbar'; import { useNavigation } from '@react-navigation/native'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import additionalVerificationImage from '../../Deposit/assets/additional-verification.png'; import { strings } from '../../../../../../locales/i18n'; -import { TextVariant } from '../../../../../component-library/components/Texts/Text/Text.types'; import { useTransakRouting } from '../../hooks/useTransakRouting'; import { useParams } from '../../../../../util/navigation/navUtils'; import type { TransakBuyQuote } from '@metamask/ramps-controller'; @@ -61,14 +61,14 @@ const V2AdditionalVerification = () => { resizeMode={'contain'} style={styles.image} /> - + {strings('deposit.additional_verification.title')} - + {strings('deposit.additional_verification.paragraph_1')} - + {strings('deposit.additional_verification.paragraph_2')} @@ -78,10 +78,11 @@ const V2AdditionalVerification = () => { diff --git a/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx b/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx index f28632c910f..c6cd5adb5ec 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx @@ -5,18 +5,22 @@ import styleSheet from '../../Deposit/Views/BankDetails/BankDetails.styles'; import { useNavigation } from '@react-navigation/native'; import { useParams } from '../../../../../util/navigation/navUtils'; import Routes from '../../../../../constants/navigation/Routes'; -import { useStyles } from '../../../../../component-library/hooks'; +import { useStyles } from '../../../../hooks/useStyles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { getDepositNavbarOptions } from '../../../Navbar'; import { strings } from '../../../../../../locales/i18n'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; -import Icon, { + Icon, IconName, IconSize, -} from '../../../../../component-library/components/Icons/Icon'; + IconColor, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import Loader from '../../../../../component-library/components-temp/Loader/Loader'; import BankDetailRow from '../../Deposit/components/BankDetailRow'; import { @@ -25,10 +29,6 @@ import { normalizeProviderCode, } from '@metamask/ramps-controller'; import { useTheme } from '../../../../../util/theme'; -import Button, { - ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; import PrivacySection from '../../Deposit/components/PrivacySection'; import useAnalytics from '../../hooks/useAnalytics'; @@ -327,13 +327,19 @@ const V2BankDetails = () => { - + {strings('deposit.bank_details.main_title')} - + {strings('deposit.bank_details.main_content_1')} - + {strings('deposit.bank_details.main_content_2')} @@ -420,7 +426,10 @@ const V2BankDetails = () => { style={styles.showBankInfoButton} onPress={toggleBankInfo} > - + {showBankInfo ? strings('deposit.bank_details.hide_bank_info') : strings('deposit.bank_details.show_bank_info')} @@ -428,7 +437,7 @@ const V2BankDetails = () => { @@ -443,18 +452,21 @@ const V2BankDetails = () => { {confirmPaymentError ? ( - + {confirmPaymentError} ) : null} {cancelOrderError ? ( - + {strings('deposit.bank_details.cancel_order_error')} ) : null} - + {strings('deposit.bank_details.info_banner_text', { accountHolderName: accountName, })} @@ -464,24 +476,26 @@ const V2BankDetails = () => { diff --git a/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx b/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx index 48156f32b8b..1bc9436310c 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx @@ -2,9 +2,17 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Keyboard, TextInput, TouchableOpacity, View } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import { useNavigation } from '@react-navigation/native'; -import Text, { +import { + Text, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; + Icon, + IconName, + IconSize, + IconColor, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { getDepositNavbarOptions } from '../../../Navbar'; import { useStyles } from '../../../../hooks/useStyles'; @@ -18,16 +26,6 @@ import DepositProgressBar from '../../Deposit/components/DepositProgressBar'; import DepositDateField from '../../Deposit/components/DepositDateField'; import { VALIDATION_REGEX } from '../../Deposit/constants/constants'; import { formatNumberToTemplate } from '../../Deposit/components/DepositPhoneField/formatNumberToTemplate'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; -import Icon, { - IconColor, - IconName, - IconSize, -} from '../../../../../component-library/components/Icons/Icon'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; import PrivacySection from '../../Deposit/components/PrivacySection'; import { timestampToTransakFormat } from '../../Deposit/utils'; @@ -35,6 +33,8 @@ import useAnalytics from '../../hooks/useAnalytics'; import Logger from '../../../../../util/Logger'; import BannerAlert from '../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert'; import { BannerAlertSeverity } from '../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types'; +import { ButtonVariants } from '../../../../../component-library/components/Buttons/Button'; +import { TextVariant as ComponentLibraryTextVariant } from '../../../../../component-library/components/Texts/Text/Text.types'; import { useTransakController } from '../../hooks/useTransakController'; import { useRampsUserRegion } from '../../hooks/useRampsUserRegion'; import type { TransakBuyQuote } from '@metamask/ramps-controller'; @@ -322,7 +322,7 @@ const V2BasicInfo = (): JSX.Element => { > - + {strings('deposit.basic_info.title')} @@ -339,7 +339,7 @@ const V2BasicInfo = (): JSX.Element => { variant: ButtonVariants.Link, label: strings('deposit.basic_info.login_with_email'), onPress: handleLogout, - labelTextVariant: TextVariant.BodyMD, + labelTextVariant: ComponentLibraryTextVariant.BodyMD, testID: BASIC_INFO_TEST_IDS.LOGOUT_BUTTON, } : undefined @@ -449,7 +449,7 @@ const V2BasicInfo = (): JSX.Element => { - + {strings('deposit.basic_info.social_security_number')} { @@ -490,13 +490,14 @@ const V2BasicInfo = (): JSX.Element => { diff --git a/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx b/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx index 170a75b94b6..6596cdaf163 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx @@ -2,9 +2,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { View, TextInput, Keyboard } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; -import Text, { +import { + Text, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { getDepositNavbarOptions } from '../../../Navbar'; import { useStyles } from '../../../../hooks/useStyles'; @@ -15,11 +19,6 @@ import DepositTextField from '../../Deposit/components/DepositTextField'; import { useForm } from '../../Deposit/hooks/useForm'; import DepositProgressBar from '../../Deposit/components/DepositProgressBar'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import PrivacySection from '../../Deposit/components/PrivacySection'; import { VALIDATION_REGEX } from '../../Deposit/constants/constants'; import Logger from '../../../../../util/Logger'; @@ -228,7 +227,7 @@ const V2EnterAddress = (): JSX.Element => { - + {strings('deposit.enter_address.title')} @@ -356,13 +355,14 @@ const V2EnterAddress = (): JSX.Element => { diff --git a/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx index 50fe451ed5f..8b365ba66b5 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx @@ -1,9 +1,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { TextInput, View } from 'react-native'; -import Text, { +import { + Text, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../../component-library/hooks'; + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from '../../Deposit/Views/EnterEmail/EnterEmail.styles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { @@ -18,11 +22,6 @@ import { getDepositNavbarOptions } from '../../../Navbar'; import { createV2OtpCodeNavDetails } from './OtpCode'; import { validateEmail } from '../../Deposit/utils'; import DepositProgressBar from '../../Deposit/components/DepositProgressBar/DepositProgressBar'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; import Logger from '../../../../../util/Logger'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; @@ -142,10 +141,10 @@ const V2EnterEmail = () => { - + {strings('deposit.enter_email.title')} - + {strings('deposit.enter_email.description')} @@ -165,12 +164,16 @@ const V2EnterEmail = () => { /> {validationError && ( - + {strings('deposit.enter_email.validation_error')} )} - {error && {error}} + {error && ( + + {error} + + )} @@ -181,12 +184,13 @@ const V2EnterEmail = () => { testID={EnterEmailSelectorsIDs.SEND_EMAIL_BUTTON} size={ButtonSize.Lg} onPress={handleSubmit} - label={strings('deposit.enter_email.submit_button')} - variant={ButtonVariants.Primary} - width={ButtonWidthTypes.Full} - loading={isLoading} + variant={ButtonVariant.Primary} + isFullWidth + isLoading={isLoading} isDisabled={isLoading} - /> + > + {strings('deposit.enter_email.submit_button')} + diff --git a/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx b/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx index 5b39160a9ea..029655dd1bd 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx @@ -4,23 +4,22 @@ import styleSheet from '../../Deposit/Views/KycProcessing/KycProcessing.styles'; import { useNavigation } from '@react-navigation/native'; import DepositProgressBar from '../../Deposit/components/DepositProgressBar'; import { useParams } from '../../../../../util/navigation/navUtils'; -import { useStyles } from '../../../../../component-library/hooks'; +import { useStyles } from '../../../../hooks/useStyles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { getDepositNavbarOptions } from '../../../Navbar'; import { strings } from '../../../../../../locales/i18n'; -import Text, { +import { + Text, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import Icon, { + Icon, IconName, IconSize, IconColor, -} from '../../../../../component-library/components/Icons/Icon'; -import Button, { + Button, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; + ButtonVariant, + FontWeight, +} from '@metamask/design-system-react-native'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; import { KycStatus } from '../../Deposit/constants'; import Logger from '../../../../../util/Logger'; @@ -174,12 +173,12 @@ const V2KycProcessing = () => { - + {strings('deposit.kyc_processing.error_heading')} - + {error || strings('deposit.kyc_processing.error_description')} @@ -190,10 +189,11 @@ const V2KycProcessing = () => { @@ -212,13 +212,17 @@ const V2KycProcessing = () => { - + {strings('deposit.kyc_processing.success_heading')} - + {strings('deposit.kyc_processing.success_description')} @@ -229,10 +233,11 @@ const V2KycProcessing = () => { @@ -251,10 +256,14 @@ const V2KycProcessing = () => { color={theme.colors.primary.default} testID={KYC_PROCESSING_TEST_IDS.ACTIVITY_INDICATOR} /> - + {strings('deposit.kyc_processing.heading')} - + {strings('deposit.kyc_processing.description')} diff --git a/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.tsx b/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.tsx index b6f6288b7ab..a4bdfe21deb 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.tsx @@ -5,7 +5,7 @@ import styleSheet from '../../Deposit/Views/OrderProcessing/OrderProcessing.styl import { useNavigation } from '@react-navigation/native'; import { useParams } from '../../../../../util/navigation/navUtils'; import Routes from '../../../../../constants/navigation/Routes'; -import { useStyles } from '../../../../../component-library/hooks'; +import { useStyles } from '../../../../hooks/useStyles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { getDepositNavbarOptions } from '../../../Navbar'; import { getOrderById } from '../../../../../reducers/fiatOrders'; @@ -14,10 +14,11 @@ import { strings } from '../../../../../../locales/i18n'; import DepositOrderContent from '../../Deposit/components/DepositOrderContent/DepositOrderContent'; import { FIAT_ORDER_STATES } from '../../../../../constants/on-ramp'; import { TRANSAK_SUPPORT_URL } from '../../Deposit/constants'; -import Button, { +import { + Button, ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; + ButtonVariant, +} from '@metamask/design-system-react-native'; import Loader from '../../../../../component-library/components-temp/Loader/Loader'; import { ORDER_PROCESSING_TEST_IDS } from './OrderProcessing.testIds'; @@ -89,27 +90,25 @@ const V2OrderProcessing = () => { order.state === FIAT_ORDER_STATES.FAILED) && ( )} diff --git a/app/components/UI/Ramp/Views/NativeFlow/OtpCode.tsx b/app/components/UI/Ramp/Views/NativeFlow/OtpCode.tsx index 034cbc63bde..8acede1842a 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/OtpCode.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/OtpCode.tsx @@ -1,11 +1,17 @@ import React, { useCallback, useState, useEffect, useRef, FC } from 'react'; import { TextInput, View, TouchableOpacity, Linking } from 'react-native'; import Clipboard from '@react-native-clipboard/clipboard'; -import Text, { +import { + Box, + BoxAlignItems, + Text, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../../component-library/hooks'; + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from '../../Deposit/Views/OtpCode/OtpCode.styles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { @@ -26,16 +32,10 @@ import DepositProgressBar from '../../Deposit/components/DepositProgressBar'; import Row from '../../Aggregator/components/Row'; import { TRANSAK_SUPPORT_URL } from '../../Deposit/constants'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import Logger from '../../../../../util/Logger'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { trace, TraceName } from '../../../../../util/trace'; -import { Box, BoxAlignItems } from '@metamask/design-system-react-native'; import { useTransakController } from '../../hooks/useTransakController'; import { useTransakRouting } from '../../hooks/useTransakRouting'; import { useRampsController } from '../../hooks/useRampsController'; @@ -336,7 +336,7 @@ const V2OtpCode = () => { - + {strings('deposit.otp_code.title')} @@ -345,8 +345,8 @@ const V2OtpCode = () => { @@ -380,7 +380,9 @@ const V2OtpCode = () => { /> {error && ( - {error} + + {error} + )} @@ -414,13 +416,14 @@ const V2OtpCode = () => { diff --git a/app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.tsx b/app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.tsx index e757bcc6695..e6f0a4fafa5 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.tsx @@ -1,10 +1,14 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { Image, Linking, ScrollView } from 'react-native'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../../component-library/hooks'; + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from '../../Deposit/Views/VerifyIdentity/VerifyIdentity.styles'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import Routes from '../../../../../constants/navigation/Routes'; @@ -13,11 +17,6 @@ import { getDepositNavbarOptions } from '../../../Navbar'; import { strings } from '../../../../../../locales/i18n'; import VerifyIdentityImage from '../../Deposit/assets/verifyIdentityIllustration.png'; import PoweredByTransak from '../../Deposit/components/PoweredByTransak'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import { TRANSAK_TERMS_URL_US, TRANSAK_TERMS_URL_WORLD, @@ -176,24 +175,32 @@ const V2VerifyIdentity = () => { resizeMode={'contain'} style={styles.image} /> - + {strings('deposit.verify_identity.title')} - + {strings('deposit.verify_identity.description_1')} - - + + {strings('deposit.verify_identity.description_2_transak')} {strings('deposit.verify_identity.description_2_rest')} - + {strings('deposit.verify_identity.description_3_part1')} { {strings('deposit.verify_identity.agreement_text_part1')} @@ -225,8 +232,8 @@ const V2VerifyIdentity = () => { {strings('deposit.verify_identity.agreement_text_and')} { testID={VerifyIdentitySelectorsIDs.CONTINUE_BUTTON} size={ButtonSize.Lg} onPress={handleSubmit} - label={strings('deposit.verify_identity.button')} - variant={ButtonVariants.Primary} - width={ButtonWidthTypes.Full} - /> + variant={ButtonVariant.Primary} + isFullWidth + > + {strings('deposit.verify_identity.button')} + diff --git a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/AdditionalVerification.test.tsx.snap b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/AdditionalVerification.test.tsx.snap index 1dbe3478ade..e8e5cfb67bd 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/AdditionalVerification.test.tsx.snap +++ b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/AdditionalVerification.test.tsx.snap @@ -65,15 +65,20 @@ exports[`V2AdditionalVerification matches snapshot 1`] = ` deposit.additional_verification.title @@ -81,14 +86,19 @@ exports[`V2AdditionalVerification matches snapshot 1`] = ` deposit.additional_verification.paragraph_1 @@ -96,14 +106,19 @@ exports[`V2AdditionalVerification matches snapshot 1`] = ` deposit.additional_verification.paragraph_2 @@ -125,42 +140,90 @@ exports[`V2AdditionalVerification matches snapshot 1`] = ` ] } > - deposit.additional_verification.button - + deposit.bank_details.main_title @@ -99,13 +103,17 @@ exports[`V2BankDetails matches snapshot when order is null (loading) 1`] = ` deposit.bank_details.main_content_1 @@ -113,13 +121,17 @@ exports[`V2BankDetails matches snapshot when order is null (loading) 1`] = ` deposit.bank_details.main_content_2 @@ -215,13 +227,17 @@ exports[`V2BankDetails matches snapshot when order is null (loading) 1`] = ` deposit.bank_details.info_banner_text @@ -237,87 +253,183 @@ exports[`V2BankDetails matches snapshot when order is null (loading) 1`] = ` } } > - deposit.order_processing.cancel_order_button - - + deposit.bank_details.button - + @@ -411,13 +523,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while cancel deposit.bank_details.main_title @@ -425,13 +541,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while cancel deposit.bank_details.main_content_1 @@ -439,13 +559,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while cancel deposit.bank_details.main_content_2 @@ -525,29 +649,34 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while cancel deposit.bank_details.show_bank_info @@ -638,13 +767,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while cancel deposit.bank_details.info_banner_text @@ -660,77 +793,244 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while cancel } } > - - - - + + + + + + + + + deposit.order_processing.cancel_order_button + + + deposit.bank_details.button - + @@ -824,13 +1124,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while confirm deposit.bank_details.main_title @@ -838,13 +1142,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while confirm deposit.bank_details.main_content_1 @@ -852,13 +1160,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while confirm deposit.bank_details.main_content_2 @@ -938,29 +1250,34 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while confirm deposit.bank_details.show_bank_info @@ -1051,13 +1368,17 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while confirm deposit.bank_details.info_banner_text @@ -1073,77 +1394,244 @@ exports[`V2BankDetails matches snapshot with both buttons disabled while confirm } } > - deposit.order_processing.cancel_order_button - - + - - + + + + + + + + + deposit.bank_details.button + + @@ -1237,13 +1725,17 @@ exports[`V2BankDetails matches snapshot with order data and depositOrder from re deposit.bank_details.main_title @@ -1251,13 +1743,17 @@ exports[`V2BankDetails matches snapshot with order data and depositOrder from re deposit.bank_details.main_content_1 @@ -1265,13 +1761,17 @@ exports[`V2BankDetails matches snapshot with order data and depositOrder from re deposit.bank_details.main_content_2 @@ -1351,29 +1851,34 @@ exports[`V2BankDetails matches snapshot with order data and depositOrder from re deposit.bank_details.show_bank_info @@ -1464,13 +1969,17 @@ exports[`V2BankDetails matches snapshot with order data and depositOrder from re deposit.bank_details.info_banner_text @@ -1486,87 +1995,183 @@ exports[`V2BankDetails matches snapshot with order data and depositOrder from re } } > - deposit.order_processing.cancel_order_button - - + deposit.bank_details.button - + @@ -1660,13 +2265,17 @@ exports[`V2BankDetails renders IBAN and BIC fields when present 1`] = ` deposit.bank_details.main_title @@ -1674,13 +2283,17 @@ exports[`V2BankDetails renders IBAN and BIC fields when present 1`] = ` deposit.bank_details.main_content_1 @@ -1688,13 +2301,17 @@ exports[`V2BankDetails renders IBAN and BIC fields when present 1`] = ` deposit.bank_details.main_content_2 @@ -1754,29 +2371,34 @@ exports[`V2BankDetails renders IBAN and BIC fields when present 1`] = ` deposit.bank_details.show_bank_info @@ -1867,13 +2489,17 @@ exports[`V2BankDetails renders IBAN and BIC fields when present 1`] = ` deposit.bank_details.info_banner_text @@ -1889,87 +2515,183 @@ exports[`V2BankDetails renders IBAN and BIC fields when present 1`] = ` } } > - deposit.order_processing.cancel_order_button - - + deposit.bank_details.button - + @@ -2063,13 +2785,17 @@ exports[`V2BankDetails when shouldUpdate is false matches snapshot showing bank deposit.bank_details.main_title @@ -2077,13 +2803,17 @@ exports[`V2BankDetails when shouldUpdate is false matches snapshot showing bank deposit.bank_details.main_content_1 @@ -2091,13 +2821,17 @@ exports[`V2BankDetails when shouldUpdate is false matches snapshot showing bank deposit.bank_details.main_content_2 @@ -2177,29 +2911,34 @@ exports[`V2BankDetails when shouldUpdate is false matches snapshot showing bank deposit.bank_details.show_bank_info @@ -2290,13 +3029,17 @@ exports[`V2BankDetails when shouldUpdate is false matches snapshot showing bank deposit.bank_details.info_banner_text @@ -2312,87 +3055,183 @@ exports[`V2BankDetails when shouldUpdate is false matches snapshot showing bank } } > - deposit.order_processing.cancel_order_button - - + deposit.bank_details.button - + diff --git a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/BasicInfo.test.tsx.snap b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/BasicInfo.test.tsx.snap index fc9f9e088d7..d3792f42aa2 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/BasicInfo.test.tsx.snap +++ b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/BasicInfo.test.tsx.snap @@ -174,15 +174,20 @@ exports[`V2BasicInfo handles region with no phone prefix 1`] = ` deposit.basic_info.title @@ -190,14 +195,20 @@ exports[`V2BasicInfo handles region with no phone prefix 1`] = ` deposit.basic_info.subtitle @@ -552,13 +563,19 @@ exports[`V2BasicInfo handles region with no phone prefix 1`] = ` 🇬🇧 @@ -853,45 +870,91 @@ exports[`V2BasicInfo handles region with no phone prefix 1`] = ` - deposit.basic_info.continue - + deposit.basic_info.title @@ -1098,14 +1166,20 @@ exports[`V2BasicInfo matches snapshot 1`] = ` deposit.basic_info.subtitle @@ -1460,13 +1534,19 @@ exports[`V2BasicInfo matches snapshot 1`] = ` 🇺🇸 @@ -1474,13 +1554,20 @@ exports[`V2BasicInfo matches snapshot 1`] = ` +1 @@ -1695,13 +1782,17 @@ exports[`V2BasicInfo matches snapshot 1`] = ` deposit.basic_info.social_security_number @@ -1711,17 +1802,18 @@ exports[`V2BasicInfo matches snapshot 1`] = ` testID="ssn-info-button" > @@ -1927,45 +2019,91 @@ exports[`V2BasicInfo matches snapshot 1`] = ` - deposit.basic_info.continue - + deposit.basic_info.title @@ -2172,14 +2315,20 @@ exports[`V2BasicInfo matches snapshot for non-US region 1`] = ` deposit.basic_info.subtitle @@ -2534,13 +2683,19 @@ exports[`V2BasicInfo matches snapshot for non-US region 1`] = ` 🇬🇧 @@ -2548,13 +2703,20 @@ exports[`V2BasicInfo matches snapshot for non-US region 1`] = ` +44 @@ -2849,45 +3011,91 @@ exports[`V2BasicInfo matches snapshot for non-US region 1`] = ` - deposit.basic_info.continue - + deposit.enter_address.title @@ -199,13 +203,19 @@ exports[`V2EnterAddress matches snapshot 1`] = ` deposit.enter_address.subtitle @@ -914,13 +924,19 @@ exports[`V2EnterAddress matches snapshot 1`] = ` 🇺🇸 @@ -1074,45 +1090,91 @@ exports[`V2EnterAddress matches snapshot 1`] = ` - deposit.enter_address.continue - + deposit.enter_address.title @@ -1329,13 +1395,19 @@ exports[`V2EnterAddress matches snapshot for non-US region 1`] = ` deposit.enter_address.subtitle @@ -2044,13 +2116,19 @@ exports[`V2EnterAddress matches snapshot for non-US region 1`] = ` 🇬🇧 @@ -2204,45 +2282,91 @@ exports[`V2EnterAddress matches snapshot for non-US region 1`] = ` - deposit.enter_address.continue - + deposit.enter_email.title @@ -80,13 +85,19 @@ exports[`V2EnterEmail matches snapshot 1`] = ` deposit.enter_email.description @@ -197,45 +208,91 @@ exports[`V2EnterEmail matches snapshot 1`] = ` ] } > - deposit.enter_email.submit_button - + deposit.kyc_processing.heading @@ -178,14 +183,19 @@ exports[`V2KycProcessing matches snapshot in loading state 1`] = ` deposit.kyc_processing.description diff --git a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OrderProcessing.test.tsx.snap b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OrderProcessing.test.tsx.snap index f848535e762..6db6651cbf4 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OrderProcessing.test.tsx.snap +++ b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OrderProcessing.test.tsx.snap @@ -119,44 +119,94 @@ exports[`V2OrderProcessing matches snapshot when order is pending 1`] = ` } } > - deposit.order_processing.button - + diff --git a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OtpCode.test.tsx.snap b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OtpCode.test.tsx.snap index c456a1391e4..62d929a98ab 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OtpCode.test.tsx.snap +++ b/app/components/UI/Ramp/Views/NativeFlow/__snapshots__/OtpCode.test.tsx.snap @@ -146,15 +146,20 @@ exports[`V2OtpCode matches snapshot 1`] = ` deposit.otp_code.title @@ -162,14 +167,20 @@ exports[`V2OtpCode matches snapshot 1`] = ` deposit.otp_code.description @@ -189,13 +200,17 @@ exports[`V2OtpCode matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } testID="otp-code-paste-button" > @@ -228,15 +243,23 @@ exports[`V2OtpCode matches snapshot 1`] = ` @@ -260,15 +283,23 @@ exports[`V2OtpCode matches snapshot 1`] = ` @@ -292,15 +323,23 @@ exports[`V2OtpCode matches snapshot 1`] = ` @@ -324,15 +363,23 @@ exports[`V2OtpCode matches snapshot 1`] = ` @@ -356,15 +403,23 @@ exports[`V2OtpCode matches snapshot 1`] = ` @@ -388,15 +443,23 @@ exports[`V2OtpCode matches snapshot 1`] = ` @@ -419,14 +482,20 @@ exports[`V2OtpCode matches snapshot 1`] = ` deposit.otp_code.resend_cooldown @@ -449,46 +518,91 @@ exports[`V2OtpCode matches snapshot 1`] = ` ] } > - deposit.otp_code.submit_button - + deposit.verify_identity.title @@ -90,14 +95,19 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` deposit.verify_identity.description_1 @@ -105,28 +115,38 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` - + deposit.verify_identity.description_2_transak @@ -136,14 +156,19 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` deposit.verify_identity.description_3_part1 @@ -151,14 +176,19 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#131416", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - "textDecorationLine": "underline", - } + [ + { + "color": "#131416", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + { + "textDecorationLine": "underline", + }, + ] } testID="privacy-policy-link-1" > @@ -188,14 +218,19 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` deposit.verify_identity.agreement_text_part1 @@ -203,14 +238,19 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#babbbe", - "fontFamily": "Geist-Regular", - "fontSize": 12, - "letterSpacing": 0.25, - "lineHeight": 20, - "textDecorationLine": "underline", - } + [ + { + "color": "#babbbe", + "fontFamily": "Geist-Regular", + "fontSize": 12, + "fontWeight": 400, + "letterSpacing": 0.25, + "lineHeight": 20, + }, + { + "textDecorationLine": "underline", + }, + ] } > deposit.verify_identity.agreement_text_transak_terms @@ -220,14 +260,19 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#babbbe", - "fontFamily": "Geist-Regular", - "fontSize": 12, - "letterSpacing": 0.25, - "lineHeight": 20, - "textDecorationLine": "underline", - } + [ + { + "color": "#babbbe", + "fontFamily": "Geist-Regular", + "fontSize": 12, + "fontWeight": 400, + "letterSpacing": 0.25, + "lineHeight": 20, + }, + { + "textDecorationLine": "underline", + }, + ] } testID="privacy-policy-link-2" > @@ -235,43 +280,91 @@ exports[`V2VerifyIdentity matches snapshot 1`] = ` deposit.verify_identity.agreement_text_part2 - deposit.verify_identity.button - + = ({ {showManageBankTransfer && ( )} @@ -644,12 +643,13 @@ const OrderContent: React.FC = ({ {showCloseButton && ( )} diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx index e903408e333..3996ce64cd5 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx @@ -10,6 +10,9 @@ import { IconName, IconSize, FontWeight, + Button, + ButtonVariant, + ButtonSize, } from '@metamask/design-system-react-native'; import { normalizeProviderCode, @@ -21,11 +24,6 @@ import { getNavigateAfterExternalBrowserRoutes, type RampsOrderDetailsParams, } from '../../utils/rampsNavigation'; -import Button, { - ButtonVariants, - ButtonSize, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { strings } from '../../../../../../locales/i18n'; import { getRampsOrderDetailsNavbarOptions } from '../../../Navbar'; @@ -290,12 +288,13 @@ const OrderDetails = () => { {error} diff --git a/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.tsx b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.tsx index 292841cb191..2de02f95397 100644 --- a/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.tsx +++ b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.tsx @@ -19,21 +19,22 @@ import { } from '@react-navigation/native'; import Fuse from 'fuse.js'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; import ListItemSelect from '../../../../../../component-library/components/List/ListItemSelect'; import ListItemColumn, { WidthType, } from '../../../../../../component-library/components/List/ListItemColumn'; import TextFieldSearch from '../../../../../../component-library/components/Form/TextFieldSearch'; -import Icon, { +import { IconName as ComponentLibraryIconName } from '../../../../../../component-library/components/Icons/Icon'; +import { + Text, + TextColor, + TextVariant, + FontWeight, + Icon, IconName, -} from '../../../../../../component-library/components/Icons/Icon'; -import ButtonIcon, { - ButtonIconSizes, -} from '../../../../../../component-library/components/Buttons/ButtonIcon'; + ButtonIcon, + ButtonIconSize, +} from '@metamask/design-system-react-native'; import styleSheet, { styles as navigationOptionsStyles, @@ -94,7 +95,7 @@ interface HeaderBackButtonProps { function HeaderBackButton({ onPress, testID }: HeaderBackButtonProps) { return ( {item.country.flag} @@ -381,17 +383,20 @@ function RegionSelector() { )} {item.country.name} {showStateName && userRegion.state && ( {userRegion.state.name} @@ -424,11 +429,12 @@ function RegionSelector() { {state.name || ''} @@ -471,9 +477,12 @@ function RegionSelector() { {region.flag && ( {region.flag} @@ -482,15 +491,21 @@ function RegionSelector() { )} {region.name} {showStateName && userRegion.state && ( - + {userRegion.state.name} )} @@ -519,9 +534,12 @@ function RegionSelector() { {region.name || ''} @@ -558,10 +576,14 @@ function RegionSelector() { if (countriesError && countries.length === 0) { return ( - + {strings('fiat_on_ramp_aggregator.error')} - + {countriesError} @@ -571,7 +593,7 @@ function RegionSelector() { if (searchString.length > 0) { return ( - + {strings('fiat_on_ramp_aggregator.region.no_region_results', { searchString, })} @@ -651,8 +673,8 @@ function RegionSelector() { {activeView === RegionViewType.COUNTRY && ( {strings('fiat_on_ramp_aggregator.region.region_variation_notice')} @@ -662,7 +684,7 @@ function RegionSelector() { value={searchString} onPressClearButton={clearSearchText} clearButtonProps={{ - iconName: IconName.Close, + iconName: ComponentLibraryIconName.Close, testID: REGION_SELECTOR_TEST_IDS.CLEAR_BUTTON, }} onFocus={scrollToTop} @@ -710,7 +732,7 @@ RegionSelector.navigationOptions = ({ }) => ({ headerLeft: () => ( navigation.goBack()} style={navigationOptionsStyles.headerLeft} diff --git a/app/components/UI/Ramp/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap b/app/components/UI/Ramp/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap index 2abfdef31fd..4af2248d1dc 100644 --- a/app/components/UI/Ramp/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Settings/RegionSelector/__snapshots__/RegionSelector.test.tsx.snap @@ -337,15 +337,20 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr Payment methods and available tokens may vary based on your region and our providers. @@ -649,13 +654,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr 🇫🇷 @@ -671,13 +680,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr France @@ -752,13 +765,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr 🇺🇸 @@ -774,13 +791,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr United States @@ -806,17 +827,18 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr testID="listitemcolumn" > @@ -886,13 +908,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr 🇨🇦 @@ -908,13 +934,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr Canada @@ -940,17 +970,18 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr testID="listitemcolumn" > @@ -1020,13 +1051,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr 🏳️ @@ -1042,13 +1077,17 @@ exports[`RegionSelector clears search and scrolls to top when clear button is pr Unsupported Country @@ -1412,15 +1451,20 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -1724,13 +1768,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` 🇫🇷 @@ -1746,13 +1794,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` France @@ -1827,13 +1879,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` 🇺🇸 @@ -1849,13 +1905,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` United States @@ -1881,17 +1941,18 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` testID="listitemcolumn" > @@ -1961,13 +2022,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` 🇨🇦 @@ -1983,13 +2048,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` Canada @@ -2015,17 +2084,18 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` testID="listitemcolumn" > @@ -2095,13 +2165,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` 🏳️ @@ -2117,13 +2191,17 @@ exports[`RegionSelector clears search text when clear button is pressed 1`] = ` Unsupported Country @@ -2487,15 +2565,20 @@ exports[`RegionSelector displays empty state when search has no results 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -2693,13 +2776,17 @@ exports[`RegionSelector displays empty state when search has no results 1`] = ` No region matches @@ -3057,15 +3144,20 @@ exports[`RegionSelector displays grouped search results showing country and matc Payment methods and available tokens may vary based on your region and our providers. @@ -3364,13 +3456,17 @@ exports[`RegionSelector displays grouped search results showing country and matc 🇺🇸 @@ -3386,13 +3482,17 @@ exports[`RegionSelector displays grouped search results showing country and matc United States @@ -3418,17 +3518,18 @@ exports[`RegionSelector displays grouped search results showing country and matc testID="listitemcolumn" > @@ -3497,13 +3598,17 @@ exports[`RegionSelector displays grouped search results showing country and matc California @@ -3868,15 +3973,20 @@ exports[`RegionSelector displays standalone countries in search results 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -4144,13 +4254,17 @@ exports[`RegionSelector displays standalone countries in search results 1`] = ` 🇩🇪 @@ -4166,13 +4280,17 @@ exports[`RegionSelector displays standalone countries in search results 1`] = ` Germany @@ -4536,15 +4654,20 @@ exports[`RegionSelector displays standalone states in search results 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -4843,13 +4966,17 @@ exports[`RegionSelector displays standalone states in search results 1`] = ` 🇺🇸 @@ -4865,13 +4992,17 @@ exports[`RegionSelector displays standalone states in search results 1`] = ` United States @@ -4897,17 +5028,18 @@ exports[`RegionSelector displays standalone states in search results 1`] = ` testID="listitemcolumn" > @@ -4976,13 +5108,17 @@ exports[`RegionSelector displays standalone states in search results 1`] = ` Texas @@ -5347,15 +5483,20 @@ exports[`RegionSelector does not highlight country when regionCode does not matc Payment methods and available tokens may vary based on your region and our providers. @@ -5679,13 +5820,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc 🇫🇷 @@ -5701,13 +5846,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc France @@ -5782,13 +5931,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc 🇺🇸 @@ -5804,13 +5957,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc United States @@ -5836,17 +5993,18 @@ exports[`RegionSelector does not highlight country when regionCode does not matc testID="listitemcolumn" > @@ -5916,13 +6074,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc 🇨🇦 @@ -5938,13 +6100,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc Canada @@ -5970,17 +6136,18 @@ exports[`RegionSelector does not highlight country when regionCode does not matc testID="listitemcolumn" > @@ -6050,13 +6217,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc 🏳️ @@ -6072,13 +6243,17 @@ exports[`RegionSelector does not highlight country when regionCode does not matc Unsupported Country @@ -6686,13 +6861,17 @@ exports[`RegionSelector does not highlight state when country does not match 1`] California @@ -6766,13 +6945,17 @@ exports[`RegionSelector does not highlight state when country does not match 1`] New York @@ -7380,13 +7563,17 @@ exports[`RegionSelector does not highlight state when state ID does not match 1` California @@ -7460,13 +7647,17 @@ exports[`RegionSelector does not highlight state when state ID does not match 1` New York @@ -8082,13 +8273,17 @@ exports[`RegionSelector does not highlight state when userRegion has no state 1` California @@ -8162,13 +8357,17 @@ exports[`RegionSelector does not highlight state when userRegion has no state 1` New York @@ -8532,15 +8731,20 @@ exports[`RegionSelector falls back to userCountryCode when regionInTransit is nu Payment methods and available tokens may vary based on your region and our providers. @@ -8858,13 +9062,17 @@ exports[`RegionSelector falls back to userCountryCode when regionInTransit is nu 🇺🇸 @@ -8880,13 +9088,17 @@ exports[`RegionSelector falls back to userCountryCode when regionInTransit is nu United States @@ -8894,13 +9106,17 @@ exports[`RegionSelector falls back to userCountryCode when regionInTransit is nu CA @@ -8926,17 +9142,18 @@ exports[`RegionSelector falls back to userCountryCode when regionInTransit is nu testID="listitemcolumn" > @@ -9020,13 +9237,17 @@ exports[`RegionSelector falls back to userCountryCode when regionInTransit is nu California @@ -9406,15 +9627,20 @@ exports[`RegionSelector filters regions when search text is entered 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -9704,13 +9930,17 @@ exports[`RegionSelector filters regions when search text is entered 1`] = ` 🇺🇸 @@ -9726,13 +9956,17 @@ exports[`RegionSelector filters regions when search text is entered 1`] = ` United States @@ -9758,17 +9992,18 @@ exports[`RegionSelector filters regions when search text is entered 1`] = ` testID="listitemcolumn" > @@ -10128,15 +10363,20 @@ exports[`RegionSelector highlights country when regionCode exactly matches count Payment methods and available tokens may vary based on your region and our providers. @@ -10460,13 +10700,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count 🇫🇷 @@ -10482,13 +10726,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count France @@ -10578,13 +10826,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count 🇺🇸 @@ -10600,13 +10852,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count United States @@ -10632,17 +10888,18 @@ exports[`RegionSelector highlights country when regionCode exactly matches count testID="listitemcolumn" > @@ -10712,13 +10969,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count 🇨🇦 @@ -10734,13 +10995,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count Canada @@ -10766,17 +11031,18 @@ exports[`RegionSelector highlights country when regionCode exactly matches count testID="listitemcolumn" > @@ -10846,13 +11112,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count 🏳️ @@ -10868,13 +11138,17 @@ exports[`RegionSelector highlights country when regionCode exactly matches count Unsupported Country @@ -11238,15 +11512,20 @@ exports[`RegionSelector highlights country when regionCode starts with country c Payment methods and available tokens may vary based on your region and our providers. @@ -11577,13 +11856,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c 🇫🇷 @@ -11599,13 +11882,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c France @@ -11680,13 +11967,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c 🇺🇸 @@ -11702,13 +11993,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c United States @@ -11716,13 +12011,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c CA @@ -11748,17 +12047,18 @@ exports[`RegionSelector highlights country when regionCode starts with country c testID="listitemcolumn" > @@ -11843,13 +12143,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c 🇨🇦 @@ -11865,13 +12169,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c Canada @@ -11897,17 +12205,18 @@ exports[`RegionSelector highlights country when regionCode starts with country c testID="listitemcolumn" > @@ -11977,13 +12286,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c 🏳️ @@ -11999,13 +12312,17 @@ exports[`RegionSelector highlights country when regionCode starts with country c Unsupported Country @@ -12369,15 +12686,20 @@ exports[`RegionSelector highlights state in grouped search results when parent c Payment methods and available tokens may vary based on your region and our providers. @@ -12703,13 +13025,17 @@ exports[`RegionSelector highlights state in grouped search results when parent c 🇺🇸 @@ -12725,13 +13051,17 @@ exports[`RegionSelector highlights state in grouped search results when parent c United States @@ -12739,13 +13069,17 @@ exports[`RegionSelector highlights state in grouped search results when parent c CA @@ -12771,17 +13105,18 @@ exports[`RegionSelector highlights state in grouped search results when parent c testID="listitemcolumn" > @@ -12865,13 +13200,17 @@ exports[`RegionSelector highlights state in grouped search results when parent c California @@ -13495,13 +13834,17 @@ exports[`RegionSelector highlights state when selected in state view 1`] = ` California @@ -13590,13 +13933,17 @@ exports[`RegionSelector highlights state when selected in state view 1`] = ` New York @@ -13960,15 +14307,20 @@ exports[`RegionSelector limits search results 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -14559,13 +14911,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -14581,13 +14937,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 0 @@ -14662,13 +15022,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -14684,13 +15048,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 1 @@ -14765,13 +15133,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -14787,13 +15159,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 2 @@ -14868,13 +15244,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -14890,13 +15270,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 3 @@ -14971,13 +15355,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -14993,13 +15381,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 4 @@ -15074,13 +15466,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -15096,13 +15492,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 5 @@ -15177,13 +15577,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -15199,13 +15603,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 6 @@ -15280,13 +15688,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -15302,13 +15714,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 7 @@ -15383,13 +15799,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -15405,13 +15825,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 8 @@ -15486,13 +15910,17 @@ exports[`RegionSelector limits search results 1`] = ` 🏳️ @@ -15508,13 +15936,17 @@ exports[`RegionSelector limits search results 1`] = ` Country 9 @@ -16102,13 +16534,17 @@ exports[`RegionSelector navigates back to countries view when back button is pre California @@ -16182,13 +16618,17 @@ exports[`RegionSelector navigates back to countries view when back button is pre New York @@ -16769,13 +17209,17 @@ exports[`RegionSelector navigates to states view when country with states is sel California @@ -16849,13 +17293,17 @@ exports[`RegionSelector navigates to states view when country with states is sel New York @@ -17219,15 +17667,20 @@ exports[`RegionSelector renders countries list 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -17531,13 +17984,17 @@ exports[`RegionSelector renders countries list 1`] = ` 🇫🇷 @@ -17553,13 +18010,17 @@ exports[`RegionSelector renders countries list 1`] = ` France @@ -17634,13 +18095,17 @@ exports[`RegionSelector renders countries list 1`] = ` 🇺🇸 @@ -17656,13 +18121,17 @@ exports[`RegionSelector renders countries list 1`] = ` United States @@ -17688,17 +18157,18 @@ exports[`RegionSelector renders countries list 1`] = ` testID="listitemcolumn" > @@ -17768,13 +18238,17 @@ exports[`RegionSelector renders countries list 1`] = ` 🇨🇦 @@ -17790,13 +18264,17 @@ exports[`RegionSelector renders countries list 1`] = ` Canada @@ -17822,17 +18300,18 @@ exports[`RegionSelector renders countries list 1`] = ` testID="listitemcolumn" > @@ -17902,13 +18381,17 @@ exports[`RegionSelector renders countries list 1`] = ` 🏳️ @@ -17924,13 +18407,17 @@ exports[`RegionSelector renders countries list 1`] = ` Unsupported Country @@ -18294,15 +18781,20 @@ exports[`RegionSelector renders country with supported set to false 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -18529,13 +19021,17 @@ exports[`RegionSelector renders country with supported set to false 1`] = ` 🏳️ @@ -18551,13 +19047,17 @@ exports[`RegionSelector renders country with supported set to false 1`] = ` Unsupported @@ -18921,15 +19421,20 @@ exports[`RegionSelector renders country without flag 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -19164,13 +19669,17 @@ exports[`RegionSelector renders country without flag 1`] = ` United States @@ -19196,17 +19705,18 @@ exports[`RegionSelector renders country without flag 1`] = ` testID="listitemcolumn" > @@ -19565,15 +20075,20 @@ exports[`RegionSelector renders description text only in country view 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -19877,13 +20392,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` 🇫🇷 @@ -19899,13 +20418,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` France @@ -19980,13 +20503,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` 🇺🇸 @@ -20002,13 +20529,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` United States @@ -20034,17 +20565,18 @@ exports[`RegionSelector renders description text only in country view 1`] = ` testID="listitemcolumn" > @@ -20114,13 +20646,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` 🇨🇦 @@ -20136,13 +20672,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` Canada @@ -20168,17 +20708,18 @@ exports[`RegionSelector renders description text only in country view 1`] = ` testID="listitemcolumn" > @@ -20248,13 +20789,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` 🏳️ @@ -20270,13 +20815,17 @@ exports[`RegionSelector renders description text only in country view 1`] = ` Unsupported Country @@ -20857,13 +21406,17 @@ exports[`RegionSelector renders description text only in country view 2`] = ` California @@ -20937,13 +21490,17 @@ exports[`RegionSelector renders description text only in country view 2`] = ` New York @@ -21307,15 +21864,20 @@ exports[`RegionSelector renders disabled country 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -21619,13 +22181,17 @@ exports[`RegionSelector renders disabled country 1`] = ` 🇫🇷 @@ -21641,13 +22207,17 @@ exports[`RegionSelector renders disabled country 1`] = ` France @@ -21722,13 +22292,17 @@ exports[`RegionSelector renders disabled country 1`] = ` 🇺🇸 @@ -21744,13 +22318,17 @@ exports[`RegionSelector renders disabled country 1`] = ` United States @@ -21776,17 +22354,18 @@ exports[`RegionSelector renders disabled country 1`] = ` testID="listitemcolumn" > @@ -21856,13 +22435,17 @@ exports[`RegionSelector renders disabled country 1`] = ` 🇨🇦 @@ -21878,13 +22461,17 @@ exports[`RegionSelector renders disabled country 1`] = ` Canada @@ -21910,17 +22497,18 @@ exports[`RegionSelector renders disabled country 1`] = ` testID="listitemcolumn" > @@ -21990,13 +22578,17 @@ exports[`RegionSelector renders disabled country 1`] = ` 🏳️ @@ -22012,13 +22604,17 @@ exports[`RegionSelector renders disabled country 1`] = ` Unsupported Country @@ -22599,13 +23195,17 @@ exports[`RegionSelector renders disabled state 1`] = ` California @@ -22679,13 +23279,17 @@ exports[`RegionSelector renders disabled state 1`] = ` Texas @@ -23049,15 +23653,20 @@ exports[`RegionSelector renders error state when countries error occurs 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -23214,15 +23823,20 @@ exports[`RegionSelector renders error state when countries error occurs 1`] = ` Error @@ -23230,13 +23844,17 @@ exports[`RegionSelector renders error state when countries error occurs 1`] = ` Failed to fetch countries @@ -23594,15 +24212,20 @@ exports[`RegionSelector renders loading state when regions are loading 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -24113,15 +24736,20 @@ exports[`RegionSelector renders recommended countries 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -24365,13 +24993,17 @@ exports[`RegionSelector renders recommended countries 1`] = ` 🇺🇸 @@ -24387,13 +25019,17 @@ exports[`RegionSelector renders recommended countries 1`] = ` United States @@ -24468,13 +25104,17 @@ exports[`RegionSelector renders recommended countries 1`] = ` 🇨🇦 @@ -24490,13 +25130,17 @@ exports[`RegionSelector renders recommended countries 1`] = ` Canada @@ -25069,13 +25713,17 @@ exports[`RegionSelector renders state with supported set to false 1`] = ` Texas @@ -25439,15 +26087,20 @@ exports[`RegionSelector renders state without stateId 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -25682,13 +26335,17 @@ exports[`RegionSelector renders state without stateId 1`] = ` 🇺🇸 @@ -25704,13 +26361,17 @@ exports[`RegionSelector renders state without stateId 1`] = ` United States @@ -25736,17 +26397,18 @@ exports[`RegionSelector renders state without stateId 1`] = ` testID="listitemcolumn" > @@ -26322,13 +26984,17 @@ exports[`RegionSelector renders states view 1`] = ` California @@ -26402,13 +27068,17 @@ exports[`RegionSelector renders states view 1`] = ` New York @@ -26772,15 +27442,20 @@ exports[`RegionSelector renders unsupported country 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -27084,13 +27759,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` 🇫🇷 @@ -27106,13 +27785,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` France @@ -27187,13 +27870,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` 🇺🇸 @@ -27209,13 +27896,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` United States @@ -27241,17 +27932,18 @@ exports[`RegionSelector renders unsupported country 1`] = ` testID="listitemcolumn" > @@ -27321,13 +28013,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` 🇨🇦 @@ -27343,13 +28039,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` Canada @@ -27375,17 +28075,18 @@ exports[`RegionSelector renders unsupported country 1`] = ` testID="listitemcolumn" > @@ -27455,13 +28156,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` 🏳️ @@ -27477,13 +28182,17 @@ exports[`RegionSelector renders unsupported country 1`] = ` Unsupported Country @@ -28064,13 +28773,17 @@ exports[`RegionSelector renders unsupported state 1`] = ` California @@ -28144,13 +28857,17 @@ exports[`RegionSelector renders unsupported state 1`] = ` Texas @@ -28514,15 +29231,20 @@ exports[`RegionSelector renders when country has states and user region state is Payment methods and available tokens may vary based on your region and our providers. @@ -28853,13 +29575,17 @@ exports[`RegionSelector renders when country has states and user region state is 🇫🇷 @@ -28875,13 +29601,17 @@ exports[`RegionSelector renders when country has states and user region state is France @@ -28956,13 +29686,17 @@ exports[`RegionSelector renders when country has states and user region state is 🇺🇸 @@ -28978,13 +29712,17 @@ exports[`RegionSelector renders when country has states and user region state is United States @@ -28992,13 +29730,17 @@ exports[`RegionSelector renders when country has states and user region state is CA @@ -29024,17 +29766,18 @@ exports[`RegionSelector renders when country has states and user region state is testID="listitemcolumn" > @@ -29119,13 +29862,17 @@ exports[`RegionSelector renders when country has states and user region state is 🇨🇦 @@ -29141,13 +29888,17 @@ exports[`RegionSelector renders when country has states and user region state is Canada @@ -29173,17 +29924,18 @@ exports[`RegionSelector renders when country has states and user region state is testID="listitemcolumn" > @@ -29253,13 +30005,17 @@ exports[`RegionSelector renders when country has states and user region state is 🏳️ @@ -29275,13 +30031,17 @@ exports[`RegionSelector renders when country has states and user region state is Unsupported Country @@ -29645,15 +30405,20 @@ exports[`RegionSelector renders with empty regions array 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -30148,15 +30913,20 @@ exports[`RegionSelector renders with selected user region 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -30487,13 +31257,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` 🇫🇷 @@ -30509,13 +31283,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` France @@ -30590,13 +31368,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` 🇺🇸 @@ -30612,13 +31394,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` United States @@ -30626,13 +31412,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` CA @@ -30658,17 +31448,18 @@ exports[`RegionSelector renders with selected user region 1`] = ` testID="listitemcolumn" > @@ -30753,13 +31544,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` 🇨🇦 @@ -30775,13 +31570,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` Canada @@ -30807,17 +31606,18 @@ exports[`RegionSelector renders with selected user region 1`] = ` testID="listitemcolumn" > @@ -30887,13 +31687,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` 🏳️ @@ -30909,13 +31713,17 @@ exports[`RegionSelector renders with selected user region 1`] = ` Unsupported Country @@ -31496,13 +32304,17 @@ exports[`RegionSelector resets search when navigating to state view 1`] = ` California @@ -31576,13 +32388,17 @@ exports[`RegionSelector resets search when navigating to state view 1`] = ` New York @@ -31946,15 +32762,20 @@ exports[`RegionSelector scrolls to top when search text changes 1`] = ` Payment methods and available tokens may vary based on your region and our providers. @@ -32152,13 +32973,17 @@ exports[`RegionSelector scrolls to top when search text changes 1`] = ` No region matches @@ -32733,13 +33558,17 @@ exports[`RegionSelector sets up back button in state view 1`] = ` California @@ -32813,13 +33642,17 @@ exports[`RegionSelector sets up back button in state view 1`] = ` New York @@ -33183,15 +34016,20 @@ exports[`RegionSelector shows state name in country view when user has selected Payment methods and available tokens may vary based on your region and our providers. @@ -33532,13 +34370,17 @@ exports[`RegionSelector shows state name in country view when user has selected 🇫🇷 @@ -33554,13 +34396,17 @@ exports[`RegionSelector shows state name in country view when user has selected France @@ -33635,13 +34481,17 @@ exports[`RegionSelector shows state name in country view when user has selected 🇺🇸 @@ -33657,13 +34507,17 @@ exports[`RegionSelector shows state name in country view when user has selected United States @@ -33671,13 +34525,17 @@ exports[`RegionSelector shows state name in country view when user has selected California @@ -33703,17 +34561,18 @@ exports[`RegionSelector shows state name in country view when user has selected testID="listitemcolumn" > @@ -33798,13 +34657,17 @@ exports[`RegionSelector shows state name in country view when user has selected 🇨🇦 @@ -33820,13 +34683,17 @@ exports[`RegionSelector shows state name in country view when user has selected Canada @@ -33852,17 +34719,18 @@ exports[`RegionSelector shows state name in country view when user has selected testID="listitemcolumn" > @@ -33932,13 +34800,17 @@ exports[`RegionSelector shows state name in country view when user has selected 🏳️ @@ -33954,13 +34826,17 @@ exports[`RegionSelector shows state name in country view when user has selected Unsupported Country @@ -34324,15 +35200,20 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] Payment methods and available tokens may vary based on your region and our providers. @@ -34593,13 +35474,17 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] 🇫🇷 @@ -34615,13 +35500,17 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] France @@ -34696,13 +35585,17 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] 🇺🇸 @@ -34718,13 +35611,17 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] United States @@ -34799,13 +35696,17 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] 🇨🇦 @@ -34821,13 +35722,17 @@ exports[`RegionSelector sorts regions with recommended first when no search 1`] Canada diff --git a/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.tsx b/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.tsx index 220487a5e9e..7c9348b3835 100644 --- a/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.tsx +++ b/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.tsx @@ -17,10 +17,12 @@ import TokenNetworkFilterBar from '../../components/TokenNetworkFilterBar'; import TokenListItem from '../../components/TokenListItem'; import { createUnsupportedTokenModalNavigationDetails } from '../Modals/UnsupportedTokenModal/UnsupportedTokenModal'; -import { Box } from '@metamask/design-system-react-native'; -import Text, { +import { + Box, + Text, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; + FontWeight, +} from '@metamask/design-system-react-native'; import ListItemSelect from '../../../../../component-library/components/List/ListItemSelect'; import TextFieldSearch from '../../../../../component-library/components/Form/TextFieldSearch'; @@ -298,7 +300,7 @@ function TokenSelection() { const renderEmptyList = useCallback( () => ( - + {strings('deposit.token_modal.no_tokens_found', { searchString, })} @@ -365,10 +367,10 @@ function TokenSelection() { - + {strings('deposit.token_modal.error_loading_tokens')} - + {parseUserFacingError( error, strings('deposit.token_modal.error_loading_tokens'), diff --git a/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap b/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap index 682a8a90658..bc3ba8abc06 100644 --- a/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap +++ b/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap @@ -377,237 +377,426 @@ exports[`TokenSelection Component displays empty state when no tokens match sear showsHorizontalScrollIndicator={false} > - - - All - - - + + All + + + + + - + > + + - - Ethereum - - - + Ethereum + + + + - + - + > + + - - Bitcoin - - - + Bitcoin + + + + - + - + > + + - - Solana - - + + Solana + + + @@ -829,13 +1018,17 @@ exports[`TokenSelection Component displays empty state when no tokens match sear No tokens match "Nonexistent Token" @@ -1237,237 +1430,426 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena showsHorizontalScrollIndicator={false} > - - - All - - - + + All + + + + + - + > + + - - Ethereum - - - + Ethereum + + + + - + - + > + + - - Bitcoin - - - + Bitcoin + + + + - + - + > + + - - Solana - - + + Solana + + + @@ -1849,13 +2231,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena USD Coin @@ -1863,13 +2249,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena USDC @@ -2151,13 +2541,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena Tether USD @@ -2165,13 +2559,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena USDT @@ -2453,13 +2851,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena Bitcoin @@ -2467,13 +2869,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena BTC @@ -2755,13 +3161,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena Ethereum @@ -2769,13 +3179,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena ETH @@ -3057,13 +3471,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena USD Coin @@ -3071,13 +3489,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena USDC @@ -3567,237 +3989,426 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy showsHorizontalScrollIndicator={false} > - - - All - - - + All + + + + - + - + > + + - - Ethereum - - - + Ethereum + + + + - + - + > + + - - Bitcoin - - - + Bitcoin + + + + - + - + > + + - - Solana - - + + Solana + + + @@ -4179,13 +4790,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy USD Coin @@ -4193,13 +4808,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy USDC @@ -4481,13 +5100,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy Tether USD @@ -4495,13 +5118,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy USDT @@ -4783,13 +5410,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy Bitcoin @@ -4797,13 +5428,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy BTC @@ -5085,13 +5720,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy Ethereum @@ -5099,13 +5738,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy ETH @@ -5387,13 +6030,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy USD Coin @@ -5401,13 +6048,17 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy USDC diff --git a/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx index 289cdd1edf7..d5d05cec9f5 100644 --- a/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx +++ b/app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.tsx @@ -1,18 +1,17 @@ import React, { useCallback, useRef } from 'react'; import { View, Linking } from 'react-native'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import styleSheet from './EligibilityFailedModal.styles'; import { useStyles } from '../../../../hooks/useStyles'; @@ -56,13 +55,13 @@ function EligibilityFailedModal() { testID: ELIGIBILITY_FAILED_MODAL_TEST_IDS.CLOSE_BUTTON, }} > - + {strings('fiat_on_ramp_aggregator.eligibility_failed_modal.title')} - + {strings( 'fiat_on_ramp_aggregator.eligibility_failed_modal.description', )} @@ -73,21 +72,21 @@ function EligibilityFailedModal() { ); diff --git a/app/components/UI/Ramp/components/EligibilityFailedModal/__snapshots__/EligibilityFailedModal.test.tsx.snap b/app/components/UI/Ramp/components/EligibilityFailedModal/__snapshots__/EligibilityFailedModal.test.tsx.snap index f587090d88e..f31b4bd7174 100644 --- a/app/components/UI/Ramp/components/EligibilityFailedModal/__snapshots__/EligibilityFailedModal.test.tsx.snap +++ b/app/components/UI/Ramp/components/EligibilityFailedModal/__snapshots__/EligibilityFailedModal.test.tsx.snap @@ -351,13 +351,17 @@ exports[`EligibilityFailedModal renders modal with title and description 1`] = ` Eligibility check failed @@ -414,13 +418,17 @@ exports[`EligibilityFailedModal renders modal with title and description 1`] = ` We couldn't confirm access based on your region. Please try again. If the issue continues, contact support. @@ -435,80 +443,176 @@ exports[`EligibilityFailedModal renders modal with title and description 1`] = ` } } > - Contact support - - + Got it - + diff --git a/app/components/UI/Ramp/components/MenuItem/MenuItem.test.tsx b/app/components/UI/Ramp/components/MenuItem/MenuItem.test.tsx index 6c300ee55ae..4b613685ab6 100644 --- a/app/components/UI/Ramp/components/MenuItem/MenuItem.test.tsx +++ b/app/components/UI/Ramp/components/MenuItem/MenuItem.test.tsx @@ -4,7 +4,7 @@ import { render, fireEvent } from '@testing-library/react-native'; // Internal dependencies. import MenuItem from './MenuItem'; -import { IconName } from '../../../../../component-library/components/Icons/Icon'; +import { IconName } from '@metamask/design-system-react-native'; const createTestProps = (overrides = {}) => ({ iconName: IconName.Add, diff --git a/app/components/UI/Ramp/components/MenuItem/MenuItem.tsx b/app/components/UI/Ramp/components/MenuItem/MenuItem.tsx index 6807f519064..798ee9e0b71 100644 --- a/app/components/UI/Ramp/components/MenuItem/MenuItem.tsx +++ b/app/components/UI/Ramp/components/MenuItem/MenuItem.tsx @@ -1,18 +1,20 @@ import React from 'react'; -import Icon, { +import { + Icon, IconName, IconSize, -} from '../../../../../component-library/components/Icons/Icon'; + IconColor, + Text, + TextVariant, + TextColor, + FontWeight, +} from '@metamask/design-system-react-native'; import ListItemSelect from '../../../../../component-library/components/List/ListItemSelect'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './MenuItem.styles'; import ListItemColumn, { WidthType, } from '../../../../../component-library/components/List/ListItemColumn'; -import Text, { - TextVariant, - TextColor, -} from '../../../../../component-library/components/Texts/Text'; interface MenuItemProps { iconName: IconName; @@ -27,7 +29,7 @@ export default function MenuItem({ description, onPress, }: MenuItemProps) { - const { theme, styles } = useStyles(styleSheet, {}); + const { styles } = useStyles(styleSheet, {}); return ( - {title} + + {title} + {description && ( - + {description} )} diff --git a/app/components/UI/Ramp/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap b/app/components/UI/Ramp/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap index dd4fbeed459..3a9e3b08f97 100644 --- a/app/components/UI/Ramp/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap +++ b/app/components/UI/Ramp/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap @@ -40,17 +40,18 @@ exports[`MenuItem renders snapshot correctly 1`] = ` testID="listitemcolumn" > Test Menu Item @@ -87,13 +92,17 @@ exports[`MenuItem renders snapshot correctly 1`] = ` Test description @@ -144,17 +153,18 @@ exports[`MenuItem renders with different icon 1`] = ` testID="listitemcolumn" > Test Menu Item @@ -191,13 +205,17 @@ exports[`MenuItem renders with different icon 1`] = ` Test description @@ -248,17 +266,18 @@ exports[`MenuItem renders with empty description 1`] = ` testID="listitemcolumn" > Test Menu Item @@ -338,17 +361,18 @@ exports[`MenuItem renders with title only 1`] = ` testID="listitemcolumn" > Test Menu Item diff --git a/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx b/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx index 535e1877def..acc26334130 100644 --- a/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx +++ b/app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.tsx @@ -1,20 +1,16 @@ import React from 'react'; import { TouchableOpacity, View } from 'react-native'; import { - IconColor as DsIconColor, - IconSize as DsIconSize, -} from '@metamask/design-system-react-native'; -import { Spinner } from '@metamask/design-system-react-native/dist/components/temp-components/Spinner/index.cjs'; - -import Icon, { + Icon, IconName, IconSize, IconColor, -} from '../../../../../component-library/components/Icons/Icon'; -import Text, { + Text, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../../component-library/hooks'; + FontWeight, + Spinner, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './PaymentMethodPill.styles'; import { PAYMENT_METHOD_PILL_TEST_IDS } from './PaymentMethodPill.testIds'; @@ -36,14 +32,14 @@ const PaymentMethodPill: React.FC = ({ isLoading = false, testID = PAYMENT_METHOD_PILL_TEST_IDS.CONTAINER, }) => { - const { styles } = useStyles(styleSheet); + const { styles } = useStyles(styleSheet, {}); if (isLoading) { return ( ); @@ -60,17 +56,21 @@ const PaymentMethodPill: React.FC = ({ - + {label} diff --git a/app/components/UI/Ramp/components/PaymentMethodPill/__snapshots__/PaymentMethodPill.test.tsx.snap b/app/components/UI/Ramp/components/PaymentMethodPill/__snapshots__/PaymentMethodPill.test.tsx.snap index ccaa168c104..a03dffcb9dd 100644 --- a/app/components/UI/Ramp/components/PaymentMethodPill/__snapshots__/PaymentMethodPill.test.tsx.snap +++ b/app/components/UI/Ramp/components/PaymentMethodPill/__snapshots__/PaymentMethodPill.test.tsx.snap @@ -23,31 +23,37 @@ exports[`PaymentMethodPill matches snapshot 1`] = ` } > Debit card @@ -60,17 +66,18 @@ exports[`PaymentMethodPill matches snapshot 1`] = ` } > @@ -95,5 +102,49 @@ exports[`PaymentMethodPill when isLoading is true matches snapshot when loading ] } testID="payment-method-pill" -/> +> + + + + + + `; diff --git a/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.tsx b/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.tsx index 448a4457a00..620ee0671d4 100644 --- a/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.tsx +++ b/app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.tsx @@ -6,7 +6,7 @@ import { ButtonVariant, ButtonSize, } from '@metamask/design-system-react-native'; -import { useStyles } from '../../../../../component-library/hooks'; +import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './QuickAmounts.styles'; import { QUICK_AMOUNTS_TEST_IDS } from './QuickAmounts.testIds'; diff --git a/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.tsx b/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.tsx index b656ef5c0aa..32481953f5b 100644 --- a/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.tsx +++ b/app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.tsx @@ -1,18 +1,17 @@ import React, { useCallback, useRef } from 'react'; -import { Box } from '@metamask/design-system-react-native'; -import Text, { +import { + Box, + Text, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import { createNavigationDetails } from '../../../../../util/navigation/navUtils'; import Routes from '../../../../../constants/navigation/Routes'; @@ -45,13 +44,13 @@ function RampUnsupportedModal() { testID: RAMP_UNSUPPORTED_MODAL_TEST_IDS.CLOSE_BUTTON, }} > - + {strings('fiat_on_ramp_aggregator.unsupported_region_modal.title')} - + {strings( 'fiat_on_ramp_aggregator.unsupported_region_modal.description', )} @@ -62,12 +61,11 @@ function RampUnsupportedModal() { ); diff --git a/app/components/UI/Ramp/components/RampUnsupportedModal/__snapshots__/RampUnsupportedModal.test.tsx.snap b/app/components/UI/Ramp/components/RampUnsupportedModal/__snapshots__/RampUnsupportedModal.test.tsx.snap index 9a41efb4d40..d2f4118d737 100644 --- a/app/components/UI/Ramp/components/RampUnsupportedModal/__snapshots__/RampUnsupportedModal.test.tsx.snap +++ b/app/components/UI/Ramp/components/RampUnsupportedModal/__snapshots__/RampUnsupportedModal.test.tsx.snap @@ -351,13 +351,17 @@ exports[`RampUnsupportedModal renders modal with title and description 1`] = ` Unavailable in your region @@ -419,13 +423,17 @@ exports[`RampUnsupportedModal renders modal with title and description 1`] = ` Buying crypto isn't available in your region due to limitations with local payment providers or regulatory restrictions. @@ -445,42 +453,90 @@ exports[`RampUnsupportedModal renders modal with title and description 1`] = ` ] } > - Got it - + diff --git a/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx b/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx index 248cb3485a2..35912fc0ad5 100644 --- a/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx @@ -11,11 +11,11 @@ import BadgeNetwork from '../../../../../component-library/components/Badges/Bad import BadgeWrapper, { BadgePosition, } from '../../../../../component-library/components/Badges/BadgeWrapper'; -import Text, { +import { + Text, TextColor, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import { + FontWeight, ButtonIcon, ButtonIconSize, IconName, @@ -28,7 +28,7 @@ interface TokenListItemProps { token: DepositCryptoCurrency; isSelected?: boolean; onPress: () => void; - textColor?: string; + textColor?: TextColor; isDisabled?: boolean; onInfoPress?: () => void; } @@ -37,7 +37,7 @@ function TokenListItem({ token, isSelected, onPress, - textColor = TextColor.Alternative, + textColor = TextColor.TextAlternative, isDisabled = false, onInfoPress, }: Readonly) { @@ -78,8 +78,14 @@ function TokenListItem({ - {token.name} - + + {token.name} + + {token.symbol} diff --git a/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap b/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap index af0ec048537..f56cd39ab4d 100644 --- a/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap +++ b/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap @@ -186,13 +186,17 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` Ethereum @@ -200,13 +204,17 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` ETH @@ -403,13 +411,17 @@ exports[`TokenListItem basic rendering renders disabled token with info button a Ethereum @@ -417,13 +429,17 @@ exports[`TokenListItem basic rendering renders disabled token with info button a ETH diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.styles.ts b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.styles.ts index d00e96a7337..8e8edea970f 100644 --- a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.styles.ts +++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.styles.ts @@ -7,9 +7,6 @@ const styleSheet = () => flexDirection: 'row', gap: 8, }, - selectedNetworkIcon: { - marginRight: 8, - }, }); export default styleSheet; diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx index 5bdf16a6bcc..e50e0e07398 100644 --- a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx +++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx @@ -4,14 +4,14 @@ import { ScrollView } from 'react-native-gesture-handler'; import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; import AvatarNetwork from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarNetwork'; -import Button, { +import { + Button, ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; -import Text, { + ButtonVariant, + Text, TextColor, TextVariant, -} from '../../../../../component-library/components/Texts/Text'; +} from '@metamask/design-system-react-native'; import styleSheet from './TokenNetworkFilterBar.styles'; @@ -58,19 +58,20 @@ function TokenNetworkFilterBar({ > {networks.map((chainId) => { const isSelected = !isAllSelected && (networkFilter?.includes(chainId) ?? false); @@ -81,28 +82,27 @@ function TokenNetworkFilterBar({ ); })} diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/__snapshots__/TokenNetworkFilterBar.test.tsx.snap b/app/components/UI/Ramp/components/TokenNetworkFilterBar/__snapshots__/TokenNetworkFilterBar.test.tsx.snap index 826946d851b..f96c4d3946b 100644 --- a/app/components/UI/Ramp/components/TokenNetworkFilterBar/__snapshots__/TokenNetworkFilterBar.test.tsx.snap +++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/__snapshots__/TokenNetworkFilterBar.test.tsx.snap @@ -18,237 +18,426 @@ exports[`TokenNetworkFilterBar renders correctly with all networks selected (emp showsHorizontalScrollIndicator={false} > - - - All - - - + + All + + + + + - + > + + - - Ethereum - - - + Ethereum + + + + - + - + > + + - - Optimism - - - + Optimism + + + + - + - + > + + - - Polygon - - + + Polygon + + + `; @@ -271,237 +460,426 @@ exports[`TokenNetworkFilterBar renders correctly with all networks selected (nul showsHorizontalScrollIndicator={false} > - - - All - - - + + All + + + + + - + > + + - - Ethereum - - - + Ethereum + + + + - + - + > + + - - Optimism - - - + Optimism + + + + - + - + > + + - - Polygon - - + + Polygon + + + `; @@ -524,237 +902,426 @@ exports[`TokenNetworkFilterBar renders correctly with single network selected 1` showsHorizontalScrollIndicator={false} > - - - All - - - + + All + + + + + - + > + + - - Ethereum - - - + Ethereum + + + + - + - + > + + - - Optimism - - - + Optimism + + + + - + - + > + + - - Polygon - - + + Polygon + + + `; diff --git a/app/components/UI/Ramp/components/TruncatedError/TruncatedError.tsx b/app/components/UI/Ramp/components/TruncatedError/TruncatedError.tsx index c254ff5dae7..b66fa4019c8 100644 --- a/app/components/UI/Ramp/components/TruncatedError/TruncatedError.tsx +++ b/app/components/UI/Ramp/components/TruncatedError/TruncatedError.tsx @@ -7,15 +7,16 @@ import { type TextLayoutEventData, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import Text, { +import { + Text, + type TextProps, TextVariant, TextColor, -} from '../../../../../component-library/components/Texts/Text'; -import Icon, { + Icon, IconName, IconSize, IconColor, -} from '../../../../../component-library/components/Icons/Icon'; +} from '@metamask/design-system-react-native'; import { createErrorDetailsModalNavDetails } from '../../Views/Modals/ErrorDetailsModal/ErrorDetailsModal'; import { strings } from '../../../../../../locales/i18n'; @@ -96,11 +97,11 @@ const TruncatedError: React.FC = ({ return ( {hasMeasured && isTruncated @@ -113,7 +114,11 @@ const TruncatedError: React.FC = ({ accessibilityRole="button" accessibilityLabel="View error details" > - + ); diff --git a/app/components/UI/Ramp/components/TruncatedError/__snapshots__/TruncatedError.test.tsx.snap b/app/components/UI/Ramp/components/TruncatedError/__snapshots__/TruncatedError.test.tsx.snap index a70aab91d4e..935b3573be8 100644 --- a/app/components/UI/Ramp/components/TruncatedError/__snapshots__/TruncatedError.test.tsx.snap +++ b/app/components/UI/Ramp/components/TruncatedError/__snapshots__/TruncatedError.test.tsx.snap @@ -17,19 +17,24 @@ exports[`TruncatedError Basic rendering renders an empty error string 1`] = ` numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } /> @@ -79,19 +85,24 @@ exports[`TruncatedError Basic rendering renders correctly and matches snapshot 1 numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > This is a test error message @@ -110,17 +121,18 @@ exports[`TruncatedError Basic rendering renders correctly and matches snapshot 1 onPress={[Function]} > @@ -143,19 +155,24 @@ exports[`TruncatedError Basic rendering renders with custom maxLines prop 1`] = numberOfLines={3} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, - }, - "1": { - "opacity": 0, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + { + "opacity": 0, + }, + ], + ] } > This is a test error message @@ -174,17 +191,18 @@ exports[`TruncatedError Basic rendering renders with custom maxLines prop 1`] = onPress={[Function]} > @@ -207,17 +225,22 @@ exports[`TruncatedError Info icon visibility displays the error text after measu numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "1": false, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + false, + ], + ] } > Short error message @@ -236,17 +259,18 @@ exports[`TruncatedError Info icon visibility displays the error text after measu onPress={[Function]} > @@ -269,17 +293,22 @@ exports[`TruncatedError Truncation behavior shows fallback text when error is tr numberOfLines={1} onTextLayout={[Function]} style={ - { - "0": { - "flexShrink": 1, + [ + { + "color": "#ca3542", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, }, - "1": false, - "color": "#ca3542", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "flexShrink": 1, + }, + false, + ], + ] } > We've encountered an error @@ -298,17 +327,18 @@ exports[`TruncatedError Truncation behavior shows fallback text when error is tr onPress={[Function]} > From 3cb90d46b847159ddfd7fc01b851502f0c088cdf Mon Sep 17 00:00:00 2001 From: Gaurav Goel Date: Tue, 31 Mar 2026 01:47:48 +0530 Subject: [PATCH 5/7] chore: drop unused env flags (#28104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** * Simplify Metro OAuth module mocks use one variable, isE2E or E2E_MOCK_OAUTH=true at bundle time. * Removes unused Metro env flags. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Metro OAuth mocks for E2E/perf bundles Scenario: Production bundle without E2E mock flags Given Metro runs with IS_TEST not "true", METAMASK_ENVIRONMENT not "e2e", and E2E_MOCK_OAUTH not "true" When the app bundle is produced Then seedless-onboarding-controller and OAuthLoginHandlers resolve to real modules Scenario: E2E bundle Given Metro runs with IS_TEST="true" or METAMASK_ENVIRONMENT="e2e" When the app bundle is produced Then seedless-onboarding-controller and OAuthLoginHandlers can resolve to tests/module-mocking implementations ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2026-03-31 at 12 03 06 AM Screenshot 2026-03-31 at 12 03 28 AM ## **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 - [ ] 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] > **Medium Risk** > Touches Metro module resolution logic used during bundling; misconfigured env vars could unintentionally enable or disable seedless/OAuth mocks for test/perf builds. > > **Overview** > **Simplifies E2E Metro module mocks configuration.** Metro now enables both `seedless-onboarding-controller` and `OAuthLoginHandlers` redirects whenever standard E2E detection (`IS_TEST`/`METAMASK_ENVIRONMENT=e2e`) is true, *or* when `E2E_MOCK_OAUTH=true` is set at bundle time. > > Removes the previously supported fine-grained env toggles (and their documentation comments) in favor of the single override, and updates Appwright onboarding perf project comments accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bbfb85481b1ce63678b1101ea2b49aad09459d4f. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .js.env.example | 10 +-- metro.config.js | 86 ++++++++----------- tests/appwright.config.ts | 2 +- .../oauth/OAuthLoginHandlers/index.ts | 13 +++ 4 files changed, 52 insertions(+), 59 deletions(-) diff --git a/.js.env.example b/.js.env.example index 7524dbbbeae..83b4d97b81b 100644 --- a/.js.env.example +++ b/.js.env.example @@ -22,15 +22,7 @@ export SENTRY_DISABLE_AUTO_UPLOAD="true" # ENV vars for e2e tests # Only enable it for e2e tests export IS_TEST="false" -# Performance E2E (Appwright): set on the bundle step in CI so seedless/OAuth Metro mocks are explicitly opted in. -# Optional locally when pairing with METAMASK_ENVIRONMENT=e2e. -# export PERFORMANCE_TEST_JOB="true" -# Force-disable seedless + OAuth Metro redirects while keeping other E2E behavior (Sentry mocks, etc.): -# export E2E_USE_SEEDLESS_OAUTH_METRO_MOCK="false" -# Finer control: disable OAuth handler Metro mock for non-seedless -# onboarding perf builds; keep seedless perf on default (unset) so seedless-*.spec.js use mocks. -# export E2E_USE_OAUTH_LOGIN_HANDLERS_METRO_MOCK="false" -# export E2E_USE_SEEDLESS_CONTROLLER_METRO_MOCK="false" +# Seedless + OAuthLoginHandlers Metro mocks: on when IS_TEST/e2e OR E2E_MOCK_OAUTH=true at bundle time. # defined as secrets to run on Bitrise CI # but have to be defined here for local tests export MM_TEST_ACCOUNT_SRP="" diff --git a/metro.config.js b/metro.config.js index 508e86d7bd6..019ab6050ce 100644 --- a/metro.config.js +++ b/metro.config.js @@ -49,22 +49,12 @@ module.exports = function (baseConfig) { /** * E2E Metro redirects under tests/module-mocking. - * - PERFORMANCE_TEST_JOB / E2E_USE_SEEDLESS_OAUTH_METRO_MOCK - * - E2E_USE_SEEDLESS_CONTROLLER_METRO_MOCK: seedless-onboarding-controller mock (default ON) - * - E2E_USE_OAUTH_LOGIN_HANDLERS_METRO_MOCK: OAuthLoginHandlers mock (default ON) + * Enables both: seedless-onboarding-controller + OAuthLoginHandlers mocks. + * True when IS_TEST / METAMASK_ENVIRONMENT=e2e OR E2E_MOCK_OAUTH. */ - const e2eAllowsSeedlessOAuthMetroMocks = - isE2E && - (process.env.PERFORMANCE_TEST_JOB === 'true' || - process.env.E2E_USE_SEEDLESS_OAUTH_METRO_MOCK !== 'false'); + const isE2EMockOAuth = process.env.E2E_MOCK_OAUTH === 'true'; - const useE2ESeedlessControllerMetroMock = - e2eAllowsSeedlessOAuthMetroMocks && - process.env.E2E_USE_SEEDLESS_CONTROLLER_METRO_MOCK !== 'false'; - - const useE2EOAuthLoginHandlersMetroMock = - e2eAllowsSeedlessOAuthMetroMocks && - process.env.E2E_USE_OAUTH_LOGIN_HANDLERS_METRO_MOCK !== 'false'; + const e2eAllowsSeedlessOAuthMetroMocks = isE2E || isE2EMockOAuth; // For less powerful machines, leave room to do other tasks. For instance, // if you have 10 cores but only 16GB, only 3 workers would get used. @@ -169,42 +159,40 @@ module.exports = function (baseConfig) { ), }; } - if (useE2ESeedlessControllerMetroMock) { - if ( - moduleName.endsWith( - 'controllers/seedless-onboarding-controller', - ) || - moduleName.endsWith( - 'controllers/seedless-onboarding-controller/index', - ) || - moduleName === './seedless-onboarding-controller' || - moduleName === '../seedless-onboarding-controller' - ) { - return { - type: 'sourceFile', - filePath: path.resolve( - __dirname, - 'tests/module-mocking/seedless/index.ts', - ), - }; - } + } + if (e2eAllowsSeedlessOAuthMetroMocks) { + if ( + moduleName.endsWith( + 'controllers/seedless-onboarding-controller', + ) || + moduleName.endsWith( + 'controllers/seedless-onboarding-controller/index', + ) || + moduleName === './seedless-onboarding-controller' || + moduleName === '../seedless-onboarding-controller' + ) { + return { + type: 'sourceFile', + filePath: path.resolve( + __dirname, + 'tests/module-mocking/seedless/index.ts', + ), + }; } - if (useE2EOAuthLoginHandlersMetroMock) { - // Skips native Google/Apple UI; tokens still hit auth server (see module mock). - if ( - moduleName.endsWith('OAuthService/OAuthLoginHandlers') || - moduleName.endsWith('OAuthService/OAuthLoginHandlers/index') || - moduleName === './OAuthLoginHandlers' || - moduleName === '../OAuthLoginHandlers' - ) { - return { - type: 'sourceFile', - filePath: path.resolve( - __dirname, - 'tests/module-mocking/oauth/OAuthLoginHandlers/index.ts', - ), - }; - } + // Skips native Google/Apple UI; tokens still hit auth server (see module mock). + if ( + moduleName.endsWith('OAuthService/OAuthLoginHandlers') || + moduleName.endsWith('OAuthService/OAuthLoginHandlers/index') || + moduleName === './OAuthLoginHandlers' || + moduleName === '../OAuthLoginHandlers' + ) { + return { + type: 'sourceFile', + filePath: path.resolve( + __dirname, + 'tests/module-mocking/oauth/OAuthLoginHandlers/index.ts', + ), + }; } } return context.resolveRequest(context, moduleName, platform); diff --git a/tests/appwright.config.ts b/tests/appwright.config.ts index 677bba370ff..cfd336fde03 100644 --- a/tests/appwright.config.ts +++ b/tests/appwright.config.ts @@ -77,7 +77,7 @@ export default defineConfig({ { name: 'android-onboarding', // Exclude seedless OAuth perf — those run under android-onboarding-seedless with a binary - // built with OAuth Metro mocks enabled (see metro.config.js E2E_USE_OAUTH_LOGIN_HANDLERS_METRO_MOCK). + // built with seedless+OAuth Metro mocks testMatch: '**/performance/onboarding/**/*.spec.js', testIgnore: '**/performance/onboarding/seedless-*.spec.js', use: { diff --git a/tests/module-mocking/oauth/OAuthLoginHandlers/index.ts b/tests/module-mocking/oauth/OAuthLoginHandlers/index.ts index 7927350094c..4e2a6eddca0 100644 --- a/tests/module-mocking/oauth/OAuthLoginHandlers/index.ts +++ b/tests/module-mocking/oauth/OAuthLoginHandlers/index.ts @@ -13,6 +13,7 @@ import { AuthServerUrl, web3AuthNetwork, } from '../../../../app/core/OAuthService/OAuthLoginHandlers/constants'; +import type { BaseHandlerOptions } from '../../../../app/core/OAuthService/OAuthLoginHandlers/baseHandler'; /** * Login result type @@ -46,6 +47,8 @@ abstract class MockBaseLoginHandler { abstract scope: string[]; abstract authServerPath: string; + public options!: BaseHandlerOptions; + protected authServerUrl: string; protected web3AuthNetwork: string; protected nonce: string; @@ -124,6 +127,11 @@ class MockGoogleLoginHandler extends MockBaseLoginHandler { super(); this.clientId = params.clientId; this.redirectUri = params.redirectUri || 'metamask://'; + this.options = { + clientId: this.clientId, + authServerUrl: this.authServerUrl, + web3AuthNetwork: this.web3AuthNetwork, + }; } async login(): Promise { @@ -171,6 +179,11 @@ class MockAppleLoginHandler extends MockBaseLoginHandler { constructor(params: { clientId: string }) { super(); this.clientId = params.clientId; + this.options = { + clientId: this.clientId, + authServerUrl: this.authServerUrl, + web3AuthNetwork: this.web3AuthNetwork, + }; } async login(): Promise { From 65ebbe106b7547f8072ec5589adaaee47a12f9ad Mon Sep 17 00:00:00 2001 From: cloudonshore Date: Mon, 30 Mar 2026 16:30:27 -0400 Subject: [PATCH 6/7] fix: set is_smart_transaction=true for cancelled/dropped smart transactions (#26479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Cancelled or dropped smart transactions never get mined, so `getSmartTransactionByMinedTxHash()` returns nothing and `is_smart_transaction` was never set. This caused "Smart transaction failed" errors to appear under `is_smart_transaction=false` in metrics. Since `getSmartTransactionMetricsProperties()` is only called when smart transactions are enabled for the chain (gated by `selectShouldUseSmartTransaction` in `stx.ts`), we can safely return `{ is_smart_transaction: true }` even when the mined hash lookup fails — the transaction still went through the smart transaction flow. ### Context In PR #21027, `return {}` was intentionally introduced for the `!smartTransaction` case to fix a bug where `undefined` smart transactions were being misclassified as `is_smart_transaction: true` (due to `!smartTransaction?.statusMetadata` evaluating to `true` for `undefined`). That fix was correct for the general case, but it didn't account for cancelled/dropped smart transactions — these go through the STX flow but never produce a mined hash, so `getSmartTransactionByMinedTxHash()` returns nothing. Since this function is only reachable when STX is enabled for the chain, returning `{ is_smart_transaction: true }` here is safe and gives us accurate metrics for failed STX. ## **Changelog** CHANGELOG entry: null ## **Related issues** N/A ## **Manual testing steps** This is a metrics-only change. `is_smart_transaction` is not used in any business logic, UI rendering, or transaction routing — it is purely an analytics property on the "Transaction Finalized" event. Verification: 1. Confirm unit tests pass for `app/util/smart-transactions/index.test.ts` 2. Confirm that cancelled/dropped smart transactions now report `is_smart_transaction: true` in the "Transaction Finalized" event in Mixpanel ## **Screenshots/Recordings** N/A — no UI changes. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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] > **Low Risk** > Low risk metrics-only change: adjusts analytics properties when a smart transaction record can’t be found, with a unit test update to lock in the new behavior. > > **Overview** > `getSmartTransactionMetricsProperties` now returns `{ is_smart_transaction: true }` (instead of `{}`) when no smart transaction is found by mined hash, ensuring cancelled/dropped smart transactions are still classified as smart transactions in analytics. > > Updates the corresponding unit test to expect `is_smart_transaction: true` in this no-record/no-wait scenario. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8184363278c57f39975de4b41fbf6ef225030eef. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/util/smart-transactions/index.test.ts | 4 ++-- app/util/smart-transactions/index.ts | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/util/smart-transactions/index.test.ts b/app/util/smart-transactions/index.test.ts index 4bf1c7c7973..b45d700462a 100644 --- a/app/util/smart-transactions/index.test.ts +++ b/app/util/smart-transactions/index.test.ts @@ -104,7 +104,7 @@ describe('Smart Transactions utils', () => { }); }); - it('returns empty object if smartTransaction is not found and waitForSmartTransaction is false', async () => { + it('returns is_smart_transaction true if smartTransaction is not found and waitForSmartTransaction is false', async () => { const transactionMeta = { hash: '0x123' } as TransactionMeta; ( smartTransactionsController.getSmartTransactionByMinedTxHash as jest.Mock @@ -116,7 +116,7 @@ describe('Smart Transactions utils', () => { false, controllerMessenger, ); - expect(result).toEqual({}); + expect(result).toEqual({ is_smart_transaction: true }); }); it('returns correct object if smartTransaction is found but statusMetadata is undefined', async () => { diff --git a/app/util/smart-transactions/index.ts b/app/util/smart-transactions/index.ts index 05852ebb2da..4f7965adae3 100644 --- a/app/util/smart-transactions/index.ts +++ b/app/util/smart-transactions/index.ts @@ -44,7 +44,11 @@ export const getSmartTransactionMetricsProperties = async ( await waitForSmartTransactionConfirmationDone(controllerMessenger); } if (!smartTransaction) { - return {}; + // Still mark as smart transaction since this function is only called when + // smart transactions are enabled for the chain. Cancelled/dropped smart + // transactions won't have a mined tx hash, so the lookup above returns + // nothing, but the transaction still went through the smart transaction flow. + return { is_smart_transaction: true }; } if (!smartTransaction?.statusMetadata) { return { is_smart_transaction: true }; From 36ddf902b55926a88086244ae170edea7c646cae Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Mon, 30 Mar 2026 22:42:29 +0200 Subject: [PATCH 7/7] fix: prevent BridgeView from blocking zero-state trending scroll (#28103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Bridge zero-state scrolling could get stuck when a drag started from the amount area above trending tokens because the outer `BridgeView` wrapper always claimed the initial responder. This change keeps the existing blur-and-close behavior for other bridge states, but lets the scroll view own the gesture when zero-state trending content is visible so users can scroll naturally into the trending section. ## **Changelog** CHANGELOG entry: Fixed bridge zero-state trending scrolling when dragging from the amount area ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Bridge zero-state trending scroll Scenario: user scrolls into trending tokens from the amount area Given I am on the Bridge screen with swaps trending tokens enabled And I have not entered a source amount so the zero-state trending section is visible When user drags upward starting from the amount area toward the trending section Then the Bridge screen should scroll And the trending section should continue scrolling without getting stuck Scenario: user taps outside the input in non-zero bridge states Given I am on the Bridge screen with a positive source amount entered When user taps outside the source input area Then the source input should blur And the keypad should close ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **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] > **Low Risk** > Low risk UI gesture-handling change limited to the Bridge zero-state when the trending-tokens flag is enabled; main risk is unintended responder behavior differences in that specific mode. > > **Overview** > Prevents `BridgeView`’s outer wrapper from always claiming touch responder events by making `onStartShouldSetResponder` conditional. > > When the bridge is in **zero-state** and *trending tokens* are enabled, the wrapper no longer intercepts the initial gesture so the inner `ScrollView` can scroll normally; all other modes keep the existing tap-to-blur input and close keypad behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 61a894df48624072caad48cf158c81b47755a9dd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/UI/Bridge/Views/BridgeView/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Bridge/Views/BridgeView/index.tsx b/app/components/UI/Bridge/Views/BridgeView/index.tsx index 2fd220a5431..127b0ae5f19 100644 --- a/app/components/UI/Bridge/Views/BridgeView/index.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/index.tsx @@ -408,7 +408,9 @@ const BridgeView = () => { true} + onStartShouldSetResponder={() => + !(contentMode === 'zero' && isSwapsTrendingTokensEnabled) + } onResponderRelease={() => { inputRef.current?.blur(); keypadRef.current?.close();