From 3a51914a08c7edae9898a3942f91b8cd91e771ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20G=C3=B6ktu=C4=9F=20Poyraz?= Date: Sat, 30 May 2026 09:02:53 +0200 Subject: [PATCH] feat: auto-select fiat payment method for `MoneyAccount` "Deposit Funds" action (#30713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Modifies the Money Hub "Deposit Funds" flow to use `initiateDeposit` with auto-fiat-selection instead of the Ramps `goToBuy` flow. When the user taps "Deposit Funds" in the Add Money sheet, the confirmation screen now auto-selects the first available fiat payment method (bank/card) rather than showing the crypto token selector. **Key implementation details:** - Extended `useAutomaticTransactionPayToken` to handle fiat auto-selection atomically: when `autoSelectFiatPayment` is true, the hook waits for Ramps payment methods to load, then calls `updateFiatPayment` without calling `setPayToken` (since `updatePaymentToken` resets `fiatPayment` to `{}` in the controller). - All three effects in `useAutomaticTransactionPayToken` are guarded with `hasFiatPaymentSelected` to prevent any instance of the hook from calling `setPayToken` when a fiat payment method is active — this protects against the second instance in `usePayWithPreferredToken` (used by the pay-with bottom sheet) from wiping the fiat selection. - The "Deposit Funds" option is gated on the `confirmations_pay_fiat` remote feature flag including `moneyAccountDeposit` in `enabledTransactionTypes`. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Auto-select fiat payment for money account deposits Scenario: user deposits funds via fiat payment Given the user is on the Money Hub with moneyAccountDeposit enabled in confirmations_pay_fiat feature flag When user taps "Add funds" then "Deposit funds" Then the confirmation screen shows with fiat payment method pre-selected (no crypto token flicker) And the PayAccountSelector and percentage buttons are hidden initially Scenario: user switches from fiat to crypto payment Given the confirmation screen shows with fiat payment method pre-selected When user taps the "Pay with" row and selects a crypto token Then the PayAccountSelector appears And percentage buttons appear And the selected crypto token is shown in the Pay with row Scenario: deposit funds hidden when feature flag is off Given moneyAccountDeposit is NOT in the confirmations_pay_fiat enabledTransactionTypes When user opens the Add Money sheet Then the "Deposit funds" option is not shown ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/31d85902-70b1-46d4-b987-1aaefb8d7ee8 ### **After** https://github.com/user-attachments/assets/09fd4583-3a21-4c2b-a819-464d5ae52458 ## **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** > Changes payment routing and Transaction Pay fiat/token selection for money account deposits; mitigated by feature flags and tests, but affects funds-in UX and controller state. > > **Overview** > Money Hub **Deposit funds** now routes through `initiateDeposit` with **`autoSelectFiatPayment`** instead of the Ramps buy flow, and the sheet only shows that option when **`confirmations_pay_fiat`** includes `moneyAccountDeposit`. > > On the money-account deposit confirmation screen, **`autoSelectFiatPayment`** is threaded from navigation params into **`useAutomaticTransactionPayToken`**, which picks the first eligible Ramps payment method (respecting max delay) via **`updateFiatPayment`** and skips **`setPayToken`** so fiat selection is not cleared. **`CustomAmountInfo`** can hide the account selector and percentage shortcuts until the user switches to crypto, and hook effects bail out when a fiat method is already selected. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1737fd55cc6d1b98575daf9aa66fa8a22fa85f1b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../MoneyAddMoneySheet.test.tsx | 54 ++++++++++--------- .../MoneyAddMoneySheet/MoneyAddMoneySheet.tsx | 47 ++++++++-------- .../UI/Money/hooks/useMoneyAccount.test.ts | 17 ++++++ .../UI/Money/hooks/useMoneyAccount.ts | 2 + .../components/confirm/confirm-component.tsx | 1 + .../custom-amount-info/custom-amount-info.tsx | 32 ++++++++--- .../money-account-deposit-info.test.tsx | 31 ++++++++--- .../money-account-deposit-info.tsx | 5 ++ .../useAutomaticTransactionPayToken.test.ts | 33 +++++++++++- .../pay/useAutomaticTransactionPayToken.ts | 54 ++++++++++++++++++- 10 files changed, 212 insertions(+), 64 deletions(-) diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx index 532aebe5d83..36d8ff5a695 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx @@ -1,24 +1,20 @@ import React from 'react'; import { fireEvent } from '@testing-library/react-native'; +import { TransactionType, CHAIN_IDS } from '@metamask/transaction-controller'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import MoneyAddMoneySheet from './MoneyAddMoneySheet'; import { MoneyAddMoneySheetTestIds } from './MoneyAddMoneySheet.testIds'; -import { useMusdConversionFlowData } from '../../../Earn/hooks/useMusdConversionFlowData'; -import { useRampNavigation } from '../../../Ramp/hooks/useRampNavigation'; import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount'; +import { useMMPayFiatConfig } from '../../../../Views/confirmations/hooks/pay/useMMPayFiatConfig'; import { MUSD_CONVERSION_DEFAULT_CHAIN_ID, MUSD_TOKEN_ADDRESS_BY_CHAIN, - MUSD_TOKEN_ASSET_ID_BY_CHAIN, } from '../../../Earn/constants/musd'; -import { CHAIN_IDS } from '@metamask/transaction-controller'; const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.()); const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); -const mockGetChainIdForBuyFlow = jest.fn(); -const mockGoToBuy = jest.fn(); const mockInitiateDeposit = jest.fn(() => Promise.resolve()); jest.mock('@react-navigation/native', () => { @@ -32,14 +28,6 @@ jest.mock('@react-navigation/native', () => { }; }); -jest.mock('../../../Earn/hooks/useMusdConversionFlowData', () => ({ - useMusdConversionFlowData: jest.fn(), -})); - -jest.mock('../../../Ramp/hooks/useRampNavigation', () => ({ - useRampNavigation: jest.fn(), -})); - jest.mock('../../../Earn/hooks/useMusdBalance', () => ({ useMusdBalance: jest.fn(), })); @@ -48,6 +36,13 @@ jest.mock('../../hooks/useMoneyAccount', () => ({ useMoneyAccountDeposit: jest.fn(), })); +jest.mock( + '../../../../Views/confirmations/hooks/pay/useMMPayFiatConfig', + () => ({ + useMMPayFiatConfig: jest.fn(), + }), +); + jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); const { forwardRef, useImperativeHandle } = jest.requireActual('react'); @@ -80,14 +75,6 @@ describe('MoneyAddMoneySheet', () => { beforeEach(() => { jest.clearAllMocks(); - mockGetChainIdForBuyFlow.mockReturnValue(MUSD_CONVERSION_DEFAULT_CHAIN_ID); - - (useMusdConversionFlowData as jest.Mock).mockReturnValue({ - getChainIdForBuyFlow: mockGetChainIdForBuyFlow, - }); - (useRampNavigation as jest.Mock).mockReturnValue({ - goToBuy: mockGoToBuy, - }); (useMusdBalance as jest.Mock).mockReturnValue({ fiatBalanceAggregated: '1203.89', fiatBalanceAggregatedFormatted: '$1,203.89', @@ -98,6 +85,10 @@ describe('MoneyAddMoneySheet', () => { (useMoneyAccountDeposit as jest.Mock).mockReturnValue({ initiateDeposit: mockInitiateDeposit, }); + (useMMPayFiatConfig as jest.Mock).mockReturnValue({ + enabledTransactionTypes: [TransactionType.moneyAccountDeposit], + maxDelayMinutesForPaymentMethods: 10, + }); }); it('renders all four options', () => { @@ -226,7 +217,7 @@ describe('MoneyAddMoneySheet', () => { expect(getByText('Add your 42.50 mUSD')).toBeOnTheScreen(); }); - it('navigates to the Ramps buy flow with mUSD pre-selected when Deposit funds is pressed', () => { + it('initiates a deposit with autoSelectFiatPayment when Deposit funds is pressed', () => { const { getByTestId } = renderWithProvider(); fireEvent.press( @@ -234,8 +225,8 @@ describe('MoneyAddMoneySheet', () => { ); expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID], + expect(mockInitiateDeposit).toHaveBeenCalledWith({ + autoSelectFiatPayment: true, }); }); @@ -250,6 +241,19 @@ describe('MoneyAddMoneySheet', () => { expect(mockInitiateDeposit).toHaveBeenCalledWith(); }); + it('hides the Deposit funds option when moneyAccountDeposit is not in enabledTransactionTypes', () => { + (useMMPayFiatConfig as jest.Mock).mockReturnValue({ + enabledTransactionTypes: [], + maxDelayMinutesForPaymentMethods: 10, + }); + + const { queryByTestId } = renderWithProvider(); + + expect( + queryByTestId(MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_OPTION), + ).toBeNull(); + }); + it('initiates a deposit pre-selecting mUSD on the highest-balance chain when Move mUSD is pressed', () => { (useMusdBalance as jest.Mock).mockReturnValue({ fiatBalanceAggregated: '1500.00', diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx index 7b08fb74d50..02d6b911886 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx @@ -1,7 +1,8 @@ -import React, { useCallback, useRef } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import BigNumber from 'bignumber.js'; +import { TransactionType } from '@metamask/transaction-controller'; import { BottomSheet, BottomSheetHeader, @@ -18,16 +19,14 @@ import { import Tag from '../../../../../component-library/components/Tags/Tag'; import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../../component-library/hooks'; -import { useMusdConversionFlowData } from '../../../Earn/hooks/useMusdConversionFlowData'; import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; import { MUSD_CONVERSION_DEFAULT_CHAIN_ID, MUSD_TOKEN_ADDRESS_BY_CHAIN, - MUSD_TOKEN_ASSET_ID_BY_CHAIN, } from '../../../Earn/constants/musd'; import { Hex } from '@metamask/utils'; -import { useRampNavigation } from '../../../Ramp/hooks/useRampNavigation'; import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount'; +import { useMMPayFiatConfig } from '../../../../Views/confirmations/hooks/pay/useMMPayFiatConfig'; import { useElevatedSurface } from '../../../../../util/theme/themeUtils'; import styleSheet from './MoneyAddMoneySheet.styles'; import { MoneyAddMoneySheetTestIds } from './MoneyAddMoneySheet.testIds'; @@ -54,9 +53,12 @@ const MoneyAddMoneySheet: React.FC = () => { tokenBalanceAggregated, tokenBalanceByChain, } = useMusdBalance(); - const { getChainIdForBuyFlow } = useMusdConversionFlowData(); - const { goToBuy } = useRampNavigation(); const { initiateDeposit } = useMoneyAccountDeposit(); + const { enabledTransactionTypes } = useMMPayFiatConfig(); + const isFiatDepositEnabled = useMemo( + () => enabledTransactionTypes.includes(TransactionType.moneyAccountDeposit), + [enabledTransactionTypes], + ); const closeAndNavigate = useCallback((navigateFn: () => void) => { sheetRef.current?.onCloseBottomSheet(navigateFn); @@ -72,17 +74,11 @@ const MoneyAddMoneySheet: React.FC = () => { }); }, [closeAndNavigate, initiateDeposit]); - // TODO(MUSD-479): point to the Ramps "Add funds" amount-entry screen - // (Figma 2547:8780). Interim: unified smart-routed Buy flow with mUSD - // pre-selected so the destination matches the Money Hub experience. const handleDepositFunds = useCallback(() => { - const chainId = getChainIdForBuyFlow - ? getChainIdForBuyFlow() - : MUSD_CONVERSION_DEFAULT_CHAIN_ID; closeAndNavigate(() => { - goToBuy({ assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId] }); + initiateDeposit({ autoSelectFiatPayment: true }).catch(() => undefined); }); - }, [closeAndNavigate, getChainIdForBuyFlow, goToBuy]); + }, [closeAndNavigate, initiateDeposit]); const handleMoveMusd = useCallback(() => { let sourceChainId: Hex = MUSD_CONVERSION_DEFAULT_CHAIN_ID; @@ -129,14 +125,21 @@ const MoneyAddMoneySheet: React.FC = () => { onPress: handleConvertCrypto, testID: MoneyAddMoneySheetTestIds.CONVERT_CRYPTO_OPTION, }, - { - label: strings('money.add_money_sheet.deposit_funds'), - description: strings('money.add_money_sheet.deposit_funds_description'), - descriptionTestID: MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_DESCRIPTION, - icon: IconName.AttachMoney, - onPress: handleDepositFunds, - testID: MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_OPTION, - }, + ...(isFiatDepositEnabled + ? [ + { + label: strings('money.add_money_sheet.deposit_funds'), + description: strings( + 'money.add_money_sheet.deposit_funds_description', + ), + descriptionTestID: + MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_DESCRIPTION, + icon: IconName.AttachMoney, + onPress: handleDepositFunds, + testID: MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_OPTION, + }, + ] + : []), ]; const options: Option[] = hasMusdBalance diff --git a/app/components/UI/Money/hooks/useMoneyAccount.test.ts b/app/components/UI/Money/hooks/useMoneyAccount.test.ts index 18dd647f357..aa4ef92059a 100644 --- a/app/components/UI/Money/hooks/useMoneyAccount.test.ts +++ b/app/components/UI/Money/hooks/useMoneyAccount.test.ts @@ -201,6 +201,7 @@ describe('useMoneyAccountDeposit', () => { loader: ConfirmationLoader.CustomAmount, stack: Routes.MONEY.CONFIRMATIONS_ROOT, preferredPaymentToken: undefined, + autoSelectFiatPayment: undefined, }); expect(mockAddTransactionBatch).toHaveBeenCalledWith( @@ -214,6 +215,21 @@ describe('useMoneyAccountDeposit', () => { ); }); + it('passes autoSelectFiatPayment to navigateToConfirmation', async () => { + const { result } = renderHook(() => useMoneyAccountDeposit()); + + await act(async () => { + await result.current.initiateDeposit({ autoSelectFiatPayment: true }); + }); + + expect(getNavigateToConfirmation()).toHaveBeenCalledWith({ + loader: ConfirmationLoader.CustomAmount, + stack: Routes.MONEY.CONFIRMATIONS_ROOT, + preferredPaymentToken: undefined, + autoSelectFiatPayment: true, + }); + }); + it('pre-generates a batchId, registers intent before the await, and forwards preferredPaymentToken', async () => { const preferredPaymentToken = { address: '0xaca92e438df0b2401ff60da7e4337b687a2435da' as Hex, @@ -241,6 +257,7 @@ describe('useMoneyAccountDeposit', () => { loader: ConfirmationLoader.CustomAmount, stack: Routes.MONEY.CONFIRMATIONS_ROOT, preferredPaymentToken, + autoSelectFiatPayment: undefined, }); expect(observedBatchId).toMatch(/^0x[0-9a-f]+$/); expect(intentAtCallTime).toBe('addMusd'); diff --git a/app/components/UI/Money/hooks/useMoneyAccount.ts b/app/components/UI/Money/hooks/useMoneyAccount.ts index bab89f948cb..47b193f4e21 100644 --- a/app/components/UI/Money/hooks/useMoneyAccount.ts +++ b/app/components/UI/Money/hooks/useMoneyAccount.ts @@ -45,6 +45,7 @@ export interface InitiateDepositOptions { chainId: Hex; }; intent?: MoneyAccountDepositIntent; + autoSelectFiatPayment?: boolean; } function resolveNetworkClientId(chainId: Hex): string { @@ -108,6 +109,7 @@ export function useMoneyAccountDeposit() { loader: ConfirmationLoader.CustomAmount, stack: Routes.MONEY.CONFIRMATIONS_ROOT, preferredPaymentToken, + autoSelectFiatPayment: options?.autoSelectFiatPayment, }); try { diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx index 381aaf66e46..11e82c4b014 100755 --- a/app/components/Views/confirmations/components/confirm/confirm-component.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.tsx @@ -58,6 +58,7 @@ export enum ConfirmationLoader { } export interface ConfirmationParams { + autoSelectFiatPayment?: boolean; loader?: ConfirmationLoader; maxValueMode?: boolean; forceBottomSheet?: boolean; diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx index 0942f562b98..93d8f159292 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, memo, useCallback, useState } from 'react'; +import React, { ReactNode, memo, useCallback, useRef, useState } from 'react'; import { toCaipAssetType } from '@metamask/utils'; import { TransactionType } from '@metamask/transaction-controller'; import { PayTokenAmount, PayTokenAmountSkeleton } from '../../pay-token-amount'; @@ -72,10 +72,12 @@ import { CustomAmountInfoTestIds } from './custom-amount-info.testIds'; import { useConfirmationContext } from '../../../context/confirmation-context'; export interface CustomAmountInfoProps { + autoSelectFiatPayment?: boolean; children?: ReactNode; currency?: string; disablePay?: boolean; hasMax?: boolean; + hideAccountSelector?: boolean; preferredToken?: SetPayTokenRequest; footerText?: string; /** @@ -104,12 +106,14 @@ export interface CustomAmountInfoProps { export const CustomAmountInfo: React.FC = memo( ({ + autoSelectFiatPayment, children, currency, disableConfirm, disablePay, hasMax, hasExtraBottomPadding, + hideAccountSelector, onAmountSubmit, hidePayTokenAmount, preferredToken, @@ -121,6 +125,7 @@ export const CustomAmountInfo: React.FC = memo( const { canSelectWithdrawToken } = useTransactionPayWithdraw(); useAutomaticTransactionPayToken({ + autoSelectFiatPayment, disable: disablePay, preferredToken, }); @@ -133,6 +138,12 @@ export const CustomAmountInfo: React.FC = memo( const { hasTokens } = useTransactionPayAvailableTokens(); const fiatPayment = useTransactionPayFiatPayment(); const selectedFiatPaymentMethodId = fiatPayment?.selectedPaymentMethodId; + const fiatEverSelectedRef = useRef(false); + if (selectedFiatPaymentMethodId) { + fiatEverSelectedRef.current = true; + } + const shouldHideAccountSelector = + hideAccountSelector && !fiatEverSelectedRef.current; const transactionMeta = useTransactionMetadataRequest(); const transactionId = transactionMeta?.id; const accountOverride = useTransactionAccountOverride(); @@ -239,17 +250,19 @@ export const CustomAmountInfo: React.FC = memo( {!isResultReady && ( <> - {supportAccountSelection && !selectedFiatPaymentMethodId && ( - - )} + {supportAccountSelection && + !selectedFiatPaymentMethodId && + !shouldHideAccountSelector && ( + + )} {disablePay !== true && hasTokens && } )} {isResultReady && ( - {supportAccountSelection && !selectedFiatPaymentMethodId && ( - - )} + {supportAccountSelection && + !selectedFiatPaymentMethodId && + !shouldHideAccountSelector && } {disablePay !== true && hasTokens && } {showPaymentDetails && ( <> @@ -276,7 +289,10 @@ export const CustomAmountInfo: React.FC = memo( )} {isKeyboardVisible && hasTokens && ( ({ + useParams: () => mockUseParams(), +})); + const mockUseMoneyAccountDepositNavbar = jest.fn(); jest.mock('../../../../../UI/Money/hooks/useMoneyAccountDepositNavbar', () => ({ useMoneyAccountDepositNavbar: () => mockUseMoneyAccountDepositNavbar(), @@ -29,16 +34,12 @@ jest.mock('../../../../../../../locales/i18n', () => ({ ({ 'confirm.title.money_account_add_money': 'Add funds' })[key] ?? key, })); -const mockUseParams = jest.fn(() => ({})); -jest.mock('../../../../../../util/navigation/navUtils', () => ({ - useParams: () => mockUseParams(), -})); - describe('MoneyAccountDepositInfo', () => { beforeEach(() => { jest.clearAllMocks(); mockCustomAmountInfo.mockClear(); mockUseMoneyAccountDepositNavbar.mockReturnValue(undefined); + mockUseParams.mockReturnValue({}); }); it('renders CustomAmountInfo with usd currency', () => { @@ -80,6 +81,19 @@ describe('MoneyAccountDepositInfo', () => { expect(lastCall.hasMax).toBe(true); }); + it('passes autoSelectFiatPayment and hideAccountSelector from route params', () => { + mockUseParams.mockReturnValue({ autoSelectFiatPayment: true }); + + render(); + + const lastCall = + mockCustomAmountInfo.mock.calls[ + mockCustomAmountInfo.mock.calls.length - 1 + ][0]; + expect(lastCall.autoSelectFiatPayment).toBe(true); + expect(lastCall.hideAccountSelector).toBe(true); + }); + it('forwards preferredPaymentToken from route params to CustomAmountInfo', () => { const preferredPaymentToken = { address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', @@ -96,13 +110,16 @@ describe('MoneyAccountDepositInfo', () => { expect(lastCall.preferredToken).toEqual(preferredPaymentToken); }); - it('passes undefined preferredToken when no preferredPaymentToken in params', () => { + it('does not pass autoSelectFiatPayment when route param is absent', () => { + mockUseParams.mockReturnValue({}); + render(); const lastCall = mockCustomAmountInfo.mock.calls[ mockCustomAmountInfo.mock.calls.length - 1 ][0]; - expect(lastCall.preferredToken).toBeUndefined(); + expect(lastCall.autoSelectFiatPayment).toBeUndefined(); + expect(lastCall.hideAccountSelector).toBeUndefined(); }); }); diff --git a/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.tsx b/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.tsx index 8fb5e6ecc90..566064fa18d 100644 --- a/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.tsx +++ b/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.tsx @@ -10,10 +10,15 @@ export function MoneyAccountDepositInfo() { useMoneyAccountDepositNavbar(); const { preferredPaymentToken } = useParams({}); + const params = useParams(); + const autoFiat = params?.autoSelectFiatPayment; + return ( diff --git a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts index 36c82e828d6..7abd5e6d30c 100644 --- a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts @@ -13,6 +13,7 @@ import { transactionApprovalControllerMock } from '../../__mocks__/controllers/a import { MetaMaskPayTokensFlags, selectMetaMaskPayTokensFlags, + selectMetaMaskPayFiatFlags, } from '../../../../../selectors/featureFlagController/confirmations'; import { isHardwareAccount, @@ -24,10 +25,14 @@ import { TransactionPayRequiredToken, } from '@metamask/transaction-pay-controller'; import { Hex } from '@metamask/utils'; -import { useTransactionPayRequiredTokens } from './useTransactionPayData'; +import { + useTransactionPayFiatPayment, + useTransactionPayRequiredTokens, +} from './useTransactionPayData'; import { useTransactionPayAvailableTokens } from './useTransactionPayAvailableTokens'; import { AssetType } from '../../types/token'; import { useWithdrawTokenFilter } from './useWithdrawTokenFilter'; +import { useRampsPaymentMethods } from '../../../../UI/Ramp/hooks/useRampsPaymentMethods'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { useTransactionAccountOverride } from '../transactions/useTransactionAccountOverride'; import { MUSD_TOKEN_ADDRESS } from '../../../../UI/Earn/constants/musd'; @@ -42,6 +47,7 @@ jest.mock('../../../../../selectors/transactionPayController'); jest.mock('./useTransactionPayData'); jest.mock('./useTransactionPayAvailableTokens'); jest.mock('./useWithdrawTokenFilter'); +jest.mock('../../../../UI/Ramp/hooks/useRampsPaymentMethods'); jest.mock('../../../../../selectors/transactionController', () => ({ ...jest.requireActual('../../../../../selectors/transactionController'), selectLastWithdrawTokenByType: jest.fn(), @@ -53,6 +59,7 @@ jest.mock( '../../../../../selectors/featureFlagController/confirmations', ), selectMetaMaskPayTokensFlags: jest.fn(), + selectMetaMaskPayFiatFlags: jest.fn(), }), ); @@ -101,6 +108,9 @@ function runHook({ describe('useAutomaticTransactionPayToken', () => { const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); + const useTransactionPayFiatPaymentMock = jest.mocked( + useTransactionPayFiatPayment, + ); const useTransactionPayAvailableTokensMock = jest.mocked( useTransactionPayAvailableTokens, ); @@ -112,6 +122,9 @@ describe('useAutomaticTransactionPayToken', () => { const selectMetaMaskPayTokensFlagsMock = jest.mocked( selectMetaMaskPayTokensFlags, ); + const selectMetaMaskPayFiatFlagsMock = jest.mocked( + selectMetaMaskPayFiatFlags, + ); const useTransactionMetadataRequestMock = jest.mocked( useTransactionMetadataRequest, ); @@ -160,6 +173,24 @@ describe('useAutomaticTransactionPayToken', () => { } as never); useTransactionAccountOverrideMock.mockReturnValue(undefined); + + useTransactionPayFiatPaymentMock.mockReturnValue(undefined); + + jest.mocked(useRampsPaymentMethods).mockReturnValue({ + paymentMethods: [], + selectedPaymentMethod: null, + setSelectedPaymentMethod: jest.fn(), + isLoading: false, + isFetching: false, + status: 'success', + isSuccess: true, + error: null, + }); + + selectMetaMaskPayFiatFlagsMock.mockReturnValue({ + enabledTransactionTypes: [], + maxDelayMinutesForPaymentMethods: 10, + }); }); it('selects first token', () => { diff --git a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts index ea4caf2e72d..e4bd814a15b 100644 --- a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts +++ b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts @@ -2,6 +2,7 @@ import { useTransactionMetadataRequest } from '../transactions/useTransactionMet import { useCallback, useEffect, useMemo, useRef } from 'react'; import { Hex } from 'viem'; import { createProjectLogger } from '@metamask/utils'; +import Engine from '../../../../../core/Engine'; import { useTransactionPayToken } from './useTransactionPayToken'; import { isHardwareAccount, @@ -13,7 +14,10 @@ import { TransactionType, } from '@metamask/transaction-controller'; import { PaymentOverride } from '@metamask/transaction-pay-controller'; -import { useTransactionPayRequiredTokens } from './useTransactionPayData'; +import { + useTransactionPayFiatPayment, + useTransactionPayRequiredTokens, +} from './useTransactionPayData'; import { useTransactionPayAvailableTokens } from './useTransactionPayAvailableTokens'; import { AssetType } from '../../types/token'; import { @@ -24,6 +28,7 @@ import { import { useSelector } from 'react-redux'; import { selectMetaMaskPayTokensFlags, + selectMetaMaskPayFiatFlags, PreferredToken, getPreferredTokensForTransactionType, } from '../../../../../selectors/featureFlagController/confirmations'; @@ -33,6 +38,7 @@ import { selectPaymentOverrideByTransactionId } from '../../../../../selectors/t import { MUSD_TOKEN_ADDRESS } from '../../../../UI/Earn/constants/musd'; import { useWithdrawTokenFilter } from './useWithdrawTokenFilter'; import { useTransactionAccountOverride } from '../transactions/useTransactionAccountOverride'; +import { useRampsPaymentMethods } from '../../../../UI/Ramp/hooks/useRampsPaymentMethods'; export interface SetPayTokenRequest { address: Hex; @@ -42,14 +48,18 @@ export interface SetPayTokenRequest { const log = createProjectLogger('transaction-pay'); export function useAutomaticTransactionPayToken({ + autoSelectFiatPayment = false, disable = false, preferredToken, }: { + autoSelectFiatPayment?: boolean; disable?: boolean; preferredToken?: SetPayTokenRequest; } = {}) { const isUpdated = useRef(undefined); const { payToken, setPayToken } = useTransactionPayToken(); + const fiatPayment = useTransactionPayFiatPayment(); + const hasFiatPaymentSelected = Boolean(fiatPayment?.selectedPaymentMethodId); const requiredTokens = useTransactionPayRequiredTokens(); const { availableTokens } = useTransactionPayAvailableTokens(); const payTokensFlags = useSelector(selectMetaMaskPayTokensFlags); @@ -149,10 +159,18 @@ export function useAutomaticTransactionPayToken({ const automaticToken = useMemo(() => selectBestToken(), [selectBestToken]); + const { paymentMethods } = useRampsPaymentMethods(); + const fiatFlags = useSelector(selectMetaMaskPayFiatFlags); + const isFiatEnabled = hasTransactionType( + transactionMeta, + fiatFlags.enabledTransactionTypes, + ); + useEffect(() => { if ( disable || payToken || + hasFiatPaymentSelected || !transactionId || isUpdated.current === transactionId ) { @@ -164,6 +182,31 @@ export function useAutomaticTransactionPayToken({ return; } + if (autoSelectFiatPayment) { + if (!isFiatEnabled || paymentMethods.length === 0) { + return; + } + + const eligibleMethod = paymentMethods.find( + (pm) => + !pm.delay || + pm.delay[1] <= fiatFlags.maxDelayMinutesForPaymentMethods, + ); + + if (eligibleMethod) { + Engine.context.TransactionPayController.updateFiatPayment({ + transactionId, + callback: (fp) => { + fp.selectedPaymentMethodId = eligibleMethod.id; + }, + }); + } + + isUpdated.current = transactionId; + log('Auto-selected fiat payment method', eligibleMethod?.name); + return; + } + setPayToken({ address: automaticToken.address, chainId: automaticToken.chainId, @@ -173,9 +216,14 @@ export function useAutomaticTransactionPayToken({ log('Automatically selected pay token', automaticToken); }, [ + autoSelectFiatPayment, automaticToken, disable, + fiatFlags, + hasFiatPaymentSelected, + isFiatEnabled, payToken, + paymentMethods, requiredTokens, setPayToken, tokens, @@ -191,6 +239,7 @@ export function useAutomaticTransactionPayToken({ const accountKey = `${from ?? ''}:${accountOverride ?? ''}`; if ( disable || + hasFiatPaymentSelected || !from || prevAccountKeyRef.current === accountKey || postQuoteTransactionType @@ -211,6 +260,7 @@ export function useAutomaticTransactionPayToken({ automaticToken, disable, from, + hasFiatPaymentSelected, postQuoteTransactionType, setPayToken, ]); @@ -221,6 +271,7 @@ export function useAutomaticTransactionPayToken({ useEffect(() => { if ( disable || + hasFiatPaymentSelected || !from || isMoneyPaymentOverride === previsMoneyPaymentOverrideRef.current || postQuoteTransactionType @@ -240,6 +291,7 @@ export function useAutomaticTransactionPayToken({ automaticToken, disable, from, + hasFiatPaymentSelected, postQuoteTransactionType, setPayToken, isMoneyPaymentOverride,