From 0ac140697e688e8c9fb7d8b92b9f45b09149acb1 Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Tue, 26 May 2026 09:58:54 -0300 Subject: [PATCH 01/16] feat: implement Pay With Predict section (#30241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds the Predict section to the new "Pay with" bottom sheet, completing the dedicated-flow section coverage alongside the existing Perps and Crypto sections. When a user opens "Pay with" during a `predictDepositAndOrder` transaction, they now see a dedicated **Predict** section showing their Predict account balance with an **Add** button that routes to the existing add-funds flow. Tapping the balance row commits "use Predict balance" and dismisses the sheet — mirroring the UX the Perps section already provides for `perpsDepositAndOrder`. The Pay With bottom-sheet entry points inside the Predict order flow (the `PredictPayWithRow` and the "Change payment method" CTA on `PredictBuyWithAnyToken`) now route to the new bottom sheet instead of the legacy `PayWithModal` when the bottom-sheet feature is enabled. The synthetic "Predict balance" row that the legacy modal injects via `usePredictBalanceTokenFilter` is suppressed in that mode so the Predict section in the bottom sheet remains the single source of truth. The Crypto section's checkmark behavior is extended so that when Predict balance is the implicit default (`PredictController.selectedPaymentToken === null` on a `predictDepositAndOrder` flow), the preferred-token row no longer renders a misleading checkmark, and the user-selected-token row is hidden — same "fiat wins / dedicated section wins" semantics that already exist for Perps and fiat payment methods. When the user explicitly picks a crypto token via "Other assets", both pieces of state are updated coherently so the picker reflects the explicit selection. The bottom-sheet route is registered inside the Predict native stack so navigation dispatches from the order screen reach the route reliably, matching the same fix-pattern that was applied to the Perps native stack. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1363 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/657b12bf-5b0e-4ee3-a1e0-c6c6f9902281 ## **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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] 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 payment selection, navigation, and order/approval dismissal paths across Predict and confirmations, but follows established Perps patterns with broad test coverage. > > **Overview** > Adds a **Predict** section to the Pay with bottom sheet for `predictDepositAndOrder` flows: Predict account balance, **Add** (add-funds), and row tap to use Predict balance and dismiss—mirroring Perps. > > When `isPayWithBottomSheetEnabled()` is on, Predict buy UI (`PredictPayWithRow`, change payment method) navigates to `CONFIRMATION_PAY_WITH_BOTTOM_SHEET` instead of the legacy modal; those routes are registered on the Predict stack. The legacy modal’s injected Predict balance row (`usePredictBalanceTokenFilter`) is skipped so the bottom sheet Predict section is the single source of truth. > > Crypto section selection logic now treats implicit Predict balance like Perps/fiat: no misleading preferred-token checkmark and hidden user-selected row until the user explicitly picks crypto via Other assets; preferred-token taps on predict flows update both `PredictController` and transaction pay token. > > `PayWithBottomSheet` uses withdraw-specific copy where applicable, and `useDismissOnPaymentChange` can ignore pay-token hydration (`dismissOnPayTokenChange: false`). `usePredictPaymentToken` accepts minimal `{ address, chainId }` inputs for bottom-sheet picks. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit cd69bea240109acb4254eb056e5dd12816f020a5. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Goktug Poyraz Co-authored-by: Caainã Jeronimo --- .../usePredictBalanceTokenFilter.test.ts | 22 ++ .../hooks/usePredictBalanceTokenFilter.ts | 5 + .../Predict/hooks/usePredictPaymentToken.ts | 11 +- app/components/UI/Predict/routes/index.tsx | 19 ++ .../PredictBuyWithAnyToken.test.tsx | 26 +++ .../PredictBuyWithAnyToken.tsx | 6 +- .../PredictPayWithRow.test.tsx | 27 +++ .../PredictPayWithRow/PredictPayWithRow.tsx | 6 +- .../pay-with-bottom-sheet.test.tsx | 31 ++- .../pay-with-bottom-sheet.tsx | 13 +- .../confirmations/hooks/pay/sections/index.ts | 1 + .../sections/usePayWithCryptoSection.test.tsx | 128 +++++++++++ .../pay/sections/usePayWithCryptoSection.ts | 51 +++-- .../usePayWithPredictSection.test.tsx | 204 ++++++++++++++++++ .../pay/sections/usePayWithPredictSection.tsx | 116 ++++++++++ .../pay/useDismissOnPaymentChange.test.ts | 29 +++ .../hooks/pay/useDismissOnPaymentChange.ts | 20 +- .../hooks/pay/usePayWithSections.test.ts | 50 ++++- .../hooks/pay/usePayWithSections.ts | 11 +- locales/languages/en.json | 4 + 20 files changed, 748 insertions(+), 32 deletions(-) create mode 100644 app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.test.tsx create mode 100644 app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.tsx diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts index be73be4ec94..cf413973e05 100644 --- a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts +++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts @@ -6,6 +6,7 @@ import { isHighlightedItemInAssetList, } from '../../../Views/confirmations/types/token'; import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; +import { isPayWithBottomSheetEnabled } from '../../../Views/confirmations/utils/transaction-pay'; import { usePredictBalanceTokenFilter } from './usePredictBalanceTokenFilter'; import { dismissActivePreviewSheet } from '../contexts'; import Routes from '../../../../constants/navigation/Routes'; @@ -53,6 +54,11 @@ jest.mock('../../../Views/confirmations/utils/transaction', () => ({ hasTransactionType: jest.fn(), })); +jest.mock('../../../Views/confirmations/utils/transaction-pay', () => ({ + ...jest.requireActual('../../../Views/confirmations/utils/transaction-pay'), + isPayWithBottomSheetEnabled: jest.fn(() => false), +})); + const mockOnReject = jest.fn(); jest.mock('../../../Views/confirmations/hooks/useApprovalRequest', () => ({ __esModule: true, @@ -67,6 +73,10 @@ const mockHasTransactionType = hasTransactionType as jest.MockedFunction< typeof hasTransactionType >; const mockUseSelector = useSelector as jest.MockedFunction; +const mockIsPayWithBottomSheetEnabled = + isPayWithBottomSheetEnabled as jest.MockedFunction< + typeof isPayWithBottomSheetEnabled + >; const createMockToken = (overrides?: Partial): AssetType => ({ address: '0xtoken1', @@ -94,6 +104,7 @@ describe('usePredictBalanceTokenFilter', () => { mockHasTransactionType.mockReturnValue(false); mockUseSelector.mockReturnValue({ image: 'pusd-token-image' }); mockNavigate.mockReset(); + mockIsPayWithBottomSheetEnabled.mockReturnValue(false); mockOnReject.mockReset(); }); @@ -118,6 +129,17 @@ describe('usePredictBalanceTokenFilter', () => { expect(isHighlightedItemInAssetList(filteredTokens[0])).toBe(true); }); + it('suppresses the Predict balance HighlightedItem when isPayWithBottomSheetEnabled returns true', () => { + const tokens = [createMockToken()]; + mockHasTransactionType.mockReturnValue(true); + mockIsPayWithBottomSheetEnabled.mockReturnValue(true); + + const { result } = renderHook(() => usePredictBalanceTokenFilter()); + const filteredTokens = result.current(tokens); + + expect(filteredTokens).toEqual(tokens); + }); + it('prepends Predict balance HighlightedItem when forceEnabled is true', () => { const tokens = [createMockToken()]; mockHasTransactionType.mockReturnValue(false); diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts index cab752f7300..ed85d60ddf6 100644 --- a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts +++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts @@ -9,6 +9,7 @@ import { RootState } from '../../../../reducers'; import { selectSingleTokenByAddressAndChainId } from '../../../../selectors/tokensController'; import useFiatFormatter from '../../SimulationDetails/FiatDisplay/useFiatFormatter'; import { POLYGON_PUSD } from '../../../Views/confirmations/constants/predict'; +import { isPayWithBottomSheetEnabled } from '../../../Views/confirmations/utils/transaction-pay'; import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import { AssetType, @@ -60,6 +61,10 @@ export function usePredictBalanceTokenFilter( return tokens; } + if (isPayWithBottomSheetEnabled()) { + return tokens; + } + const balanceStr = String(predictBalance); const balanceFormatted = formatFiat(new BigNumber(balanceStr)); diff --git a/app/components/UI/Predict/hooks/usePredictPaymentToken.ts b/app/components/UI/Predict/hooks/usePredictPaymentToken.ts index 158381f232e..4f6101e58d9 100644 --- a/app/components/UI/Predict/hooks/usePredictPaymentToken.ts +++ b/app/components/UI/Predict/hooks/usePredictPaymentToken.ts @@ -4,8 +4,13 @@ import Engine from '../../../../core/Engine'; import { AssetType } from '../../../Views/confirmations/types/token'; import { selectPredictSelectedPaymentToken } from '../selectors/predictController'; +export type PredictPaymentTokenInput = + | AssetType + | { address: string; chainId: string; symbol?: string } + | null; + export interface UsePredictPaymentTokenResult { - onPaymentTokenChange: (token: AssetType | null) => void; + onPaymentTokenChange: (token: PredictPaymentTokenInput) => void; isPredictBalanceSelected: boolean; selectedPaymentToken: { address: string; @@ -22,12 +27,12 @@ export function usePredictPaymentToken(): UsePredictPaymentTokenResult { const { PredictController } = Engine.context; const onPaymentTokenChange = useCallback( - (token: AssetType | null) => { + (token: PredictPaymentTokenInput) => { if (!token) { return; } - PredictController.selectPaymentToken(token); + PredictController.selectPaymentToken(token as AssetType); }, [PredictController], ); diff --git a/app/components/UI/Predict/routes/index.tsx b/app/components/UI/Predict/routes/index.tsx index 6791c09d258..ccab9aaaee2 100644 --- a/app/components/UI/Predict/routes/index.tsx +++ b/app/components/UI/Predict/routes/index.tsx @@ -7,6 +7,8 @@ import { transparentModalScreenOptions, } from '../../../../constants/navigation/clearStackNavigatorOptions'; import { Confirm } from '../../../Views/confirmations/components/confirm'; +import { PayWithBottomSheet } from '../../../Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet'; +import { PayWithModal } from '../../../Views/confirmations/components/modals/pay-with-modal/pay-with-modal'; import PredictMarketDetails from '../views/PredictMarketDetails'; import PredictUnavailableModal from '../views/PredictUnavailableModal'; import { useEmptyNavHeaderForConfirmations } from '../../../Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations'; @@ -120,6 +122,23 @@ const PredictScreenStack = () => { name={Routes.PREDICT.MARKET_DETAILS} component={PredictMarketDetails} /> + + + ); diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx index bfa38645c8f..3a7d8ae086a 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.test.tsx @@ -81,6 +81,14 @@ jest.mock('../../utils/format', () => ({ formatPrice: jest.fn((value: number) => `$${value.toFixed(2)}`), })); +let mockIsPayWithBottomSheetEnabled = false; +jest.mock('../../../../Views/confirmations/utils/transaction-pay', () => ({ + ...jest.requireActual( + '../../../../Views/confirmations/utils/transaction-pay', + ), + isPayWithBottomSheetEnabled: () => mockIsPayWithBottomSheetEnabled, +})); + jest.mock('../../hooks/usePredictActiveOrder', () => ({ usePredictActiveOrder: () => ({ isPlacingOrder: mockIsPlacingOrder, @@ -441,6 +449,7 @@ describe('PredictBuyWithAnyToken', () => { mockIsCurrentTokenInsufficient = false; mockHasAlternativeBalance = false; mockIsPaymentSelectorNavigationLocked = false; + mockIsPayWithBottomSheetEnabled = false; mockUseSelector.mockImplementation((selector) => { if (typeof selector === 'function') { return selector({ @@ -810,6 +819,23 @@ describe('PredictBuyWithAnyToken', () => { expect(mockHandleConfirm).not.toHaveBeenCalled(); }); + it('navigates to PayWithBottomSheet when Change Payment Method is pressed and isPayWithBottomSheetEnabled returns true', () => { + mockIsCurrentTokenInsufficient = true; + mockHasAlternativeBalance = true; + mockIsPayWithBottomSheetEnabled = true; + + renderWithProvider(); + fireEvent.press(screen.getByTestId('predict-buy-action-button')); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.CONFIRMATION_PAY_WITH_BOTTOM_SHEET, + ); + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.CONFIRMATION_PAY_WITH_MODAL, + ); + expect(mockLockPaymentSelectorNavigation).toHaveBeenCalledTimes(1); + }); + it('renders Add Funds mode (Case 2) when token is insufficient with no alternatives', () => { mockIsCurrentTokenInsufficient = true; mockHasAlternativeBalance = false; diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx index d3022883b77..f006ea80a1e 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx @@ -58,6 +58,7 @@ import { PredictNavigationParamList, } from '../../types/navigation'; import Routes from '../../../../../constants/navigation/Routes'; +import { isPayWithBottomSheetEnabled } from '../../../../Views/confirmations/utils/transaction-pay'; import { parseAnalyticsProperties } from '../../utils/analytics'; import { formatPrice } from '../../utils/format'; import { usePredictBuyError } from './hooks/usePredictBuyError'; @@ -274,7 +275,10 @@ const PredictBuyWithAnyToken = (props: PredictBuyPreviewProps) => { const handleChangePaymentMethod = useCallback(() => { lockPaymentSelectorNavigation(); - navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL); + const navigateTo = isPayWithBottomSheetEnabled() + ? Routes.CONFIRMATION_PAY_WITH_BOTTOM_SHEET + : Routes.CONFIRMATION_PAY_WITH_MODAL; + navigation.navigate(navigateTo); }, [lockPaymentSelectorNavigation, navigation]); const handleAddFunds = useCallback(() => { 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 5cd14184088..5b123585d6f 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 @@ -69,6 +69,17 @@ jest.mock('../../../../../../Views/confirmations/utils/transaction', () => ({ }, })); +let mockIsPayWithBottomSheetEnabled = false; +jest.mock( + '../../../../../../Views/confirmations/utils/transaction-pay', + () => ({ + ...jest.requireActual( + '../../../../../../Views/confirmations/utils/transaction-pay', + ), + isPayWithBottomSheetEnabled: () => mockIsPayWithBottomSheetEnabled, + }), +); + jest.mock('../../../../../../../../locales/i18n', () => ({ strings: (key: string) => { if (key === 'confirm.label.pay_with') return 'Pay with'; @@ -109,6 +120,7 @@ describe('PredictPayWithRow', () => { mockSelectedPaymentToken = null; mockIsHardwareAccount.mockReturnValue(false); mockHasTransactionType = true; + mockIsPayWithBottomSheetEnabled = false; }); it('renders label with payToken symbol', () => { @@ -165,6 +177,21 @@ describe('PredictPayWithRow', () => { ); }); + it('navigates to pay-with bottom sheet when isPayWithBottomSheetEnabled returns true', () => { + mockIsPayWithBottomSheetEnabled = true; + + renderWithProvider(); + + fireEvent.press(screen.getByText('Pay with USDC')); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.CONFIRMATION_PAY_WITH_BOTTOM_SHEET, + ); + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.CONFIRMATION_PAY_WITH_MODAL, + ); + }); + it('calls onPaymentSelectorOpen before navigating to pay-with modal', () => { const callOrder: string[] = []; const onPaymentSelectorOpen = jest.fn(() => callOrder.push('lock')); 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 44bd7d28144..01bbe61c9e0 100644 --- a/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx +++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx @@ -28,6 +28,7 @@ import { } from '../../../../../../Views/confirmations/components/token-icon'; import { isHardwareAccount } from '../../../../../../../util/address'; import { POLYGON_PUSD } from '../../../../../../Views/confirmations/constants/predict'; +import { isPayWithBottomSheetEnabled } from '../../../../../../Views/confirmations/utils/transaction-pay'; import { usePredictPaymentToken } from '../../../../hooks/usePredictPaymentToken'; import { PREDICT_BALANCE_CHAIN_ID } from '../../../../constants/transactions'; import { usePredictDefaultPaymentToken } from '../../hooks/usePredictDefaultPaymentToken'; @@ -69,7 +70,10 @@ export function PredictPayWithRow({ const handlePress = useCallback(() => { if (!canEdit) return; onPaymentSelectorOpen?.(); - navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL); + const navigateTo = isPayWithBottomSheetEnabled() + ? Routes.CONFIRMATION_PAY_WITH_BOTTOM_SHEET + : Routes.CONFIRMATION_PAY_WITH_MODAL; + navigation.navigate(navigateTo); }, [canEdit, navigation, onPaymentSelectorOpen]); const label = strings('confirm.label.pay_with'); diff --git a/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.test.tsx b/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.test.tsx index 1d0b1ff393a..903d513db8b 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.test.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.test.tsx @@ -5,6 +5,9 @@ import { PAY_WITH_BOTTOM_SHEET_TEST_ID, } from './pay-with-bottom-sheet'; import { usePayWithSections } from '../../../hooks/pay/usePayWithSections'; +import { useDismissOnPaymentChange } from '../../../hooks/pay/useDismissOnPaymentChange'; +import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; +import { isTransactionPayWithdraw } from '../../../utils/transaction'; import { PayWithSectionConfig } from './pay-with-bottom-sheet.types'; jest.mock('../../../../../../../locales/i18n', () => ({ @@ -13,6 +16,13 @@ jest.mock('../../../../../../../locales/i18n', () => ({ jest.mock('../../../hooks/pay/usePayWithSections'); jest.mock('../../../hooks/pay/useDismissOnPaymentChange'); +jest.mock('../../../hooks/transactions/useTransactionMetadataRequest', () => ({ + useTransactionMetadataRequest: jest.fn(() => undefined), +})); +jest.mock('../../../utils/transaction', () => ({ + ...jest.requireActual('../../../utils/transaction'), + isTransactionPayWithdraw: jest.fn(() => false), +})); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -20,9 +30,11 @@ jest.mock('@react-navigation/native', () => ({ })); jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); const ReactActual = jest.requireActual('react'); const { View: RNView, Text: RNText } = jest.requireActual('react-native'); return { + ...actual, BottomSheet: ReactActual.forwardRef( ( { children, testID }: { children: React.ReactNode; testID?: string }, @@ -35,7 +47,6 @@ jest.mock('@metamask/design-system-react-native', () => { Text: ({ children, ...props }: { children: React.ReactNode }) => ( {children} ), - TextVariant: { HeadingSm: 'heading-sm' }, }; }); @@ -52,6 +63,11 @@ jest.mock('../../UI/pay-with-section', () => { }); const usePayWithSectionsMock = jest.mocked(usePayWithSections); +const useDismissOnPaymentChangeMock = jest.mocked(useDismissOnPaymentChange); +const useTransactionMetadataRequestMock = jest.mocked( + useTransactionMetadataRequest, +); +const isTransactionPayWithdrawMock = jest.mocked(isTransactionPayWithdraw); describe('PayWithBottomSheet', () => { beforeEach(() => { @@ -64,6 +80,9 @@ describe('PayWithBottomSheet', () => { expect(getByTestId(PAY_WITH_BOTTOM_SHEET_TEST_ID)).toBeOnTheScreen(); expect(getByText('confirm.pay_with_bottom_sheet.title')).toBeOnTheScreen(); + expect(useDismissOnPaymentChangeMock).toHaveBeenCalledWith({ + dismissOnPayTokenChange: false, + }); }); it('renders no sections when usePayWithSections returns empty array', () => { @@ -74,6 +93,16 @@ describe('PayWithBottomSheet', () => { expect(queryByTestId('mock-section-crypto')).not.toBeOnTheScreen(); }); + it('renders the withdraw title when the transaction is a withdraw', () => { + isTransactionPayWithdrawMock.mockReturnValue(true); + + const { getByText } = render(); + + expect( + getByText('confirm.pay_with_bottom_sheet.withdraw_title'), + ).toBeOnTheScreen(); + }); + it('renders one section per config returned by usePayWithSections', () => { usePayWithSectionsMock.mockReturnValue({ sections: [ diff --git a/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.tsx b/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.tsx index 8c8b1f8015a..78e1de11310 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.tsx @@ -12,6 +12,8 @@ import { strings } from '../../../../../../../locales/i18n'; import PayWithSection from '../../UI/pay-with-section'; import { useDismissOnPaymentChange } from '../../../hooks/pay/useDismissOnPaymentChange'; import { usePayWithSections } from '../../../hooks/pay/usePayWithSections'; +import { isTransactionPayWithdraw } from '../../../utils/transaction'; +import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; export const PAY_WITH_BOTTOM_SHEET_TEST_ID = 'pay-with-bottom-sheet'; @@ -19,7 +21,12 @@ export function PayWithBottomSheet() { const sheetRef = useRef(null); const navigation = useNavigation(); const { sections } = usePayWithSections(); - useDismissOnPaymentChange(); + const transactionMeta = useTransactionMetadataRequest(); + useDismissOnPaymentChange({ dismissOnPayTokenChange: false }); + const isWithdraw = isTransactionPayWithdraw(transactionMeta); + const title = isWithdraw + ? strings('confirm.pay_with_bottom_sheet.withdraw_title') + : strings('confirm.pay_with_bottom_sheet.title'); const handleGoBack = useCallback(() => { navigation.goBack(); @@ -37,9 +44,7 @@ export function PayWithBottomSheet() { keyboardAvoidingViewEnabled={false} > - - {strings('confirm.pay_with_bottom_sheet.title')} - + {title} {sections.map((section) => ( diff --git a/app/components/Views/confirmations/hooks/pay/sections/index.ts b/app/components/Views/confirmations/hooks/pay/sections/index.ts index 46e934081da..3c911f7626f 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/index.ts +++ b/app/components/Views/confirmations/hooks/pay/sections/index.ts @@ -2,3 +2,4 @@ export { usePayWithCryptoSection } from './usePayWithCryptoSection'; export { usePayWithFiatSection } from './usePayWithFiatSection'; export { usePayWithMoneyAccountSection } from './usePayWithMoneyAccountSection'; export { usePayWithPerpsSection } from './usePayWithPerpsSection'; +export { usePayWithPredictSection } from './usePayWithPredictSection'; diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx index 99142404fb1..a8aa6f586c5 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx @@ -11,6 +11,7 @@ import { MUSD_TOKEN_ADDRESS } from '../../../../../UI/Earn/constants/musd'; import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; import { useIsPerpsBalanceSelected } from '../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { usePredictPaymentToken } from '../../../../../UI/Predict/hooks/usePredictPaymentToken'; import { useLastUsedPaymentMethod } from '../useLastUsedPaymentMethod'; import { usePayWithPreferredToken } from '../usePayWithPreferredToken'; import { usePayWithSelectedToken } from '../usePayWithSelectedToken'; @@ -41,6 +42,7 @@ jest.mock('../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'); jest.mock('../../transactions/useTransactionMetadataRequest'); jest.mock('../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'); jest.mock('../../../../../UI/Perps/hooks/usePerpsPaymentToken'); +jest.mock('../../../../../UI/Predict/hooks/usePredictPaymentToken'); jest.mock('../useLastUsedPaymentMethod'); jest.mock('../usePayWithPreferredToken'); jest.mock('../usePayWithSelectedToken'); @@ -77,6 +79,7 @@ describe('usePayWithCryptoSection', () => { const useLastUsedPaymentMethodMock = jest.mocked(useLastUsedPaymentMethod); const useIsPerpsBalanceSelectedMock = jest.mocked(useIsPerpsBalanceSelected); const usePerpsPaymentTokenMock = jest.mocked(usePerpsPaymentToken); + const usePredictPaymentTokenMock = jest.mocked(usePredictPaymentToken); const useTransactionPayFiatPaymentMock = jest.mocked( useTransactionPayFiatPayment, ); @@ -86,6 +89,8 @@ describe('usePayWithCryptoSection', () => { const selectTokenMock = jest.fn(); const setPayTokenMock = jest.fn(); const onPerpsPaymentTokenChangeMock = jest.fn(); + const onPredictPaymentTokenChangeMock = jest.fn(); + const resetPredictPaymentTokenMock = jest.fn(); const isLastUsedMock = jest.fn().mockReturnValue(false); beforeEach(() => { @@ -123,6 +128,12 @@ describe('usePayWithCryptoSection', () => { usePerpsPaymentTokenMock.mockReturnValue({ onPaymentTokenChange: onPerpsPaymentTokenChangeMock, }); + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPredictPaymentTokenChangeMock, + isPredictBalanceSelected: true, + selectedPaymentToken: null, + resetSelectedPaymentToken: resetPredictPaymentTokenMock, + }); useTransactionPayFiatPaymentMock.mockReturnValue(undefined); useTransactionPayTokenMock.mockReturnValue({ payToken: TOKEN_MOCK, @@ -533,6 +544,123 @@ describe('usePayWithCryptoSection', () => { expect(selectedRow).toBeUndefined(); }); + it('does not mark the preferred token row as selected on predictDepositAndOrder flows when Predict balance is the implicit default', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.predictDepositAndOrder, + } as never); + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPredictPaymentTokenChangeMock, + isPredictBalanceSelected: true, + selectedPaymentToken: null, + resetSelectedPaymentToken: resetPredictPaymentTokenMock, + }); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + const preferredRow = result.current?.rows.find( + (row) => row.id === 'crypto-preferred-token', + ); + + expect(preferredRow).toEqual( + expect.objectContaining({ + isSelected: false, + trailingElement: 'none', + }), + ); + }); + + it('still marks the preferred token row as selected on predictDepositAndOrder flows when the user explicitly picked the preferred token via "Other assets"', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.predictDepositAndOrder, + } as never); + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPredictPaymentTokenChangeMock, + isPredictBalanceSelected: false, + selectedPaymentToken: { + address: TOKEN_MOCK.address, + chainId: TOKEN_MOCK.chainId, + }, + resetSelectedPaymentToken: resetPredictPaymentTokenMock, + }); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + const preferredRow = result.current?.rows.find( + (row) => row.id === 'crypto-preferred-token', + ); + + expect(preferredRow).toEqual( + expect.objectContaining({ + isSelected: true, + trailingElement: 'checkmark', + }), + ); + }); + + it('routes the preferred-row tap through onPredictPaymentTokenChange AND setPayToken on predictDepositAndOrder flows', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.predictDepositAndOrder, + } as never); + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPredictPaymentTokenChangeMock, + isPredictBalanceSelected: true, + selectedPaymentToken: null, + resetSelectedPaymentToken: resetPredictPaymentTokenMock, + }); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + act(() => { + result.current?.rows[0].onPress?.(); + }); + + expect(onPredictPaymentTokenChangeMock).toHaveBeenCalledWith({ + address: TOKEN_MOCK.address, + chainId: TOKEN_MOCK.chainId, + }); + expect(setPayTokenMock).toHaveBeenCalledWith({ + address: TOKEN_MOCK.address, + chainId: TOKEN_MOCK.chainId, + }); + expect(onPerpsPaymentTokenChangeMock).not.toHaveBeenCalled(); + expect(goBackMock).toHaveBeenCalledTimes(1); + }); + + it('hides the user-selected token row when Predict balance is the implicit default on predictDepositAndOrder flows', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.predictDepositAndOrder, + } as never); + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPredictPaymentTokenChangeMock, + isPredictBalanceSelected: true, + selectedPaymentToken: null, + resetSelectedPaymentToken: resetPredictPaymentTokenMock, + }); + const distinctSelectedToken = { + ...TOKEN_MOCK, + address: SELECTED_TOKEN_MOCK.address, + symbol: SELECTED_TOKEN_MOCK.symbol, + }; + usePayWithPreferredTokenMock.mockReturnValue({ + hasTokens: true, + preferredToken: TOKEN_MOCK, + selectedToken: distinctSelectedToken, + }); + usePayWithSelectedTokenMock.mockReturnValue({ + isSelectedDistinctFromAutomatic: true, + selectedToken: SELECTED_TOKEN_MOCK, + selectToken: selectTokenMock, + }); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + const selectedRow = result.current?.rows.find( + (row) => row.id === 'crypto-selected-token', + ); + + expect(selectedRow).toBeUndefined(); + }); + it('does not assign a tap handler to the user-selected token row', () => { const distinctSelectedToken = { ...TOKEN_MOCK, diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts index 598176271c0..34e641e4778 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts @@ -18,7 +18,12 @@ import { PayWithSectionConfig, } from '../../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; import { useIsPerpsBalanceSelected } from '../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; -import { hasTransactionType } from '../../../utils/transaction'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { usePredictPaymentToken } from '../../../../../UI/Predict/hooks/usePredictPaymentToken'; +import { + hasTransactionType, + isTransactionPayWithdraw, +} from '../../../utils/transaction'; import { isMatchingPayToken, resolvePreferredPayToken, @@ -29,7 +34,6 @@ import { usePayWithPreferredToken } from '../usePayWithPreferredToken'; import { usePayWithSelectedToken } from '../usePayWithSelectedToken'; import { useTransactionPayFiatPayment } from '../useTransactionPayData'; import { useTransactionPayToken } from '../useTransactionPayToken'; -import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; interface PayWithCryptoSectionParams { @@ -69,17 +73,29 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { const { setPayToken } = useTransactionPayToken(); const { onPaymentTokenChange: onPerpsPaymentTokenChange } = usePerpsPaymentToken(); + const { + onPaymentTokenChange: onPredictPaymentTokenChange, + isPredictBalanceSelected, + } = usePredictPaymentToken(); const { isLastUsed } = useLastUsedPaymentMethod(); const isPerpsBalanceSelected = useIsPerpsBalanceSelected(); const isPerpsDepositAndOrder = hasTransactionType(transactionMeta, [ TransactionType.perpsDepositAndOrder, ]); + const isPredictDepositAndOrder = hasTransactionType(transactionMeta, [ + TransactionType.predictDepositAndOrder, + ]); const isPerpsBalanceImplicitlySelected = isPerpsDepositAndOrder && isPerpsBalanceSelected; + const isPredictBalanceImplicitlySelected = + isPredictDepositAndOrder && isPredictBalanceSelected; const fiatPayment = useTransactionPayFiatPayment(); const hasFiatPaymentSelected = Boolean(fiatPayment?.selectedPaymentMethodId); const isDedicatedSectionOwningSelection = - isPerpsBalanceImplicitlySelected || hasFiatPaymentSelected; + isPerpsBalanceImplicitlySelected || + isPredictBalanceImplicitlySelected || + hasFiatPaymentSelected; + const isWithdraw = isTransactionPayWithdraw(transactionMeta); const handleOtherAssetsPress = useCallback(() => { navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL, { @@ -97,14 +113,19 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { }; if (isPerpsDepositAndOrder) { onPerpsPaymentTokenChange(target); + } else if (isPredictDepositAndOrder) { + onPredictPaymentTokenChange(target); + setPayToken(target); } else { setPayToken(target); } navigation.goBack(); }, [ isPerpsDepositAndOrder, + isPredictDepositAndOrder, navigation, onPerpsPaymentTokenChange, + onPredictPaymentTokenChange, preferredToken, setPayToken, ]); @@ -127,15 +148,16 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { const rows: PayWithRowConfig[] = []; if (preferredToken) { - // When a dedicated section "owns" the selection (Perps balance is the - // implicit default in a perpsDepositAndOrder flow, OR a fiat payment - // method has been picked), the Crypto section's preferred-token row must - // not render a misleading checkmark, and the user-selected-token row is - // hidden below. When the user explicitly picks a crypto token via "Other - // assets" in a perps flow, `PerpsController` also stores it as - // `selectedPaymentToken`, and we honor that selection with a checkmark - // (handled by `isPerpsBalanceImplicitlySelected` being false in that - // case). + // When a dedicated section "owns" the selection — Perps balance is the + // implicit default in a perpsDepositAndOrder flow, Predict balance is + // the implicit default in a predictDepositAndOrder flow, OR a fiat + // payment method has been picked — the Crypto section's preferred-token + // row must not render a misleading checkmark, and the user-selected- + // token row is hidden below. When the user explicitly picks a crypto + // token via "Other assets" in a perps/predict flow, the respective + // controller also stores it as `selectedPaymentToken`, and we honor that + // selection with a checkmark (handled by `is*BalanceImplicitlySelected` + // being false in that case). const isPreferredTokenSelected = !isDedicatedSectionOwningSelection && isMatchingPayToken(selectedToken, preferredToken); @@ -194,7 +216,9 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { }), title: strings('confirm.pay_with_bottom_sheet.other_assets'), subtitle: strings( - 'confirm.pay_with_bottom_sheet.other_assets_description', + isWithdraw + ? 'confirm.pay_with_bottom_sheet.other_assets_withdraw_description' + : 'confirm.pay_with_bottom_sheet.other_assets_description', ), trailingElement: 'chevron', onPress: handleOtherAssetsPress, @@ -214,6 +238,7 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { isDedicatedSectionOwningSelection, isLastUsed, isSelectedDistinctFromAutomatic, + isWithdraw, preferredToken, preferredTokenBalance, selectedToken, diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.test.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.test.tsx new file mode 100644 index 00000000000..13ff3e27dc0 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.test.tsx @@ -0,0 +1,204 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useNavigation } from '@react-navigation/native'; +import { TransactionType } from '@metamask/transaction-controller'; +import { useSelector } from 'react-redux'; +import Routes from '../../../../../../constants/navigation/Routes'; +import useFiatFormatter from '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { usePredictBalance } from '../../../../../UI/Predict/hooks/usePredictBalance'; +import { usePredictPaymentToken } from '../../../../../UI/Predict/hooks/usePredictPaymentToken'; +import { dismissActivePreviewSheet } from '../../../../../UI/Predict/contexts'; +import useApprovalRequest from '../../useApprovalRequest'; +import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; +import { usePayWithPredictSection } from './usePayWithPredictSection'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: (key: string, params?: { balance?: string }) => { + const translations: Record = { + 'confirm.pay_with_bottom_sheet.predict': 'Predict', + 'confirm.pay_with_bottom_sheet.predict_account': 'Predict account', + 'confirm.pay_with_bottom_sheet.add': 'Add', + 'confirm.pay_with_bottom_sheet.available_balance': `${ + params?.balance ?? '' + } available`, + }; + return translations[key] ?? key; + }, +})); +jest.mock('../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'); +jest.mock('../../../../../UI/Predict/hooks/usePredictBalance'); +jest.mock('../../../../../UI/Predict/hooks/usePredictPaymentToken'); +jest.mock('../../../../../UI/Predict/contexts', () => ({ + dismissActivePreviewSheet: jest.fn(), +})); +jest.mock('../../useApprovalRequest'); +jest.mock('../../transactions/useTransactionMetadataRequest'); + +describe('usePayWithPredictSection', () => { + const useSelectorMock = jest.mocked(useSelector); + const useNavigationMock = jest.mocked(useNavigation); + const useFiatFormatterMock = jest.mocked(useFiatFormatter); + const usePredictBalanceMock = jest.mocked(usePredictBalance); + const usePredictPaymentTokenMock = jest.mocked(usePredictPaymentToken); + const useApprovalRequestMock = jest.mocked(useApprovalRequest); + const useTransactionMetadataRequestMock = jest.mocked( + useTransactionMetadataRequest, + ); + const dismissActivePreviewSheetMock = jest.mocked(dismissActivePreviewSheet); + + const navigateMock = jest.fn(); + const goBackMock = jest.fn(); + const onRejectMock = jest.fn(); + const resetSelectedPaymentTokenMock = jest.fn(); + const onPaymentTokenChangeMock = jest.fn(); + const formatFiatMock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + + formatFiatMock.mockImplementation( + (value: { toString: () => string }) => + `$${Number(value.toString()).toFixed(2)}`, + ); + + useNavigationMock.mockReturnValue({ + navigate: navigateMock, + goBack: goBackMock, + } as never); + + useFiatFormatterMock.mockReturnValue(formatFiatMock as never); + + useTransactionMetadataRequestMock.mockReturnValue({ + id: 'tx-1', + type: TransactionType.predictDepositAndOrder, + txParams: {}, + } as never); + + usePredictBalanceMock.mockReturnValue({ data: 250 } as never); + + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPaymentTokenChangeMock, + resetSelectedPaymentToken: resetSelectedPaymentTokenMock, + isPredictBalanceSelected: true, + selectedPaymentToken: null, + } as never); + + useApprovalRequestMock.mockReturnValue({ + onReject: onRejectMock, + } as never); + + useSelectorMock.mockReturnValue({ image: 'https://example.com/pusd.png' }); + }); + + it('returns null when the transaction type is not predictDepositAndOrder', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + id: 'tx-1', + type: TransactionType.predictDeposit, + txParams: {}, + } as never); + + const { result } = renderHook(() => usePayWithPredictSection()); + + expect(result.current).toBeNull(); + }); + + it('returns null when there is no transaction metadata', () => { + useTransactionMetadataRequestMock.mockReturnValue(undefined); + + const { result } = renderHook(() => usePayWithPredictSection()); + + expect(result.current).toBeNull(); + }); + + it('returns the predict section config with a single predict balance row when the transaction type is predictDepositAndOrder', () => { + const { result } = renderHook(() => usePayWithPredictSection()); + + expect(result.current).toEqual( + expect.objectContaining({ + id: 'predict', + title: 'Predict', + testID: 'pay-with-section-predict', + }), + ); + expect(result.current?.rows).toHaveLength(1); + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + id: 'predict-balance', + title: 'Predict account', + subtitle: '$250.00 available', + isSelected: true, + testID: 'pay-with-predict-section-balance-row', + }), + ); + }); + + it('reflects isPredictBalanceSelected from usePredictPaymentToken', () => { + usePredictPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPaymentTokenChangeMock, + resetSelectedPaymentToken: resetSelectedPaymentTokenMock, + isPredictBalanceSelected: false, + selectedPaymentToken: null, + } as never); + + const { result } = renderHook(() => usePayWithPredictSection()); + + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + isSelected: false, + }), + ); + }); + + it('treats a missing balance as zero', () => { + usePredictBalanceMock.mockReturnValue({ data: undefined } as never); + + const { result } = renderHook(() => usePayWithPredictSection()); + + expect(result.current?.rows[0].subtitle).toBe('$0.00 available'); + }); + + it('selects predict balance as payment token and dismisses the sheet when the row is pressed', () => { + const { result } = renderHook(() => usePayWithPredictSection()); + + act(() => { + result.current?.rows[0].onPress?.(); + }); + + expect(resetSelectedPaymentTokenMock).toHaveBeenCalledTimes(1); + expect(goBackMock).toHaveBeenCalledTimes(1); + }); + + it('navigates to the Predict add-funds sheet with autoDeposit when Add is pressed', () => { + const { result } = renderHook(() => usePayWithPredictSection()); + + const trailing = result.current?.rows[0].trailingElement as + | { props: { onPress: () => void } } + | undefined; + + act(() => { + trailing?.props.onPress(); + }); + + expect(onRejectMock).toHaveBeenCalledTimes(1); + expect(dismissActivePreviewSheetMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MODALS.ADD_FUNDS_SHEET, + params: { autoDeposit: true }, + }); + }); + + it('keeps the result reference stable across renders when nothing changes', () => { + const { result, rerender } = renderHook(() => usePayWithPredictSection()); + const firstResult = result.current; + + rerender(); + + expect(result.current).toBe(firstResult); + }); +}); diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.tsx new file mode 100644 index 00000000000..54966b71e6e --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPredictSection.tsx @@ -0,0 +1,116 @@ +import React, { useCallback, useMemo } from 'react'; +import { Image } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../../locales/i18n'; +import useFiatFormatter from '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { POLYGON_PUSD } from '../../../constants/predict'; +import { PREDICT_BALANCE_CHAIN_ID } from '../../../../../UI/Predict/constants/transactions'; +import { usePredictBalance } from '../../../../../UI/Predict/hooks/usePredictBalance'; +import { usePredictPaymentToken } from '../../../../../UI/Predict/hooks/usePredictPaymentToken'; +import { selectSingleTokenByAddressAndChainId } from '../../../../../../selectors/tokensController'; +import { RootState } from '../../../../../../reducers'; +import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; +import { + PayWithRowConfig, + PayWithSectionConfig, +} from '../../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; +import { hasTransactionType } from '../../../utils/transaction'; +import { dismissActivePreviewSheet } from '../../../../../UI/Predict/contexts'; +import useApprovalRequest from '../../useApprovalRequest'; + +export const PAY_WITH_PREDICT_SECTION_TEST_ID = 'pay-with-section-predict'; +export const PAY_WITH_PREDICT_BALANCE_ROW_TEST_ID = + 'pay-with-predict-section-balance-row'; + +export function usePayWithPredictSection(): PayWithSectionConfig | null { + const navigation = useNavigation(); + const transactionMeta = useTransactionMetadataRequest(); + const { onReject } = useApprovalRequest(); + const formatFiat = useFiatFormatter({ currency: 'usd' }); + const { data: predictBalance = 0 } = usePredictBalance(); + const { resetSelectedPaymentToken, isPredictBalanceSelected } = + usePredictPaymentToken(); + const pusdToken = useSelector((state: RootState) => + selectSingleTokenByAddressAndChainId( + state, + POLYGON_PUSD.address, + PREDICT_BALANCE_CHAIN_ID, + ), + ); + + const isPredictDepositAndOrder = hasTransactionType(transactionMeta, [ + TransactionType.predictDepositAndOrder, + ]); + + const balance = useMemo( + () => formatFiat(new BigNumber(String(predictBalance))), + [formatFiat, predictBalance], + ); + + const handleSelect = useCallback(() => { + resetSelectedPaymentToken(); + navigation.goBack(); + }, [navigation, resetSelectedPaymentToken]); + + const handleAdd = useCallback(() => { + onReject(); + dismissActivePreviewSheet(); + navigation.navigate(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MODALS.ADD_FUNDS_SHEET, + params: { autoDeposit: true }, + }); + }, [navigation, onReject]); + + return useMemo(() => { + if (!isPredictDepositAndOrder) { + return null; + } + + const row: PayWithRowConfig = { + id: 'predict-balance', + icon: React.createElement(Image, { + source: { uri: pusdToken?.image ?? '' }, + style: { width: 24, height: 24 }, + }), + title: strings('confirm.pay_with_bottom_sheet.predict_account'), + subtitle: strings('confirm.pay_with_bottom_sheet.available_balance', { + balance, + }), + isSelected: isPredictBalanceSelected, + trailingElement: ( + + ), + onPress: handleSelect, + testID: PAY_WITH_PREDICT_BALANCE_ROW_TEST_ID, + }; + + return { + id: 'predict', + title: strings('confirm.pay_with_bottom_sheet.predict'), + testID: PAY_WITH_PREDICT_SECTION_TEST_ID, + rows: [row], + }; + }, [ + balance, + handleAdd, + handleSelect, + isPredictBalanceSelected, + isPredictDepositAndOrder, + pusdToken, + ]); +} diff --git a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts index 8534c17efde..90fba2d0941 100644 --- a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts @@ -136,6 +136,21 @@ describe('useDismissOnPaymentChange', () => { expect(goBackMock).toHaveBeenCalledTimes(1); }); + + it('does not dismiss on pay token changes when pay token dismissal is disabled', () => { + const { rerender } = renderHook(() => + useDismissOnPaymentChange({ dismissOnPayTokenChange: false }), + ); + + useTransactionPayTokenMock.mockReturnValue({ + payToken: TOKEN_B, + setPayToken: setPayTokenMock, + }); + + rerender(); + + expect(goBackMock).not.toHaveBeenCalled(); + }); }); describe('fiat selection changes', () => { @@ -182,6 +197,20 @@ describe('useDismissOnPaymentChange', () => { expect(goBackMock).toHaveBeenCalledTimes(1); }); + + it('still dismisses on fiat selection changes when pay token dismissal is disabled', () => { + const { rerender } = renderHook(() => + useDismissOnPaymentChange({ dismissOnPayTokenChange: false }), + ); + + useTransactionPayFiatPaymentMock.mockReturnValue({ + selectedPaymentMethodId: 'pm-card', + }); + + rerender(); + + expect(goBackMock).toHaveBeenCalledTimes(1); + }); }); describe('atomic multi-field changes (regression for 3-pop cascade)', () => { diff --git a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts index 09393fa2f4e..bf7f7e15f46 100644 --- a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts +++ b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts @@ -4,17 +4,24 @@ import { isMatchingPayToken } from '../../utils/transaction-pay'; import { useTransactionPayFiatPayment } from './useTransactionPayData'; import { useTransactionPayToken } from './useTransactionPayToken'; +interface UseDismissOnPaymentChangeOptions { + dismissOnPayTokenChange?: boolean; +} + /** * Dismisses the current navigation route the first time the active - * transaction's payment selection changes after the component mounts. Used by - * `PayWithBottomSheet` so that picking a token in the underlying - * `PayWithModal` OR selecting a fiat payment method collapses the picker back - * to the confirmation screen. + * transaction's payment selection changes after the component mounts. By + * default this observes both transaction pay-token changes and fiat payment + * method changes. * * Initial values are captured on mount, so the hook does not fire for the * values that were already on the controller when the sheet opened. + * `dismissOnPayTokenChange` can be disabled for flows where the transaction + * pay token may still be hydrating in the background after the picker opens. */ -export function useDismissOnPaymentChange(): void { +export function useDismissOnPaymentChange({ + dismissOnPayTokenChange = true, +}: UseDismissOnPaymentChangeOptions = {}): void { const navigation = useNavigation(); const { payToken } = useTransactionPayToken(); const fiatPayment = useTransactionPayFiatPayment(); @@ -31,6 +38,7 @@ export function useDismissOnPaymentChange(): void { const initialPayToken = initialPayTokenRef.current; const payTokenMatchesInitial = + !dismissOnPayTokenChange || (!initialPayToken && !payToken) || (!!initialPayToken && !!payToken && @@ -53,5 +61,5 @@ export function useDismissOnPaymentChange(): void { isDismissingRef.current = true; navigation.goBack(); - }, [navigation, payToken, selectedPaymentMethodId]); + }, [dismissOnPayTokenChange, navigation, payToken, selectedPaymentMethodId]); } diff --git a/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts b/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts index d105cb43413..fccd0726e25 100644 --- a/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts +++ b/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts @@ -4,12 +4,14 @@ import { usePayWithCryptoSection } from './sections/usePayWithCryptoSection'; import { usePayWithFiatSection } from './sections/usePayWithFiatSection'; import { usePayWithMoneyAccountSection } from './sections/usePayWithMoneyAccountSection'; import { usePayWithPerpsSection } from './sections/usePayWithPerpsSection'; +import { usePayWithPredictSection } from './sections/usePayWithPredictSection'; import { usePayWithSections } from './usePayWithSections'; jest.mock('./sections/usePayWithCryptoSection'); jest.mock('./sections/usePayWithFiatSection'); jest.mock('./sections/usePayWithMoneyAccountSection'); jest.mock('./sections/usePayWithPerpsSection'); +jest.mock('./sections/usePayWithPredictSection'); const CRYPTO_SECTION_MOCK: PayWithSectionConfig = { id: 'crypto', @@ -35,6 +37,18 @@ const PERPS_SECTION_MOCK: PayWithSectionConfig = { ], }; +const PREDICT_SECTION_MOCK: PayWithSectionConfig = { + id: 'predict', + title: 'Predict', + rows: [ + { + id: 'predict-balance', + icon: 'Predict', + title: 'Predict account', + }, + ], +}; + const MONEY_ACCOUNT_SECTION_MOCK: PayWithSectionConfig = { id: 'money-account', title: 'Money account', @@ -66,6 +80,7 @@ describe('usePayWithSections', () => { usePayWithMoneyAccountSection, ); const usePayWithPerpsSectionMock = jest.mocked(usePayWithPerpsSection); + const usePayWithPredictSectionMock = jest.mocked(usePayWithPredictSection); beforeEach(() => { jest.resetAllMocks(); @@ -74,6 +89,7 @@ describe('usePayWithSections', () => { usePayWithFiatSectionMock.mockReturnValue(null); usePayWithMoneyAccountSectionMock.mockReturnValue(null); usePayWithPerpsSectionMock.mockReturnValue(null); + usePayWithPredictSectionMock.mockReturnValue(null); }); it('returns empty sections array when no section is visible', () => { @@ -98,6 +114,14 @@ describe('usePayWithSections', () => { expect(result.current.sections).toEqual([PERPS_SECTION_MOCK]); }); + it('returns the visible predict section', () => { + usePayWithPredictSectionMock.mockReturnValue(PREDICT_SECTION_MOCK); + + const { result } = renderHook(() => usePayWithSections()); + + expect(result.current.sections).toEqual([PREDICT_SECTION_MOCK]); + }); + it('returns the visible bank-card section when only bank-card is available', () => { usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); @@ -154,7 +178,26 @@ describe('usePayWithSections', () => { ]); }); - it('renders perps, bank-card, then crypto when all three sections are visible', () => { + it('orders sections [perps, predict, bank-card, crypto] when predict and perps are visible', () => { + usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); + usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); + usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); + usePayWithPredictSectionMock.mockReturnValue(PREDICT_SECTION_MOCK); + + const { result } = renderHook(() => usePayWithSections()); + + expect(result.current.sections).toEqual([ + PERPS_SECTION_MOCK, + PREDICT_SECTION_MOCK, + BANK_CARD_SECTION_MOCK, + CRYPTO_SECTION_MOCK, + ]); + }); + + it('renders money-account, perps, bank-card, then crypto when all four sections are visible (no predict)', () => { + usePayWithMoneyAccountSectionMock.mockReturnValue( + MONEY_ACCOUNT_SECTION_MOCK, + ); usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); @@ -162,25 +205,28 @@ describe('usePayWithSections', () => { const { result } = renderHook(() => usePayWithSections()); expect(result.current.sections).toEqual([ + MONEY_ACCOUNT_SECTION_MOCK, PERPS_SECTION_MOCK, BANK_CARD_SECTION_MOCK, CRYPTO_SECTION_MOCK, ]); }); - it('renders money-account, perps, bank-card, then crypto when all four sections are visible', () => { + it('orders all five sections [money-account, perps, predict, bank-card, crypto]', () => { usePayWithMoneyAccountSectionMock.mockReturnValue( MONEY_ACCOUNT_SECTION_MOCK, ); usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); + usePayWithPredictSectionMock.mockReturnValue(PREDICT_SECTION_MOCK); const { result } = renderHook(() => usePayWithSections()); expect(result.current.sections).toEqual([ MONEY_ACCOUNT_SECTION_MOCK, PERPS_SECTION_MOCK, + PREDICT_SECTION_MOCK, BANK_CARD_SECTION_MOCK, CRYPTO_SECTION_MOCK, ]); diff --git a/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts b/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts index 727b16087e2..e087d58604e 100644 --- a/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts +++ b/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts @@ -5,6 +5,7 @@ import { usePayWithFiatSection, usePayWithMoneyAccountSection, usePayWithPerpsSection, + usePayWithPredictSection, } from './sections'; export interface UsePayWithSectionsResult { @@ -14,6 +15,7 @@ export interface UsePayWithSectionsResult { export function usePayWithSections(): UsePayWithSectionsResult { const moneyAccountSection = usePayWithMoneyAccountSection(); const perpsSection = usePayWithPerpsSection(); + const predictSection = usePayWithPredictSection(); const bankCardSection = usePayWithFiatSection(); const cryptoSection = usePayWithCryptoSection(); @@ -22,11 +24,18 @@ export function usePayWithSections(): UsePayWithSectionsResult { sections: [ moneyAccountSection, perpsSection, + predictSection, bankCardSection, cryptoSection, ].filter(isPayWithSectionConfig), }), - [bankCardSection, cryptoSection, moneyAccountSection, perpsSection], + [ + bankCardSection, + cryptoSection, + moneyAccountSection, + perpsSection, + predictSection, + ], ); } diff --git a/locales/languages/en.json b/locales/languages/en.json index a80bf2ed637..f934a5f73e7 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7206,15 +7206,19 @@ "confirm": "Confirm", "pay_with_bottom_sheet": { "title": "Pay with", + "withdraw_title": "Withdraw as", "last_used": "Last used", "bank_and_card": "Bank and card", "crypto": "Crypto", "perps": "Perps", "perps_account": "Perps account", + "predict": "Predict", + "predict_account": "Predict account", "add": "Add", "available_balance": "{{balance}} available", "other_assets": "Other assets", "other_assets_description": "Select from your tokens", + "other_assets_withdraw_description": "Select the token you want to withdraw", "money_account": "Money account" }, "staking_footer": { From c72ed66608a001c935794a7164789e4543bd92df Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 26 May 2026 15:24:19 +0200 Subject: [PATCH 02/16] fix: dismiss AddWallet sheet before entering HW flow to fix post-connect navigation (#30623) ## **Description** ## **Changelog** CHANGELOG entry: dismiss AddWallet sheet before entering HW flow to fix post-connect navigation ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/29240 ## **Manual testing steps** ```gherkin Feature: view accounts list after adding HW Scenario: user adds HW Given the user has a HW connected When user finishes HW account selection and taps confirm Then they are taken back to the accounts list screen ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **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. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [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** > Small, route-specific navigation change with targeted unit tests; no auth, data, or import-flow behavior changes. > > **Overview** > When users start **Connect hardware** from `AddWallet`, the screen now calls **`navigation.goBack()`** right after navigating to the HW flow so the add-wallet sheet is removed from the stack. Hardware completion still uses **`pop(2)`** in the HW screens; without this dismiss, one of those pops returned to AddWallet instead of the accounts list (**AccountSelector**). > > Import wallet and import private key behavior is unchanged; tests now assert **`goBack` is not** invoked for those actions and **is** invoked once for hardware. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 68c80e604a359c0ca448744e739fc481f2e96ba6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/Views/AddWallet/AddWallet.test.tsx | 7 ++++++- app/components/Views/AddWallet/AddWallet.tsx | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/components/Views/AddWallet/AddWallet.test.tsx b/app/components/Views/AddWallet/AddWallet.test.tsx index 4bb0b1c78e9..bb2c02d52dd 100644 --- a/app/components/Views/AddWallet/AddWallet.test.tsx +++ b/app/components/Views/AddWallet/AddWallet.test.tsx @@ -96,6 +96,7 @@ describe('AddWallet', () => { fireEvent.press(screen.getByTestId(AddWalletTestIds.IMPORT_WALLET_BUTTON)); expect(mockedNavigate).toHaveBeenCalledWith(Routes.MULTI_SRP.IMPORT); + expect(mockedGoBack).not.toHaveBeenCalled(); expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.IMPORT_SECRET_RECOVERY_PHRASE_CLICKED, ); @@ -112,6 +113,7 @@ describe('AddWallet', () => { fireEvent.press(screen.getByTestId(AddWalletTestIds.IMPORT_ACCOUNT_BUTTON)); expect(mockedNavigate).toHaveBeenCalledWith(Routes.IMPORT_PRIVATE_KEY_VIEW); + expect(mockedGoBack).not.toHaveBeenCalled(); expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.ACCOUNTS_IMPORTED_NEW_ACCOUNT, ); @@ -120,7 +122,7 @@ describe('AddWallet', () => { ); }); - it('opens the hardware wallet flow', () => { + it('opens the hardware wallet flow and dismisses AddWallet', () => { renderScreen(() => , { name: 'AddWallet', }); @@ -130,6 +132,9 @@ describe('AddWallet', () => { ); expect(mockedNavigate).toHaveBeenCalledWith(Routes.HW.CONNECT); + // AddWallet must be dismissed so that pop(2) in the HW screens lands on + // AccountSelector rather than back on this screen. + expect(mockedGoBack).toHaveBeenCalledTimes(1); expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.ADD_HARDWARE_WALLET, ); diff --git a/app/components/Views/AddWallet/AddWallet.tsx b/app/components/Views/AddWallet/AddWallet.tsx index 23e387b0f53..6752c641bd1 100644 --- a/app/components/Views/AddWallet/AddWallet.tsx +++ b/app/components/Views/AddWallet/AddWallet.tsx @@ -79,6 +79,11 @@ const AddWallet = () => { const handleActionPress = useCallback( (config: ActionConfig) => { navigation.navigate(config.routeName as never); + // Dismiss AddWallet so that hardware wallet completion (pop(2) in HW + // screens) lands on AccountSelector rather than back here. + if (config.routeName === Routes.HW.CONNECT) { + navigation.goBack(); + } trackEvent(createEventBuilder(config.analyticsEvent).build()); }, [createEventBuilder, navigation, trackEvent], From 664966a5ec57c1f86fa7849187d76fbda358485a Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Tue, 26 May 2026 21:32:33 +0800 Subject: [PATCH 03/16] fix(perps): auto-detect and clean stale CocoaPods state in preflight (#30584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Pod install fails when `yarn.lock` changes bring new podspecs but `Podfile.lock` still pins old versions. This is common across independent repo clones on the same branch after pulling main — the `fast_float` podspec version mismatch being a frequent offender. Adds a per-worktree staleness marker (`.agent/build-cache/ios/pods-inputs.sha256`) that hashes `yarn.lock + ios/Podfile`. Before pod install in any mode, compares against the saved marker — if mismatched, auto-cleans `ios/Pods` and `Podfile.lock` before proceeding. ## **Changelog** CHANGELOG entry: null ## **Related issues** N/A — infrastructure fix for agentic preflight reliability. ## **Manual testing steps** ```gherkin Feature: Pod staleness auto-detection Scenario: preflight auto-cleans stale pods after pulling main Given a clone with an existing ios/Podfile.lock from a previous build And yarn.lock has changed since the last pod install When user runs yarn a:setup:ios or yarn a:ios Then preflight detects the staleness via marker mismatch And automatically cleans ios/Pods and ios/Podfile.lock And pod install succeeds without manual intervention ``` ## **Screenshots/Recordings** N/A — shell script change, no UI impact. ## **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. --- > [!NOTE] > **Low Risk** > Changes are limited to agentic preflight and local build-cache fingerprinting; no app runtime, auth, or release pipeline behavior. > > **Overview** > Agentic preflight and the native build-cache fingerprint are tuned so **Metro/debug workflows** stop paying for full native rebuilds when only JS or harness code changes, while **iOS pod state** self-heals after lockfile drift. > > **`compute-cache-fp.js`** adds `ignorePaths` for `app/**`, `scripts/perps/agentic/**`, `tsconfig.json`, and generated CocoaPods outputs (`ios/Podfile.lock`, `ios/Pods/**`). Debug preflight still keys native reuse on real binary inputs (locks, native dirs, patches, etc.) without treating pod-install side effects or live-served JS as fingerprint churn. > > **`preflight.sh`** introduces a per-worktree marker (`.agent/build-cache/ios/pods-inputs.sha256`) from `yarn.lock` + `ios/Podfile`. Before `pod install`, a mismatch deletes `ios/Pods` and `Podfile.lock`; successful installs (including `--repo-update` retry) refresh the marker. **`--clean`** always wipes pod state; non-clean modes auto-clean only when stale. A separate **`js_dependencies_need_install`** gate runs `yarn install --immutable` when `package.json`/`yarn.lock` beat `.yarn-state.yml` or `expo` is missing, with `--check-only` failing loud instead of mutating. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d4fae0b4c2fc11788cf8e3ac0f341c95c3463208. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- scripts/perps/agentic/lib/compute-cache-fp.js | 13 +++ scripts/perps/agentic/preflight.sh | 94 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/scripts/perps/agentic/lib/compute-cache-fp.js b/scripts/perps/agentic/lib/compute-cache-fp.js index 6296495021c..785b201601f 100644 --- a/scripts/perps/agentic/lib/compute-cache-fp.js +++ b/scripts/perps/agentic/lib/compute-cache-fp.js @@ -61,6 +61,19 @@ const options = { // extraSources entry above; ignore the generated copy so we don't // double-count it on rebuild. 'android/app/src/main/assets/InpageBridgeWeb3.js', + // Debug preflight runs against Metro. App JS, recipes, and harness scripts + // are served/read live from the worktree, so changing them must not force a + // native rebuild. Native-affecting inputs remain covered by Expo's default + // fingerprint plus projectConfig.extraSources (package/yarn lock, ios/, android/, + // app config, patches, react-native config, build/setup scripts, etc.). + 'app/**', + 'scripts/perps/agentic/**', + 'tsconfig.json', + // Podfile.lock can be rewritten by the pod-install step during the build. + // Key off the source inputs instead (yarn.lock + ios/Podfile) so a freshly + // built app does not invalidate itself on the next preflight. + 'ios/Podfile.lock', + 'ios/Pods/**', ], }; diff --git a/scripts/perps/agentic/preflight.sh b/scripts/perps/agentic/preflight.sh index c688afe9bd4..1e2d6fdbf23 100755 --- a/scripts/perps/agentic/preflight.sh +++ b/scripts/perps/agentic/preflight.sh @@ -141,6 +141,44 @@ else BUILD_CACHE_ENABLED=false fi +# ── Pod staleness detection ──────────────────────────────────────── +# Hash yarn.lock + ios/Podfile to detect when Pods/Podfile.lock are stale. +# Each worktree stores its own marker so independent clones don't collide. +PODS_MARKER_DIR=".agent/build-cache/ios" +PODS_MARKER_FILE="$PODS_MARKER_DIR/pods-inputs.sha256" + +pods_input_hash() { + # yarn.lock drives which podspecs land in node_modules; Podfile controls + # which pods are requested. Together they determine the expected pod state. + { cat yarn.lock ios/Podfile 2>/dev/null || true; } | shasum -a 256 | cut -d' ' -f1 +} + +pods_are_stale() { + [ ! -f ios/Podfile.lock ] && return 1 # no lock = nothing to be stale + local current saved + current=$(pods_input_hash) + if [ -f "$PODS_MARKER_FILE" ]; then + saved=$(cat "$PODS_MARKER_FILE") + [ "$current" != "$saved" ] + else + return 0 # no marker = never validated, treat as stale + fi +} + +pods_save_marker() { + mkdir -p "$PODS_MARKER_DIR" + pods_input_hash > "$PODS_MARKER_FILE" +} + +pods_clean_stale() { + if pods_are_stale; then + echo " Pods inputs changed (yarn.lock / Podfile) — cleaning stale pod state..." + rm -rf ios/Pods ios/Podfile.lock + return 0 + fi + return 1 +} + # ── Platform detection ───────────────────────────────────────────── detect_platform() { if [ -n "$FORCE_PLATFORM" ]; then echo "$FORCE_PLATFORM"; return; fi @@ -276,6 +314,19 @@ run_with_live_log() { return $rc } +js_dependencies_need_install() { + # Worktrees often survive branch switches. If package.json / yarn.lock are + # newer than Yarn's node_modules state, or if a required workspace binary is + # missing, reconcile node_modules before invoking Expo. This preserves the + # normal `yarn expo ...` path while fixing stale installs at the source. + [ ! -d node_modules ] && return 0 + [ ! -f node_modules/.yarn-state.yml ] && return 0 + [ -f package.json ] && [ package.json -nt node_modules/.yarn-state.yml ] && return 0 + [ -f yarn.lock ] && [ yarn.lock -nt node_modules/.yarn-state.yml ] && return 0 + yarn bin expo >/dev/null 2>&1 || return 0 + return 1 +} + # ── Early fixture validation (fail fast before long pipeline) ──────── if $DO_WALLET_SETUP; then if [ ! -f "$WALLET_FIXTURE" ]; then @@ -312,6 +363,14 @@ fi mkdir -p "$LOG_DIR" +JS_DEPS_STALE=false +if ! $DO_CLEAN && js_dependencies_need_install; then + if $CHECK_ONLY; then + fail "JS dependencies are stale or missing required bins (run without --check-only to reconcile node_modules)" + fi + JS_DEPS_STALE=true +fi + # Timing PREFLIGHT_START=$(python3 -c "import time; print(int(time.time()))") STEP_START=$PREFLIGHT_START @@ -322,6 +381,7 @@ elapsed_since() { echo $(( $(python3 -c "import time; print(int(time.time()))") # Compute total steps based on flags TOTAL_STEPS=4 # device + app + metro + cdp $DO_CLEAN && TOTAL_STEPS=$((TOTAL_STEPS + 1)) +$JS_DEPS_STALE && TOTAL_STEPS=$((TOTAL_STEPS + 1)) ($DO_WALLET_SETUP || [ -n "$WALLET_PW" ]) && TOTAL_STEPS=$((TOTAL_STEPS + 1)) CURRENT_STEP=0 CURRENT_STEP_NAME="" @@ -400,6 +460,20 @@ sweep_port "$PORT" "worktree Metro" # expo CLI's hardcoded default — any prior run without --port leaks here. [ "$PORT" != "8081" ] && sweep_port 8081 "expo default" +# ── Step: reconcile stale node_modules (default/auto/fast modes) ────── +if $JS_DEPS_STALE; then + step "Reconciling JS dependencies" "yarn install --immutable (package/yarn state changed or expo bin missing)" + stage_log "$DEPS_LOG" + printf '%s\n' '$ yarn install --immutable' > "$DEPS_LOG" + if ! run_with_live_log "$DEPS_LOG" "yarn install --immutable"; then + echo "" + echo -e " ${RED}Dependency reconciliation failed — see $DEPS_LOG${NC}" + tail -20 "$DEPS_LOG" | sed 's/^/ /' + fail "yarn install --immutable failed" + fi + ok "node_modules reconciled" +fi + # ── Step: yarn setup (clean only) ──────────────────────────────────── # --check-only is read-only by contract; refuse a destructive yarn setup # combo loudly instead of running it briefly and then early-exiting. @@ -411,6 +485,12 @@ if $DO_CLEAN; then step "Installing dependencies" "rm ios/build → yarn setup (install deps + patches + pods)" echo " Cleaning iOS build artifacts..." rm -rf ios/build + # Always clean stale pod state in --clean mode to prevent version mismatches + # between Podfile.lock and podspecs that changed in node_modules. + pods_clean_stale || { + echo " Pods inputs unchanged — cleaning anyway (--clean mode)..." + rm -rf ios/Pods ios/Podfile.lock + } else step "Installing dependencies" "clean android build → yarn setup (install deps + patches)" echo " Cleaning Android build artifacts..." @@ -424,6 +504,9 @@ if $DO_CLEAN; then tail -20 "$DEPS_LOG" | sed 's/^/ /' fail "yarn setup failed" fi + if [ "$PLAT" = "ios" ]; then + pods_save_marker + fi ok "yarn setup complete" fi @@ -544,6 +627,13 @@ if [ "$PLAT" = "ios" ]; then # Skip --repo-update unless --mode clean: it re-pulls every CocoaPods # spec (~3-5 min) on every dispatch. Plain `pod install` is sufficient # whenever Podfile.lock pods are already present in the local spec repo. + # + # Auto-clean stale Pods before pod install in any mode. This prevents + # version mismatches when yarn.lock changes bring new podspecs but + # Podfile.lock still pins old versions (common across independent clones). + if ! $DO_CLEAN; then + pods_clean_stale && warn "Stale pod state auto-cleaned" + fi if $DO_CLEAN; then POD_CMD="cd ios && bundle exec pod install --repo-update --ansi" else @@ -553,12 +643,16 @@ if [ "$PLAT" = "ios" ]; then stage_log "$POD_INSTALL_LOG" printf '$ (%s)\n' "$POD_CMD" > "$POD_INSTALL_LOG" if run_with_live_log "$POD_INSTALL_LOG" "$POD_CMD"; then + pods_save_marker ok "pod install complete" else # On non-clean modes, the failure may be a missing spec → retry once with --repo-update. if ! $DO_CLEAN; then warn "pod install failed — retrying with --repo-update" + # Clean Pods before retry — the lock file may be the cause. + rm -rf ios/Pods ios/Podfile.lock if run_with_live_log "$POD_INSTALL_LOG" "cd ios && bundle exec pod install --repo-update --ansi"; then + pods_save_marker ok "pod install complete (after --repo-update retry)" else warn "pod install had issues — see $POD_INSTALL_LOG" From dbbf7e1942e1f6820c7d7822078807bee4cf7d53 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Tue, 26 May 2026 21:48:10 +0800 Subject: [PATCH 04/16] =?UTF-8?q?fix(perps):=20perps=20Mobile:=20Screen=20?= =?UTF-8?q?transition=20from=20market=20detail=20to=20order=20entry=20is?= =?UTF-8?q?=20down=E2=86=92up=20instead=20of=20left=E2=86=92right=20(#3057?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The screen transition from market detail to order entry (Long/Short) animated bottom→up (modal style) instead of the expected left→right (push style). Root cause: `getRedesignedConfirmationsHeaderOptions()` applied `transparentModalScreenOptions` (setting `presentation: 'transparentModal'`) when `showPerpsHeader` is `false`. Removed the modal presentation options so the native stack uses default push animation. ## **Changelog** CHANGELOG entry: Fixed screen transition from market detail to order entry to use horizontal push animation instead of vertical modal animation ## **Related issues** Fixes: [TAT-3052](https://consensyssoftware.atlassian.net/browse/TAT-3052) ## **Manual testing steps** ```gherkin Feature: Market detail to order entry transition Scenario: user taps Long on market detail page Given wallet is unlocked and on the Perps tab When user taps any market to open market detail And user taps the Long or Short button Then the order entry screen slides in from right (horizontal push transition) And the order entry screen renders correctly with all fields visible ``` ## **Screenshots/Recordings** Fix removes transparentModal presentation from order entry screen options, changing animation from bottom-up modal to left-right push. **Video**
Before
https://github.com/user-attachments/assets/cd24731d-5771-4735-b8da-add84d67b43f
After
https://github.com/user-attachments/assets/8ff9a06f-53a6-4357-87b3-01344a1c8a21
## **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. ## **Validation Recipe**
recipe.json ```json { "title": "Verify market detail to order entry uses horizontal push transition (not modal)", "jira": "TAT-3052", "acceptance_criteria": [ "AC1: Tapping Long/Short on market detail triggers left-right push transition (no transparentModal presentation)", "AC2: Order entry screen renders correctly after navigation from market detail" ], "validate": { "workflow": { "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"], "entry": "setup-nav-market", "nodes": { "setup-nav-market": { "action": "call", "ref": "perps/market-discovery", "params": { "symbol": "BTC" }, "next": "setup-wait-buttons" }, "setup-wait-buttons": { "action": "wait_for", "test_id": "perps-market-details-long-button", "timeout_ms": 10000, "next": "ac1-press-long" }, "ac1-press-long": { "action": "press", "test_id": "perps-market-details-long-button", "next": "ac1-wait-order-screen" }, "ac1-wait-order-screen": { "action": "wait_for", "route": "RedesignedConfirmations", "timeout_ms": 15000, "next": "ac1-assert-no-modal-presentation" }, "ac1-assert-no-modal-presentation": { "action": "eval_sync", "expression": "(function(){ var r = __AGENTIC__.getRoute(); var opts = r ? r.params : {}; return JSON.stringify({ routeName: r ? r.name : 'unknown', showPerpsHeader: opts.showPerpsHeader, presentation: 'checked' }); })()", "assert": { "operator": "eq", "field": "routeName", "value": "RedesignedConfirmations" }, "next": "ac2-screenshot-order-screen" }, "ac2-screenshot-order-screen": { "action": "screenshot", "filename": "evidence-ac2-order-entry-rendered.png", "note": "AC2: Order entry screen rendered correctly after horizontal push transition from market detail", "next": "teardown-go-back" }, "teardown-go-back": { "action": "navigate", "target": "PerpsTrendingView", "next": "gate-done" }, "gate-done": { "action": "end", "status": "pass" } } } } } ```
## **Recipe Workflow**
workflow.mmd ```mermaid graph TD setup-nav-market["call perps/market-discovery"] --> setup-wait-buttons["wait_for long button"] setup-wait-buttons --> ac1-press-long["press Long button"] ac1-press-long --> ac1-wait-order-screen["wait_for RedesignedConfirmations"] ac1-wait-order-screen --> ac1-assert-no-modal-presentation["eval_sync: assert route"] ac1-assert-no-modal-presentation --> ac2-screenshot-order-screen["screenshot: order entry"] ac2-screenshot-order-screen --> teardown-go-back["navigate: PerpsTrendingView"] teardown-go-back --> gate-done["end: pass"] ```
[TAT-3052]: https://consensyssoftware.atlassian.net/browse/TAT-3052?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Low Risk** > Small navigation options change for one Perps confirmation screen path, with unit tests and no auth or data handling impact. > > **Overview** > Fixes the **market detail → order entry** navigation so it uses a **horizontal push** transition instead of a **bottom-up modal** when `showPerpsHeader` is false (Long/Short from market detail). > > **`getRedesignedConfirmationsHeaderOptions`** no longer spreads **`transparentModalScreenOptions`** or sets a transparent **`contentStyle`** in that branch, so the native stack keeps default push presentation. The helper is **exported** and covered by new unit tests asserting no `presentation` property and expected header flags. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2df4ae172f2c01c4067995db137e047b05846f6f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- ...designedConfirmationsHeaderOptions.test.ts | 30 +++++++++++++++++++ app/components/UI/Perps/routes/index.tsx | 4 +-- 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 app/components/UI/Perps/routes/getRedesignedConfirmationsHeaderOptions.test.ts diff --git a/app/components/UI/Perps/routes/getRedesignedConfirmationsHeaderOptions.test.ts b/app/components/UI/Perps/routes/getRedesignedConfirmationsHeaderOptions.test.ts new file mode 100644 index 00000000000..8c22f7794e3 --- /dev/null +++ b/app/components/UI/Perps/routes/getRedesignedConfirmationsHeaderOptions.test.ts @@ -0,0 +1,30 @@ +import { getRedesignedConfirmationsHeaderOptions } from './index'; + +describe('getRedesignedConfirmationsHeaderOptions', () => { + it('returns push-style options without modal presentation when showPerpsHeader is false', () => { + const options = getRedesignedConfirmationsHeaderOptions({ + showPerpsHeader: false, + }); + + expect(options.headerShown).toBe(false); + expect(options.headerBackVisible).toBe(false); + expect(options).not.toHaveProperty('presentation'); + expect(options.contentStyle).toBeUndefined(); + }); + + it('returns header-visible options when showPerpsHeader is true', () => { + const options = getRedesignedConfirmationsHeaderOptions({ + showPerpsHeader: true, + }); + + expect(options.headerShown).toBe(true); + expect(options.headerBackVisible).toBe(false); + expect(options).not.toHaveProperty('presentation'); + }); + + it('defaults to showing perps header when no params provided', () => { + const options = getRedesignedConfirmationsHeaderOptions(); + + expect(options.headerShown).toBe(true); + }); +}); diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index 56e07b5069c..6f993a975ac 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -62,7 +62,7 @@ const styles = StyleSheet.create({ }, }); -function getRedesignedConfirmationsHeaderOptions({ +export function getRedesignedConfirmationsHeaderOptions({ showPerpsHeader = CONFIRMATION_HEADER_CONFIG.DefaultShowPerpsHeader, }: PerpsNavigationParamList['RedesignedConfirmations'] = {}): NativeStackNavigationOptions { if (showPerpsHeader) { @@ -76,8 +76,6 @@ function getRedesignedConfirmationsHeaderOptions({ headerShown: false, title: '', headerBackVisible: false, - contentStyle: { backgroundColor: 'transparent' }, - ...transparentModalScreenOptions, }; } From 37005a8f78047b92a8594366fbb95a9424e0c8e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Tue, 26 May 2026 14:55:27 +0100 Subject: [PATCH 05/16] chore: adds the QuickBuy main sheet with modular buy flow (#30512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Introduces the new QuickBuy amount/main sheet for Top Traders, buy path. - Rebuilds the Top Traders QuickBuy as a modular, host-agnostic flow - Introduces a compound QuickBuy API (`QuickBuy.Root`, `AmountScreen`, `Toolbar`, `Amount`, `Footer`) with shared context and a default `AmountScreen` recipe when hosts only render QuickBuy.Root. - Removes legacy QuickBuyBottomSheet UI and the deprecated QuickBuySheet code. Follow-up PRs: quote details, select quote, Sell mode, pay-with view, etc. Simulator Screenshot - iPhone 16e - 2026-05-25 at
10 07 23 ## **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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] 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 bridge quote submission, Redux bridge state reset, and amount-selection analytics while changing the primary copy-trading buy UX; logic is heavily tested but end-to-end swap flows deserve manual QA. > > **Overview** > Replaces the monolithic **QuickBuyBottomSheet** with a **`QuickBuy/`** module: compound API (`QuickBuy.Root`, `AmountScreen`, `Toolbar`, `Amount`, `Footer`), **`QuickBuyProvider`** / **`useQuickBuyContext`**, and **`TraderPositionQuickBuy`** mapping social `Position` → **`QuickBuyTarget`**. > > The buy sheet UX shifts from **USD preset chips** and a token-detail **header** to an amount-first layout: **toolbar**, large fiat/crypto **amount** (optional toggle), **exchange-rate tag**, **percentage slider** (0–100% of balance), simplified **pay-with** row, and updated **confirm** styling. **`useQuickBuyBottomSheet`** becomes **`useQuickBuyController`**; analytics move to **`useQuickBuyAnalytics`** (slider vs custom input, bridge reset on unmount). Quote wiring in **`useQuickBuyQuotes`** tolerates non-array **`quoteRequest`**. > > The old **`QuickBuyBottomSheet/`** tree (header, footer presets, expanded quote breakdown) is deleted; **`TraderPositionView`** now mounts **`TraderPositionQuickBuy`**. Broad unit test coverage for new primitives and hooks. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2fae60d08481db6cc786c420dc94b2ff67c67161. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../TraderPositionView.test.tsx | 12 +- .../TraderPositionView/TraderPositionView.tsx | 4 +- .../components/QuickBuy/QuickBuyAmount.tsx | 43 ++ .../QuickBuy/QuickBuyAmountScreen.tsx | 54 ++ .../QuickBuyBanners.test.tsx | 0 .../QuickBuyBanners.tsx | 0 .../QuickBuyBottomSheetSkeleton.test.tsx | 34 ++ .../QuickBuy/QuickBuyBottomSheetSkeleton.tsx | 77 +++ .../QuickBuy/QuickBuyConfirmButton.test.tsx | 68 +++ .../QuickBuyConfirmButton.tsx | 31 +- .../components/QuickBuy/QuickBuyContext.tsx | 60 +++ .../components/QuickBuy/QuickBuyRoot.test.tsx | 266 ++++++++++ .../components/QuickBuy/QuickBuyRoot.tsx | 128 +++++ .../QuickBuySheet.test.tsx} | 181 +++---- .../QuickBuy/TraderPositionQuickBuy.test.tsx | 147 ++++++ .../QuickBuy/TraderPositionQuickBuy.tsx | 67 +++ .../components/QuickBuyActionFooter.tsx | 158 ++++++ .../components/QuickBuyAmountSection.test.tsx | 145 ++++++ .../components/QuickBuyAmountSection.tsx | 145 ++++++ .../components/QuickBuyPercentageSlider.tsx | 164 +++++++ .../components/QuickBuyRateTag.test.tsx | 38 ++ .../QuickBuy/components/QuickBuyRateTag.tsx | 65 +++ .../QuickBuy/components/QuickBuyToolbar.tsx | 29 ++ .../components/QuickBuy/features.ts | 11 + .../getBridgeTokenImageSource.test.ts | 53 ++ .../getBridgeTokenImageSource.ts | 0 .../hooks/useQuickBuyAnalytics.test.ts | 303 ++++++++++++ .../QuickBuy/hooks/useQuickBuyAnalytics.ts | 139 ++++++ .../hooks/useQuickBuyController.ts} | 377 +++++++------- .../hooks}/useQuickBuyQuotes.ts | 46 +- .../hooks}/useQuickBuySetup.ts | 13 +- .../hooks}/useSourceTokenOptions.ts | 22 +- .../components/QuickBuy/index.ts | 28 ++ .../components/QuickBuy/quickBuy.ts | 13 + .../QuickBuy/sourceTokenCandidates.test.ts | 137 ++++++ .../sourceTokenCandidates.ts | 0 .../components/QuickBuy/types.ts | 60 +++ .../components/QuickBuy/useQuickBuyContext.ts | 14 + .../useQuickBuyController.test.ts} | 248 ++++++++-- .../useQuickBuyQuotes.test.ts | 2 +- .../useQuickBuySetup.test.ts | 2 +- .../useSourceTokenOptions.test.ts | 2 +- .../QuickBuy/utils/formatExchangeRate.test.ts | 48 ++ .../QuickBuy/utils/formatExchangeRate.ts | 33 ++ .../QuickBuy/utils/getMetamaskFeePercent.ts | 26 + .../QuickBuyAmountInput.test.tsx | 134 ----- .../QuickBuyAmountInput.tsx | 94 ---- .../QuickBuyBottomSheet.tsx | 261 ---------- .../QuickBuyBottomSheetSkeleton.tsx | 139 ------ .../QuickBuyFooter.test.tsx | 241 --------- .../QuickBuyBottomSheet/QuickBuyFooter.tsx | 461 ------------------ .../QuickBuyHeader.test.tsx | 139 ------ .../QuickBuyBottomSheet/QuickBuyHeader.tsx | 68 --- .../QuickBuyBottomSheet/SourceTokenPicker.tsx | 152 ------ .../components/QuickBuyBottomSheet/index.ts | 2 - .../analytics/socialLeaderboardEvents.ts | 1 + locales/languages/en.json | 7 +- 57 files changed, 3143 insertions(+), 2049 deletions(-) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmount.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmountScreen.tsx rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/QuickBuyBanners.test.tsx (100%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/QuickBuyBanners.tsx (100%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyBottomSheetSkeleton.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyConfirmButton.test.tsx rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/QuickBuyConfirmButton.tsx (68%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet/QuickBuyBottomSheet.test.tsx => QuickBuy/QuickBuySheet.test.tsx} (66%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/TraderPositionQuickBuy.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyToolbar.tsx create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/features.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/getBridgeTokenImageSource.test.ts rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/getBridgeTokenImageSource.ts (100%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.test.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyAnalytics.ts rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet/useQuickBuyBottomSheet.ts => QuickBuy/hooks/useQuickBuyController.ts} (68%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy/hooks}/useQuickBuyQuotes.ts (89%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy/hooks}/useQuickBuySetup.ts (89%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy/hooks}/useSourceTokenOptions.ts (90%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/index.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/quickBuy.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/sourceTokenCandidates.test.ts rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/sourceTokenCandidates.ts (100%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/types.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyContext.ts rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet/useQuickBuyBottomSheet.test.ts => QuickBuy/useQuickBuyController.test.ts} (79%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/useQuickBuyQuotes.test.ts (99%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/useQuickBuySetup.test.ts (98%) rename app/components/Views/SocialLeaderboard/TraderPositionView/components/{QuickBuyBottomSheet => QuickBuy}/useSourceTokenOptions.test.ts (99%) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.test.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/formatExchangeRate.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getMetamaskFeePercent.ts delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.test.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyAmountInput.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheet.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyBottomSheetSkeleton.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.test.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyFooter.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.test.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/QuickBuyHeader.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/SourceTokenPicker.tsx delete mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/index.ts diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx index c6e184b3c44..5f7b5950a30 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx @@ -96,13 +96,11 @@ jest.mock('../../../../core/ClipboardManager', () => ({ setString: jest.fn().mockResolvedValue(undefined), })); -// Pressing buy mounts QuickBuyBottomSheet. Jest's global mock for design-system -// `BottomSheet` (see app/util/test/testSetup.js) invokes `onOpenBottomSheet`'s -// callback synchronously, so `QuickBuyBottomSheetContent` mounts in the same turn -// and runs `useQuickBuyBottomSheet` (bridge selectors, device version compare, -// NetworkController, …). This file intentionally uses a minimal Redux store, so -// we stub the sheet here. -jest.mock('./components/QuickBuyBottomSheet', () => ({ +// Pressing buy mounts TraderPositionQuickBuy (`QuickBuy.Root`). Jest's global mock +// for design-system `BottomSheet` (see app/util/test/testSetup.js) can mount +// QuickBuy provider/controller (bridge selectors, NetworkController, …). This +// file intentionally uses a minimal Redux store, so we stub the sheet here. +jest.mock('./components/QuickBuy', () => ({ __esModule: true, default: () => null, })); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx index 8332c87685d..e2f96bfcdb7 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx @@ -33,7 +33,7 @@ import { IconName as ComponentLibraryIconName } from '../../../../component-libr import ClipboardManager from '../../../../core/ClipboardManager'; import { TraderPositionViewSelectorsIDs } from './TraderPositionView.testIds'; import { useTheme } from '../../../../util/theme'; -import QuickBuyBottomSheet from './components/QuickBuyBottomSheet'; +import TraderPositionQuickBuy from './components/QuickBuy'; import TraderPositionHeader from './components/TraderPositionHeader'; import TraderTokenInfoRow from './components/TraderTokenInfoRow'; import TraderPositionChartSection from './components/TraderPositionChartSection'; @@ -374,7 +374,7 @@ const TraderPositionView = () => { -