diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index be2fce574ba1..de7fb38725c0 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -6647,10 +6647,26 @@ "message": "Pay with", "description": "Label for pay with row showing which token is used to pay transaction fees" }, + "payWithCrypto": { + "message": "Crypto", + "description": "Section header for crypto payment methods in the Pay with picker" + }, "payWithModalTitle": { "message": "Pay with", "description": "Title for the pay with modal that allows users to select which token to pay transaction fees with" }, + "payWithMoneyAccount": { + "message": "Money account", + "description": "Label for paying with the user's Money account in the Pay with picker" + }, + "payWithOtherAssets": { + "message": "Other assets", + "description": "Row label that opens the full token list in the Pay with picker" + }, + "payWithOtherAssetsDescription": { + "message": "Select from your tokens", + "description": "Subtitle for the Other assets row in the Pay with picker" + }, "payee": { "message": "Payee" }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index be2fce574ba1..de7fb38725c0 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -6647,10 +6647,26 @@ "message": "Pay with", "description": "Label for pay with row showing which token is used to pay transaction fees" }, + "payWithCrypto": { + "message": "Crypto", + "description": "Section header for crypto payment methods in the Pay with picker" + }, "payWithModalTitle": { "message": "Pay with", "description": "Title for the pay with modal that allows users to select which token to pay transaction fees with" }, + "payWithMoneyAccount": { + "message": "Money account", + "description": "Label for paying with the user's Money account in the Pay with picker" + }, + "payWithOtherAssets": { + "message": "Other assets", + "description": "Row label that opens the full token list in the Pay with picker" + }, + "payWithOtherAssetsDescription": { + "message": "Select from your tokens", + "description": "Subtitle for the Other assets row in the Pay with picker" + }, "payee": { "message": "Payee" }, diff --git a/app/images/money.png b/app/images/money.png new file mode 100644 index 000000000000..0fa5da7c0a8a Binary files /dev/null and b/app/images/money.png differ diff --git a/app/scripts/messenger-client-init/transaction-pay-controller-init.test.ts b/app/scripts/messenger-client-init/transaction-pay-controller-init.test.ts index 6e14ebb1fc83..f2138515788c 100644 --- a/app/scripts/messenger-client-init/transaction-pay-controller-init.test.ts +++ b/app/scripts/messenger-client-init/transaction-pay-controller-init.test.ts @@ -146,4 +146,63 @@ describe('TransactionPayControllerInit', () => { expect(config).toEqual({ accountOverride }); }); }); + + describe('api.setTransactionPayPaymentOverride', () => { + function initApi() { + const { api, messengerClient } = + TransactionPayControllerInit(getInitRequestMock()); + if (!api) { + throw new Error('Expected init result to expose an api'); + } + const setTransactionConfigMock = jest.mocked( + messengerClient.setTransactionConfig, + ); + return { api, setTransactionConfigMock }; + } + + it('writes paymentOverride and refundTo', () => { + const { api, setTransactionConfigMock } = initApi(); + const refundTo = '0xabcdef1234567890abcdef1234567890abcdef12' as const; + + api.setTransactionPayPaymentOverride('tx-1', { + paymentOverride: 'moneyAccount' as never, + refundTo, + }); + + const updater = setTransactionConfigMock.mock.calls[0][1]; + const config: { + paymentOverride?: string; + refundTo?: string; + } = {}; + updater(config as never); + + expect(config).toEqual({ + paymentOverride: 'moneyAccount', + refundTo, + }); + }); + + it('clears paymentOverride and refundTo when override is undefined', () => { + const { api, setTransactionConfigMock } = initApi(); + + api.setTransactionPayPaymentOverride('tx-2', { + paymentOverride: undefined, + }); + + const updater = setTransactionConfigMock.mock.calls[0][1]; + const config: { + paymentOverride?: string; + refundTo?: string; + } = { + paymentOverride: 'moneyAccount', + refundTo: '0xabc', + }; + updater(config as never); + + expect(config).toEqual({ + paymentOverride: undefined, + refundTo: undefined, + }); + }); + }); }); diff --git a/app/scripts/messenger-client-init/transaction-pay-controller-init.ts b/app/scripts/messenger-client-init/transaction-pay-controller-init.ts index b40950b717c9..19aa5891f573 100644 --- a/app/scripts/messenger-client-init/transaction-pay-controller-init.ts +++ b/app/scripts/messenger-client-init/transaction-pay-controller-init.ts @@ -1,4 +1,5 @@ import { + PaymentOverride, TransactionPayController, TransactionPayControllerMessenger, TransactionPayStrategy, @@ -75,6 +76,27 @@ function getApi( config.accountOverride = accountOverride; }); }, + setTransactionPayPaymentOverride: ( + transactionId: string, + { + paymentOverride, + refundTo, + }: { + paymentOverride?: PaymentOverride; + refundTo?: Hex; + } = {}, + ) => { + messengerClient.setTransactionConfig(transactionId, (config) => { + config.paymentOverride = paymentOverride; + if (paymentOverride === undefined) { + config.refundTo = undefined; + return; + } + if (refundTo !== undefined) { + config.refundTo = refundTo; + } + }); + }, updateTransactionPaymentToken: messengerClient.updatePaymentToken.bind(messengerClient), }; diff --git a/test/e2e/feature-flags/feature-flag-registry.ts b/test/e2e/feature-flags/feature-flag-registry.ts index 1a3a36fd4f48..4a91878de5fd 100644 --- a/test/e2e/feature-flags/feature-flag-registry.ts +++ b/test/e2e/feature-flags/feature-flag-registry.ts @@ -1163,6 +1163,10 @@ export const FEATURE_FLAG_REGISTRY: Record = { }, }, }, + enableMoneyAccountTransactions: { + perpsDeposit: false, + perpsWithdraw: false, + }, }, }, { @@ -1188,6 +1192,10 @@ export const FEATURE_FLAG_REGISTRY: Record = { }, }, }, + enableMoneyAccountTransactions: { + perpsDeposit: false, + perpsWithdraw: false, + }, }, }, ], diff --git a/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx index 412911038641..b3630e089811 100644 --- a/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx +++ b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx @@ -20,14 +20,21 @@ import { addToken, findNetworkClientIdByChainId, } from '../../../../../store/actions'; +import { selectIsMoneyAccountTransactionEnabled } from '../../../selectors/feature-flags'; +import { usePayWithSections } from '../../../hooks/pay/usePayWithSections'; import { PayWithModal } from './pay-with-modal'; jest.mock('../../../hooks/pay/useTransactionPayToken'); jest.mock('../../../hooks/pay/useTransactionPayData'); jest.mock('../../../hooks/pay/useTransactionPayBlockedTokens'); jest.mock('../../../hooks/pay/useWithdrawTokenFilter'); +jest.mock('../../../hooks/pay/usePayWithSections'); jest.mock('../../../utils/transaction-pay'); jest.mock('../../../../../hooks/musd'); +jest.mock('../../../selectors/feature-flags', () => ({ + ...jest.requireActual('../../../selectors/feature-flags'), + selectIsMoneyAccountTransactionEnabled: jest.fn(), +})); jest.mock('../../../context/confirm', () => ({ useConfirmContext: jest.fn(), })); @@ -124,6 +131,10 @@ describe('PayWithModal', () => { const usePostQuoteWithdrawTokenFilterMock = jest.mocked( usePostQuoteWithdrawTokenFilter, ); + const selectIsMoneyAccountTransactionEnabledMock = jest.mocked( + selectIsMoneyAccountTransactionEnabled, + ); + const usePayWithSectionsMock = jest.mocked(usePayWithSections); beforeEach(() => { jest.resetAllMocks(); @@ -131,6 +142,8 @@ describe('PayWithModal', () => { useConfirmContextMock.mockReturnValue({ currentConfirmation: {}, } as ReturnType); + selectIsMoneyAccountTransactionEnabledMock.mockReturnValue(false); + usePayWithSectionsMock.mockReturnValue({ sections: [] }); getAvailableTokensMock.mockImplementation(({ tokens }) => tokens as never); useTransactionPayBlockedTokensMock.mockReturnValue({ @@ -387,4 +400,101 @@ describe('PayWithModal', () => { consoleErrorSpy.mockRestore(); }); }); + + describe('money account pay sections', () => { + beforeEach(() => { + selectIsMoneyAccountTransactionEnabledMock.mockReturnValue(true); + useConfirmContextMock.mockReturnValue({ + currentConfirmation: { + type: TransactionType.perpsDeposit, + }, + } as ReturnType); + usePayWithSectionsMock.mockReturnValue({ + sections: [ + { + id: 'money-account', + title: '', + testId: 'pay-with-section-money-account', + rows: [ + { + id: 'money-account-musd', + icon: , + title: 'Money account', + subtitle: '$7.05 available', + testId: 'pay-with-money-account-row', + }, + ], + }, + { + id: 'crypto', + title: 'Crypto', + testId: 'pay-with-section-crypto', + rows: [ + { + id: 'crypto-other-assets', + icon: , + title: 'Other assets', + subtitle: 'Select from your tokens', + trailingElement: 'chevron', + onPress: jest.fn(), + testId: 'pay-with-crypto-section-other-assets-row', + }, + ], + }, + ], + }); + }); + + it('renders sectioned pay options when money account transactions are enabled', () => { + renderModal({ isOpen: true, onClose: onCloseMock }); + + expect(screen.getByTestId('pay-with-sections')).toBeInTheDocument(); + expect( + screen.getByTestId('pay-with-money-account-row'), + ).toBeInTheDocument(); + expect( + screen.getByText(messages.payWithMoneyAccount.message), + ).toBeInTheDocument(); + expect(screen.queryByTestId('asset-component')).not.toBeInTheDocument(); + }); + + it('keeps the token asset picker when money account transactions are disabled', () => { + selectIsMoneyAccountTransactionEnabledMock.mockReturnValue(false); + + renderModal({ isOpen: true, onClose: onCloseMock }); + + expect(screen.getByTestId('asset-component')).toBeInTheDocument(); + expect(screen.queryByTestId('pay-with-sections')).not.toBeInTheDocument(); + }); + + it('switches to the asset picker when Other assets is pressed', () => { + usePayWithSectionsMock.mockImplementation(({ onOtherAssetsPress }) => ({ + sections: [ + { + id: 'crypto', + title: 'Crypto', + testId: 'pay-with-section-crypto', + rows: [ + { + id: 'crypto-other-assets', + icon: , + title: 'Other assets', + onPress: () => onOtherAssetsPress(), + testId: 'pay-with-crypto-section-other-assets-row', + }, + ], + }, + ], + })); + + renderModal({ isOpen: true, onClose: onCloseMock }); + + fireEvent.click( + screen.getByTestId('pay-with-crypto-section-other-assets-row'), + ); + + expect(screen.getByTestId('asset-component')).toBeInTheDocument(); + expect(screen.queryByTestId('pay-with-sections')).not.toBeInTheDocument(); + }); + }); }); diff --git a/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx index bd79d660e636..2180f56d088b 100644 --- a/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx +++ b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx @@ -1,4 +1,5 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; import { Hex } from '@metamask/utils'; import { TransactionMeta, @@ -15,7 +16,10 @@ import { ScrollContainer } from '../../../../../contexts/scroll-container'; import { useTransactionPayToken } from '../../../hooks/pay/useTransactionPayToken'; import { useTransactionPayRequiredTokens } from '../../../hooks/pay/useTransactionPayData'; import { useTransactionPayBlockedTokens } from '../../../hooks/pay/useTransactionPayBlockedTokens'; -import { getAvailableTokens } from '../../../utils/transaction-pay'; +import { + clearPaymentOverride, + getAvailableTokens, +} from '../../../utils/transaction-pay'; import { Asset } from '../../send/asset'; import { type Asset as AssetType } from '../../../types/send'; import { @@ -30,6 +34,9 @@ import { } from '../../../../../store/actions'; import { isPostQuoteWithdrawTransaction } from '../../../../../../shared/lib/transactions.utils'; import { useDispatch } from '../../../../../store/hooks'; +import { selectIsMoneyAccountTransactionEnabled } from '../../../selectors/feature-flags'; +import { usePayWithSections } from '../../../hooks/pay/usePayWithSections'; +import { PayWithSection } from './pay-with-section'; export type PayWithModalProps = { isOpen: boolean; @@ -43,6 +50,11 @@ export const PayWithModal = ({ isOpen, onClose }: PayWithModalProps) => { const { payToken, setPayToken } = useTransactionPayToken(); const requiredTokens = useTransactionPayRequiredTokens(); const blockedTokens = useTransactionPayBlockedTokens(); + const [showOtherAssets, setShowOtherAssets] = useState(false); + + const isMoneyAccountPayEnabled = useSelector((state) => + selectIsMoneyAccountTransactionEnabled(state, currentConfirmation?.type), + ); const { filterTokens: musdTokenFilter } = useMusdConversionTokens({ transactionType: currentConfirmation?.type, @@ -60,9 +72,19 @@ export const PayWithModal = ({ isOpen, onClose }: PayWithModalProps) => { isPostQuoteWithdrawTransaction(currentConfirmation); const handleClose = useCallback(() => { + setShowOtherAssets(false); onClose(); }, [onClose]); + const handleOtherAssetsPress = useCallback(() => { + setShowOtherAssets(true); + }, []); + + const { sections } = usePayWithSections({ + onClose: handleClose, + onOtherAssetsPress: handleOtherAssetsPress, + }); + const handleTokenSelect = useCallback( async (token: AssetType) => { if (token.disabled) { @@ -118,11 +140,14 @@ export const PayWithModal = ({ isOpen, onClose }: PayWithModalProps) => { } } + if (currentConfirmation?.id) { + clearPaymentOverride(currentConfirmation.id); + } setPayToken(tokenSelection); handleClose(); }, [ - currentConfirmation?.type, + currentConfirmation, dispatch, handleClose, isPostQuoteWithdraw, @@ -159,11 +184,21 @@ export const PayWithModal = ({ isOpen, onClose }: PayWithModalProps) => { ], ); + const showSections = + isMoneyAccountPayEnabled && !showOtherAssets && !isPostQuoteWithdraw; + return ( - + setShowOtherAssets(false), + } + : {})} + > {t('payWithModalTitle')} { overflow: 'auto', }} > - + {showSections ? ( +
+ {sections.map((section) => ( + + ))} +
+ ) : ( + + )}
diff --git a/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.types.ts b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.types.ts new file mode 100644 index 000000000000..a17bb8657860 --- /dev/null +++ b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.types.ts @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; + +export type PayWithRowTrailingVariant = 'checkmark' | 'chevron' | 'none'; + +export type PayWithRowConfig = { + id: string; + icon: ReactNode; + title: string; + subtitle?: string; + isSelected?: boolean; + trailingElement?: PayWithRowTrailingVariant; + onPress?: () => void; + testId?: string; +}; + +export type PayWithSectionConfig = { + id: string; + title: string; + rows: PayWithRowConfig[]; + testId?: string; +}; diff --git a/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-section.test.tsx b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-section.test.tsx new file mode 100644 index 000000000000..2e4e76322da7 --- /dev/null +++ b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-section.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import { renderWithProvider } from '../../../../../../test/lib/render-helpers-navigate'; +import configureStore from '../../../../../store/store'; +import mockState from '../../../../../../test/data/mock-state.json'; +import { PayWithSection } from './pay-with-section'; +import type { PayWithSectionConfig } from './pay-with-modal.types'; + +const renderSection = (config: PayWithSectionConfig) => + renderWithProvider( + , + configureStore(mockState), + ); + +describe('PayWithSection', () => { + it('renders the section title and rows', () => { + const onPress = jest.fn(); + + renderSection({ + id: 'crypto', + title: 'Crypto', + testId: 'pay-with-section-crypto', + rows: [ + { + id: 'other-assets', + icon: , + title: 'Other assets', + subtitle: 'Select from your tokens', + trailingElement: 'chevron', + onPress, + testId: 'other-assets-row', + }, + ], + }); + + expect(screen.getByTestId('pay-with-section-crypto')).toBeInTheDocument(); + expect( + screen.getByTestId('pay-with-section-crypto-title'), + ).toHaveTextContent('Crypto'); + expect(screen.getByTestId('other-assets-row')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('other-assets-row')); + expect(onPress).toHaveBeenCalled(); + }); + + it('omits the title when empty and derives a default test id', () => { + renderSection({ + id: 'money-account', + title: '', + rows: [ + { + id: 'money', + icon: , + title: 'Money account', + testId: 'money-row', + }, + ], + }); + + expect( + screen.getByTestId('pay-with-section-money-account'), + ).toBeInTheDocument(); + expect( + screen.queryByTestId('pay-with-section-money-account-title'), + ).not.toBeInTheDocument(); + expect(screen.getByTestId('money-row')).toBeInTheDocument(); + }); +}); diff --git a/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-section.tsx b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-section.tsx new file mode 100644 index 000000000000..96463774f0cf --- /dev/null +++ b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-section.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Box, Text } from '../../../../../components/component-library'; +import { + TextColor, + TextTransform, + TextVariant, +} from '../../../../../helpers/constants/design-system'; +import { PaymentMethodRow } from './payment-method-row'; +import type { PayWithSectionConfig } from './pay-with-modal.types'; + +type PayWithSectionProps = { + config: PayWithSectionConfig; +}; + +export function PayWithSection({ config }: PayWithSectionProps) { + const testId = config.testId ?? `pay-with-section-${config.id}`; + + return ( + + {config.title ? ( + + + {config.title} + + + ) : null} + + {config.rows.map((row) => ( + + ))} + + + ); +} diff --git a/ui/pages/confirmations/components/modals/pay-with-modal/payment-method-row.test.tsx b/ui/pages/confirmations/components/modals/pay-with-modal/payment-method-row.test.tsx new file mode 100644 index 000000000000..53c96b17ac7f --- /dev/null +++ b/ui/pages/confirmations/components/modals/pay-with-modal/payment-method-row.test.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import { renderWithProvider } from '../../../../../../test/lib/render-helpers-navigate'; +import configureStore from '../../../../../store/store'; +import mockState from '../../../../../../test/data/mock-state.json'; +import { PaymentMethodRow } from './payment-method-row'; + +const renderRow = ( + props: Partial> = {}, +) => + renderWithProvider( + } + title="Money account" + {...props} + />, + configureStore(mockState), + ); + +describe('PaymentMethodRow', () => { + it('renders title, subtitle, and icon slot', () => { + renderRow({ + subtitle: '$7.05 available', + testId: 'payment-method-row', + }); + + expect(screen.getByTestId('payment-method-row')).toBeInTheDocument(); + expect(screen.getByTestId('payment-method-row-title')).toHaveTextContent( + 'Money account', + ); + expect(screen.getByTestId('payment-method-row-subtitle')).toHaveTextContent( + '$7.05 available', + ); + expect( + screen.getByTestId('payment-method-row-icon-slot'), + ).toBeInTheDocument(); + expect(screen.getByTestId('row-icon')).toBeInTheDocument(); + }); + + it('uses a default test id derived from the row id', () => { + renderRow(); + + expect(screen.getByTestId('payment-method-row-row-1')).toBeInTheDocument(); + }); + + it('invokes onPress when clicked', () => { + const onPress = jest.fn(); + renderRow({ onPress, testId: 'payment-method-row' }); + + fireEvent.click(screen.getByTestId('payment-method-row')); + + expect(onPress).toHaveBeenCalled(); + }); + + it('renders a checkmark trailing element when selected', () => { + renderRow({ + trailingElement: 'checkmark', + isSelected: true, + testId: 'payment-method-row', + }); + + expect( + screen.getByTestId('payment-method-row-checkmark'), + ).toBeInTheDocument(); + }); + + it('renders a chevron trailing element', () => { + renderRow({ + trailingElement: 'chevron', + testId: 'payment-method-row', + }); + + expect( + screen.getByTestId('payment-method-row-chevron'), + ).toBeInTheDocument(); + }); + + it('renders no trailing element by default', () => { + renderRow({ testId: 'payment-method-row' }); + + expect( + screen.queryByTestId('payment-method-row-checkmark'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('payment-method-row-chevron'), + ).not.toBeInTheDocument(); + }); + + it('omits subtitle when not provided', () => { + renderRow({ testId: 'payment-method-row' }); + + expect( + screen.queryByTestId('payment-method-row-subtitle'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/pages/confirmations/components/modals/pay-with-modal/payment-method-row.tsx b/ui/pages/confirmations/components/modals/pay-with-modal/payment-method-row.tsx new file mode 100644 index 000000000000..d57051f2223e --- /dev/null +++ b/ui/pages/confirmations/components/modals/pay-with-modal/payment-method-row.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import { + Box, + Icon, + IconName, + IconSize, + Text, +} from '../../../../../components/component-library'; +import { + AlignItems, + BackgroundColor, + BorderRadius, + Display, + FlexDirection, + IconColor, + JustifyContent, + TextColor, + TextVariant, +} from '../../../../../helpers/constants/design-system'; +import type { PayWithRowConfig } from './pay-with-modal.types'; + +export function PaymentMethodRow({ + id, + icon, + title, + subtitle, + isSelected, + trailingElement = 'none', + onPress, + testId, +}: PayWithRowConfig) { + const resolvedTestId = testId ?? `payment-method-row-${id}`; + + return ( + + + {/* Absolute center: BadgeWrapper sets `align-self: start`, which + pins TokenIcon to the top of a flex slot and looks broken. */} + + {icon} + + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {trailingElement === 'checkmark' ? ( + + ) : null} + {trailingElement === 'chevron' ? ( + + ) : null} + + ); +} diff --git a/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx b/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx index cd95af70abaa..9ae1df2e1671 100644 --- a/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx +++ b/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx @@ -3,6 +3,7 @@ import { screen, fireEvent } from '@testing-library/react'; import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { TransactionType } from '@metamask/transaction-controller'; +import { PaymentOverride } from '@metamask/transaction-pay-controller'; import { renderWithProvider } from '../../../../../../test/lib/render-helpers-navigate'; import { useTransactionPayToken } from '../../../hooks/pay/useTransactionPayToken'; import { useTransactionPayRequiredTokens } from '../../../hooks/pay/useTransactionPayData'; @@ -10,10 +11,15 @@ import { useSendTokens } from '../../../hooks/send/useSendTokens'; import { useConfirmContext } from '../../../context/confirm'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0021): route-isolation backlog import { isHardwareAccount } from '../../../../multichain-accounts/account-details/account-type-utils'; +import { MONEY_ACCOUNT_DUMMY_BALANCE_FIAT } from '../../../hooks/pay/sections/usePayWithMoneyAccountSection'; import { PayWithRow, PayWithRowSkeleton } from './pay-with-row'; jest.mock('../../../hooks/pay/useTransactionPayToken'); jest.mock('../../../hooks/pay/useTransactionPayData'); +jest.mock('../../../selectors/feature-flags', () => ({ + ...jest.requireActual('../../../selectors/feature-flags'), + selectIsMoneyAccountTransactionEnabled: jest.fn(() => false), +})); jest.mock('../../../hooks/send/useSendTokens'); jest.mock('../../../context/confirm'); jest.mock('../../../../multichain-accounts/account-details/account-type-utils'); @@ -59,7 +65,32 @@ const FROM_ADDRESS_MOCK = '0xabcdef1234567890abcdef1234567890abcdef12'; const mockStore = configureStore([thunk]); -const getMockState = () => ({ +const MOCK_PAY_TOKEN = { + address: ADDRESS_MOCK, + balanceHuman: '1.5', + balanceFiat: '$150.00', + balanceRaw: '1500000000000000000', + balanceUsd: '150', + chainId: CHAIN_ID_MOCK, + decimals: 18, + symbol: 'ETH', +} as const; + +const MOCK_REQUIRED_TOKEN = { + ...MOCK_PAY_TOKEN, + allowUnderMinimum: false, + amountFiat: '$50.00', + amountHuman: '0.5', + amountRaw: '500000000000000000', + amountUsd: '50', + skipIfBalance: false, +} as const; + +const getMockState = ({ + paymentOverride, +}: { + paymentOverride?: PaymentOverride; +} = {}) => ({ metamask: { internalAccounts: { accounts: { @@ -104,30 +135,15 @@ const getMockState = () => ({ }, }, multichainNetworkConfigurationsByChainId: {}, + transactionData: { + 'test-id': { + paymentOverride, + paymentToken: MOCK_PAY_TOKEN, + }, + }, }, }); -const MOCK_PAY_TOKEN = { - address: ADDRESS_MOCK, - balanceHuman: '1.5', - balanceFiat: '$150.00', - balanceRaw: '1500000000000000000', - balanceUsd: '150', - chainId: CHAIN_ID_MOCK, - decimals: 18, - symbol: 'ETH', -} as const; - -const MOCK_REQUIRED_TOKEN = { - ...MOCK_PAY_TOKEN, - allowUnderMinimum: false, - amountFiat: '$50.00', - amountHuman: '0.5', - amountRaw: '500000000000000000', - amountUsd: '50', - skipIfBalance: false, -} as const; - describe('PayWithRow', () => { const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); const useTransactionPayRequiredTokensMock = jest.mocked( @@ -354,6 +370,23 @@ describe('PayWithRow', () => { expect(screen.queryByTestId('pay-with-balance')).not.toBeInTheDocument(); }); }); + + it('renders the Money account icon and dummy balance when selected', () => { + const store = mockStore( + getMockState({ paymentOverride: PaymentOverride.MoneyAccount }), + ); + renderWithProvider(, store); + + expect( + screen.getByTestId('pay-with-money-account-icon'), + ).toBeInTheDocument(); + expect(screen.getByTestId('pay-with-symbol')).toHaveTextContent( + 'Money account', + ); + expect(screen.getByTestId('pay-with-balance')).toHaveTextContent( + `(${MONEY_ACCOUNT_DUMMY_BALANCE_FIAT})`, + ); + }); }); describe('PayWithRowSkeleton', () => { diff --git a/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.tsx b/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.tsx index ed332fbf2c1f..43a3bccf6c8f 100644 --- a/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.tsx +++ b/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.tsx @@ -57,6 +57,7 @@ type PaySelectorContentProps = { balanceText: string; showBalance: boolean; showArrow: boolean; + isMoneyAccountSelected?: boolean; }; function PaySelectorContent({ @@ -64,6 +65,7 @@ function PaySelectorContent({ balanceText, showBalance, showArrow, + isMoneyAccountSelected = false, }: PaySelectorContentProps) { return ( <> @@ -72,12 +74,22 @@ function PaySelectorContent({ alignItems={AlignItems.center} marginRight={1} > - + {isMoneyAccountSelected ? ( + + ) : ( + + )} {displayToken.symbol} @@ -117,6 +129,7 @@ export function PayWithRow({ from, ownerId, isPerpsWithdraw, + isMoneyAccountSelected, openModal, modal, } = usePayWithToken(); @@ -148,6 +161,7 @@ export function PayWithRow({ balanceText={` (${balanceUsdFormatted})`} showBalance={!isPerpsWithdraw} showArrow={canEdit && Boolean(from)} + isMoneyAccountSelected={isMoneyAccountSelected} /> diff --git a/ui/pages/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx b/ui/pages/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx new file mode 100644 index 000000000000..114834f63dea --- /dev/null +++ b/ui/pages/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { + PaymentOverride, + type TransactionPaymentToken, +} from '@metamask/transaction-pay-controller'; +import { useSelector } from 'react-redux'; +import { useConfirmContext } from '../../../context/confirm'; +import { useTransactionPayToken } from '../useTransactionPayToken'; +import { useClearPaymentOverride } from '../useClearPaymentOverride'; +import { + PAY_WITH_CRYPTO_OTHER_ASSETS_ROW_TEST_ID, + PAY_WITH_CRYPTO_SECTION_TEST_ID, + PAY_WITH_CRYPTO_SELECTED_TOKEN_ROW_TEST_ID, + usePayWithCryptoSection, +} from './usePayWithCryptoSection'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); +jest.mock('../../../context/confirm', () => ({ + useConfirmContext: jest.fn(), +})); +jest.mock('../useTransactionPayToken', () => ({ + useTransactionPayToken: jest.fn(), +})); +jest.mock('../useClearPaymentOverride', () => ({ + useClearPaymentOverride: jest.fn(), +})); +jest.mock('../../../../../hooks/useI18nContext', () => ({ + useI18nContext: () => (key: string) => { + const messages: Record = { + available: 'available', + payWithCrypto: 'Crypto', + payWithOtherAssets: 'Other assets', + payWithOtherAssetsDescription: 'Select from your tokens', + }; + return messages[key] ?? key; + }, +})); +jest.mock('../../../../../hooks/useFiatFormatter', () => ({ + useFiatFormatter: () => (value: number) => `$${value.toFixed(2)}`, +})); +jest.mock('../../../components/token-icon', () => ({ + TokenIcon: () => , +})); + +const PAY_TOKEN = { + address: '0x1111111111111111111111111111111111111111', + chainId: '0x1', + symbol: 'USDC', + balanceUsd: '12.5', + balanceFiat: '$12.50', + balanceHuman: '12.5', + balanceRaw: '12500000', + decimals: 6, +} as TransactionPaymentToken; + +describe('usePayWithCryptoSection', () => { + const useSelectorMock = jest.mocked(useSelector); + const useConfirmContextMock = jest.mocked(useConfirmContext); + const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); + const useClearPaymentOverrideMock = jest.mocked(useClearPaymentOverride); + const onClose = jest.fn(); + const onOtherAssetsPress = jest.fn(); + const setPayToken = jest.fn(); + const clearOverride = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + + useConfirmContextMock.mockReturnValue({ + currentConfirmation: { id: 'tx-1' }, + } as ReturnType); + useSelectorMock.mockReturnValue(undefined); + useClearPaymentOverrideMock.mockReturnValue(clearOverride); + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN, + setPayToken, + isNative: false, + }); + }); + + it('returns a crypto section with selected token and other assets rows', () => { + const { result } = renderHook(() => + usePayWithCryptoSection({ onClose, onOtherAssetsPress }), + ); + + expect(result.current).toMatchObject({ + id: 'crypto', + title: 'Crypto', + testId: PAY_WITH_CRYPTO_SECTION_TEST_ID, + rows: [ + expect.objectContaining({ + title: 'USDC', + subtitle: '$12.50 available', + isSelected: true, + trailingElement: 'checkmark', + testId: PAY_WITH_CRYPTO_SELECTED_TOKEN_ROW_TEST_ID, + }), + expect.objectContaining({ + title: 'Other assets', + subtitle: 'Select from your tokens', + trailingElement: 'chevron', + testId: PAY_WITH_CRYPTO_OTHER_ASSETS_ROW_TEST_ID, + }), + ], + }); + }); + + it('omits the selected token row when payToken is missing', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + setPayToken, + isNative: false, + }); + + const { result } = renderHook(() => + usePayWithCryptoSection({ onClose, onOtherAssetsPress }), + ); + + expect(result.current?.rows).toHaveLength(1); + expect(result.current?.rows[0].testId).toBe( + PAY_WITH_CRYPTO_OTHER_ASSETS_ROW_TEST_ID, + ); + }); + + it('marks the selected token unselected when Money account is active', () => { + useSelectorMock.mockReturnValue(PaymentOverride.MoneyAccount); + + const { result } = renderHook(() => + usePayWithCryptoSection({ onClose, onOtherAssetsPress }), + ); + + expect(result.current?.rows[0].isSelected).toBe(false); + expect(result.current?.rows[0].trailingElement).toBe('none'); + }); + + it('closes without clearing override when selected token is already active', () => { + const { result } = renderHook(() => + usePayWithCryptoSection({ onClose, onOtherAssetsPress }), + ); + + act(() => { + result.current?.rows[0].onPress?.(); + }); + + expect(clearOverride).not.toHaveBeenCalled(); + expect(setPayToken).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('clears override and reselects pay token when Money account is active', () => { + useSelectorMock.mockReturnValue(PaymentOverride.MoneyAccount); + + const { result } = renderHook(() => + usePayWithCryptoSection({ onClose, onOtherAssetsPress }), + ); + + act(() => { + result.current?.rows[0].onPress?.(); + }); + + expect(clearOverride).toHaveBeenCalled(); + expect(setPayToken).toHaveBeenCalledWith({ + address: PAY_TOKEN.address, + chainId: PAY_TOKEN.chainId, + }); + expect(onClose).toHaveBeenCalled(); + }); + + it('opens other assets without clearing override', () => { + useSelectorMock.mockReturnValue(PaymentOverride.MoneyAccount); + + const { result } = renderHook(() => + usePayWithCryptoSection({ onClose, onOtherAssetsPress }), + ); + + act(() => { + result.current?.rows[1].onPress?.(); + }); + + expect(clearOverride).not.toHaveBeenCalled(); + expect(onOtherAssetsPress).toHaveBeenCalled(); + }); +}); diff --git a/ui/pages/confirmations/hooks/pay/sections/usePayWithCryptoSection.tsx b/ui/pages/confirmations/hooks/pay/sections/usePayWithCryptoSection.tsx new file mode 100644 index 000000000000..6310d3698124 --- /dev/null +++ b/ui/pages/confirmations/hooks/pay/sections/usePayWithCryptoSection.tsx @@ -0,0 +1,133 @@ +import React, { useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { BigNumber } from 'bignumber.js'; +import { PaymentOverride } from '@metamask/transaction-pay-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + Icon, + IconName, + IconSize, +} from '../../../../../components/component-library'; +import { IconColor } from '../../../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { useFiatFormatter } from '../../../../../hooks/useFiatFormatter'; +import { + selectPaymentOverrideByTransactionId, + type TransactionPayState, +} from '../../../../../selectors/transactionPayController'; +import { useConfirmContext } from '../../../context/confirm'; +import { TokenIcon } from '../../../components/token-icon'; +import type { + PayWithRowConfig, + PayWithSectionConfig, +} from '../../../components/modals/pay-with-modal/pay-with-modal.types'; +import { useTransactionPayToken } from '../useTransactionPayToken'; +import { useClearPaymentOverride } from '../useClearPaymentOverride'; + +export const PAY_WITH_CRYPTO_SECTION_TEST_ID = 'pay-with-section-crypto'; +export const PAY_WITH_CRYPTO_SELECTED_TOKEN_ROW_TEST_ID = + 'pay-with-crypto-section-selected-token-row'; +export const PAY_WITH_CRYPTO_OTHER_ASSETS_ROW_TEST_ID = + 'pay-with-crypto-section-other-assets-row'; + +type UsePayWithCryptoSectionArgs = { + onClose: () => void; + onOtherAssetsPress: () => void; +}; + +export function usePayWithCryptoSection({ + onClose, + onOtherAssetsPress, +}: UsePayWithCryptoSectionArgs): PayWithSectionConfig | null { + const t = useI18nContext(); + const fiatFormatter = useFiatFormatter({ overrideCurrency: 'usd' }); + const { currentConfirmation } = useConfirmContext(); + const transactionId = currentConfirmation?.id ?? ''; + const { payToken, setPayToken } = useTransactionPayToken(); + const clearOverride = useClearPaymentOverride(); + + const paymentOverride = useSelector((state: TransactionPayState) => + selectPaymentOverrideByTransactionId(state, transactionId), + ); + const isMoneyAccountSelected = + paymentOverride === PaymentOverride.MoneyAccount; + + const handleSelectedTokenPress = useCallback(() => { + if (!payToken) { + return; + } + if (!isMoneyAccountSelected) { + onClose(); + return; + } + clearOverride(); + setPayToken({ + address: payToken.address, + chainId: payToken.chainId, + }); + onClose(); + }, [clearOverride, isMoneyAccountSelected, onClose, payToken, setPayToken]); + + const handleOtherAssetsPress = useCallback(() => { + onOtherAssetsPress(); + }, [onOtherAssetsPress]); + + const selectedTokenBalance = useMemo( + () => fiatFormatter(new BigNumber(payToken?.balanceUsd ?? '0').toNumber()), + [fiatFormatter, payToken?.balanceUsd], + ); + + return useMemo((): PayWithSectionConfig => { + const rows: PayWithRowConfig[] = []; + + if (payToken) { + rows.push({ + id: 'crypto-selected-token', + icon: ( + + ), + title: payToken.symbol, + subtitle: `${selectedTokenBalance} ${t('available')}`, + isSelected: !isMoneyAccountSelected, + trailingElement: isMoneyAccountSelected ? 'none' : 'checkmark', + onPress: handleSelectedTokenPress, + testId: PAY_WITH_CRYPTO_SELECTED_TOKEN_ROW_TEST_ID, + }); + } + + rows.push({ + id: 'crypto-other-assets', + icon: ( + + ), + title: t('payWithOtherAssets'), + subtitle: t('payWithOtherAssetsDescription'), + trailingElement: 'chevron', + onPress: handleOtherAssetsPress, + testId: PAY_WITH_CRYPTO_OTHER_ASSETS_ROW_TEST_ID, + }); + + return { + id: 'crypto', + title: t('payWithCrypto'), + testId: PAY_WITH_CRYPTO_SECTION_TEST_ID, + rows, + }; + }, [ + handleOtherAssetsPress, + handleSelectedTokenPress, + isMoneyAccountSelected, + payToken, + selectedTokenBalance, + t, + ]); +} diff --git a/ui/pages/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.test.tsx b/ui/pages/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.test.tsx new file mode 100644 index 000000000000..6a2a98ce49b1 --- /dev/null +++ b/ui/pages/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.test.tsx @@ -0,0 +1,124 @@ +import { renderHook, act } from '@testing-library/react'; +import { TransactionType } from '@metamask/transaction-controller'; +import { PaymentOverride } from '@metamask/transaction-pay-controller'; +import { useSelector } from 'react-redux'; +import { useConfirmContext } from '../../../context/confirm'; +import { applyMoneyAccountOverride } from '../../../utils/transaction-pay'; +import { + PAY_WITH_MONEY_ACCOUNT_ROW_TEST_ID, + usePayWithMoneyAccountSection, +} from './usePayWithMoneyAccountSection'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); +jest.mock('../../../context/confirm', () => ({ + useConfirmContext: jest.fn(), +})); +jest.mock('../../../../../hooks/useI18nContext', () => ({ + useI18nContext: () => (key: string) => { + const messages: Record = { + payWithMoneyAccount: 'Money account', + available: 'available', + }; + return messages[key] ?? key; + }, +})); +jest.mock('../../../utils/transaction-pay', () => ({ + applyMoneyAccountOverride: jest.fn(), +})); + +describe('usePayWithMoneyAccountSection', () => { + const useSelectorMock = jest.mocked(useSelector); + const useConfirmContextMock = jest.mocked(useConfirmContext); + const applyMoneyAccountOverrideMock = jest.mocked(applyMoneyAccountOverride); + const onClose = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + + useConfirmContextMock.mockReturnValue({ + currentConfirmation: { + id: 'tx-1', + type: TransactionType.perpsDeposit, + }, + } as ReturnType); + + // First selector call is isEnabled; second is paymentOverride. + let call = 0; + useSelectorMock.mockImplementation(() => { + call += 1; + if (call === 1) { + return true; + } + return undefined; + }); + }); + + it('returns null when money account transactions are disabled', () => { + useSelectorMock.mockImplementation(() => false); + + const { result } = renderHook(() => + usePayWithMoneyAccountSection({ onClose }), + ); + + expect(result.current).toBeNull(); + }); + + it('returns a money account section when enabled', () => { + const { result } = renderHook(() => + usePayWithMoneyAccountSection({ onClose }), + ); + + expect(result.current).toMatchObject({ + id: 'money-account', + rows: [ + expect.objectContaining({ + title: 'Money account', + subtitle: '$7.05 available', + testId: PAY_WITH_MONEY_ACCOUNT_ROW_TEST_ID, + isSelected: false, + }), + ], + }); + }); + + it('applies the money account override and closes on press', () => { + const { result } = renderHook(() => + usePayWithMoneyAccountSection({ onClose }), + ); + + act(() => { + result.current?.rows[0].onPress?.(); + }); + + expect(applyMoneyAccountOverrideMock).toHaveBeenCalledWith( + 'tx-1', + undefined, + expect.objectContaining({ + id: 'tx-1', + type: TransactionType.perpsDeposit, + }), + ); + expect(onClose).toHaveBeenCalled(); + }); + + it('marks the row selected when paymentOverride is MoneyAccount', () => { + let call = 0; + useSelectorMock.mockImplementation(() => { + call += 1; + if (call === 1) { + return true; + } + return PaymentOverride.MoneyAccount; + }); + + const { result } = renderHook(() => + usePayWithMoneyAccountSection({ onClose }), + ); + + expect(result.current?.rows[0].isSelected).toBe(true); + expect(result.current?.rows[0].trailingElement).toBe('checkmark'); + }); +}); diff --git a/ui/pages/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx b/ui/pages/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx new file mode 100644 index 000000000000..8036b8705f7f --- /dev/null +++ b/ui/pages/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx @@ -0,0 +1,83 @@ +import React, { useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { PaymentOverride } from '@metamask/transaction-pay-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + selectPaymentOverrideByTransactionId, + type TransactionPayState, +} from '../../../../../selectors/transactionPayController'; +import { selectIsMoneyAccountTransactionEnabled } from '../../../selectors/feature-flags'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { useConfirmContext } from '../../../context/confirm'; +import { applyMoneyAccountOverride } from '../../../utils/transaction-pay'; +import type { PayWithSectionConfig } from '../../../components/modals/pay-with-modal/pay-with-modal.types'; + +export const PAY_WITH_MONEY_ACCOUNT_SECTION_TEST_ID = + 'pay-with-section-money-account'; +export const PAY_WITH_MONEY_ACCOUNT_ROW_TEST_ID = 'pay-with-money-account-row'; + +/** Temporary placeholder until Money account balance wiring lands. */ +export const MONEY_ACCOUNT_DUMMY_BALANCE_FIAT = '$7.05'; + +type UsePayWithMoneyAccountSectionArgs = { + onClose: () => void; +}; + +export function usePayWithMoneyAccountSection({ + onClose, +}: UsePayWithMoneyAccountSectionArgs): PayWithSectionConfig | null { + const t = useI18nContext(); + const { currentConfirmation } = useConfirmContext(); + const transactionId = currentConfirmation?.id ?? ''; + const transactionType = currentConfirmation?.type; + + const isEnabled = useSelector((state) => + selectIsMoneyAccountTransactionEnabled(state, transactionType), + ); + + const paymentOverride = useSelector((state: TransactionPayState) => + selectPaymentOverrideByTransactionId(state, transactionId), + ); + const isMoneyAccountSelected = + paymentOverride === PaymentOverride.MoneyAccount; + + const handlePress = useCallback(() => { + if (!transactionId) { + return; + } + applyMoneyAccountOverride(transactionId, undefined, currentConfirmation); + onClose(); + }, [currentConfirmation, onClose, transactionId]); + + return useMemo(() => { + if (!isEnabled) { + return null; + } + + return { + id: 'money-account', + title: '', + testId: PAY_WITH_MONEY_ACCOUNT_SECTION_TEST_ID, + rows: [ + { + id: 'money-account-musd', + icon: ( + + ), + title: t('payWithMoneyAccount'), + subtitle: `${MONEY_ACCOUNT_DUMMY_BALANCE_FIAT} ${t('available')}`, + isSelected: isMoneyAccountSelected, + trailingElement: isMoneyAccountSelected ? 'checkmark' : 'none', + onPress: handlePress, + testId: PAY_WITH_MONEY_ACCOUNT_ROW_TEST_ID, + }, + ], + }; + }, [handlePress, isEnabled, isMoneyAccountSelected, t]); +} diff --git a/ui/pages/confirmations/hooks/pay/useClearPaymentOverride.test.ts b/ui/pages/confirmations/hooks/pay/useClearPaymentOverride.test.ts new file mode 100644 index 000000000000..8584250a054e --- /dev/null +++ b/ui/pages/confirmations/hooks/pay/useClearPaymentOverride.test.ts @@ -0,0 +1,69 @@ +import { renderHook, act } from '@testing-library/react'; +import { PaymentOverride } from '@metamask/transaction-pay-controller'; +import { useSelector } from 'react-redux'; +import { useConfirmContext } from '../../context/confirm'; +import { clearPaymentOverride } from '../../utils/transaction-pay'; +import { useClearPaymentOverride } from './useClearPaymentOverride'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); +jest.mock('../../context/confirm', () => ({ + useConfirmContext: jest.fn(), +})); +jest.mock('../../utils/transaction-pay', () => ({ + clearPaymentOverride: jest.fn(), +})); + +describe('useClearPaymentOverride', () => { + const useSelectorMock = jest.mocked(useSelector); + const useConfirmContextMock = jest.mocked(useConfirmContext); + const clearPaymentOverrideMock = jest.mocked(clearPaymentOverride); + + beforeEach(() => { + jest.resetAllMocks(); + useConfirmContextMock.mockReturnValue({ + currentConfirmation: { id: 'tx-1' }, + } as ReturnType); + }); + + it('clears the payment override when one is set', () => { + useSelectorMock.mockReturnValue(PaymentOverride.MoneyAccount); + + const { result } = renderHook(() => useClearPaymentOverride()); + + act(() => { + result.current(); + }); + + expect(clearPaymentOverrideMock).toHaveBeenCalledWith('tx-1'); + }); + + it('does not clear when no payment override is set', () => { + useSelectorMock.mockReturnValue(undefined); + + const { result } = renderHook(() => useClearPaymentOverride()); + + act(() => { + result.current(); + }); + + expect(clearPaymentOverrideMock).not.toHaveBeenCalled(); + }); + + it('does not clear when the confirmation has no id', () => { + useConfirmContextMock.mockReturnValue({ + currentConfirmation: {}, + } as ReturnType); + useSelectorMock.mockReturnValue(PaymentOverride.MoneyAccount); + + const { result } = renderHook(() => useClearPaymentOverride()); + + act(() => { + result.current(); + }); + + expect(clearPaymentOverrideMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/pages/confirmations/hooks/pay/useClearPaymentOverride.ts b/ui/pages/confirmations/hooks/pay/useClearPaymentOverride.ts new file mode 100644 index 000000000000..bcc8830f0694 --- /dev/null +++ b/ui/pages/confirmations/hooks/pay/useClearPaymentOverride.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + selectPaymentOverrideByTransactionId, + type TransactionPayState, +} from '../../../../selectors/transactionPayController'; +import { useConfirmContext } from '../../context/confirm'; +import { clearPaymentOverride } from '../../utils/transaction-pay'; + +/** + * Clears any active paymentOverride on the current confirmation. + * Call from non-money-account pay option handlers. + */ +export function useClearPaymentOverride(): () => void { + const { currentConfirmation } = useConfirmContext(); + const transactionId = currentConfirmation?.id ?? ''; + const paymentOverride = useSelector((state: TransactionPayState) => + selectPaymentOverrideByTransactionId(state, transactionId), + ); + + return useCallback(() => { + if (transactionId && paymentOverride) { + clearPaymentOverride(transactionId); + } + }, [paymentOverride, transactionId]); +} diff --git a/ui/pages/confirmations/hooks/pay/usePayWithSections.test.ts b/ui/pages/confirmations/hooks/pay/usePayWithSections.test.ts new file mode 100644 index 000000000000..1acd2c3770f0 --- /dev/null +++ b/ui/pages/confirmations/hooks/pay/usePayWithSections.test.ts @@ -0,0 +1,67 @@ +import { renderHook } from '@testing-library/react'; +import type { PayWithSectionConfig } from '../../components/modals/pay-with-modal/pay-with-modal.types'; +import { usePayWithCryptoSection } from './sections/usePayWithCryptoSection'; +import { usePayWithMoneyAccountSection } from './sections/usePayWithMoneyAccountSection'; +import { usePayWithSections } from './usePayWithSections'; + +jest.mock('./sections/usePayWithCryptoSection', () => ({ + usePayWithCryptoSection: jest.fn(), +})); +jest.mock('./sections/usePayWithMoneyAccountSection', () => ({ + usePayWithMoneyAccountSection: jest.fn(), +})); + +describe('usePayWithSections', () => { + const usePayWithCryptoSectionMock = jest.mocked(usePayWithCryptoSection); + const usePayWithMoneyAccountSectionMock = jest.mocked( + usePayWithMoneyAccountSection, + ); + const onClose = jest.fn(); + const onOtherAssetsPress = jest.fn(); + + const moneySection = { + id: 'money-account', + title: '', + rows: [], + } as PayWithSectionConfig; + + const cryptoSection = { + id: 'crypto', + title: 'Crypto', + rows: [], + } as PayWithSectionConfig; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns both sections when both hooks provide configs', () => { + usePayWithMoneyAccountSectionMock.mockReturnValue(moneySection); + usePayWithCryptoSectionMock.mockReturnValue(cryptoSection); + + const { result } = renderHook(() => + usePayWithSections({ onClose, onOtherAssetsPress }), + ); + + expect(usePayWithMoneyAccountSectionMock).toHaveBeenCalledWith({ onClose }); + expect(usePayWithCryptoSectionMock).toHaveBeenCalledWith({ + onClose, + onOtherAssetsPress, + }); + expect(result.current.sections).toStrictEqual([ + moneySection, + cryptoSection, + ]); + }); + + it('filters out null sections', () => { + usePayWithMoneyAccountSectionMock.mockReturnValue(null); + usePayWithCryptoSectionMock.mockReturnValue(cryptoSection); + + const { result } = renderHook(() => + usePayWithSections({ onClose, onOtherAssetsPress }), + ); + + expect(result.current.sections).toStrictEqual([cryptoSection]); + }); +}); diff --git a/ui/pages/confirmations/hooks/pay/usePayWithSections.ts b/ui/pages/confirmations/hooks/pay/usePayWithSections.ts new file mode 100644 index 000000000000..8abd0b8e0656 --- /dev/null +++ b/ui/pages/confirmations/hooks/pay/usePayWithSections.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; +import type { PayWithSectionConfig } from '../../components/modals/pay-with-modal/pay-with-modal.types'; +import { usePayWithCryptoSection } from './sections/usePayWithCryptoSection'; +import { usePayWithMoneyAccountSection } from './sections/usePayWithMoneyAccountSection'; + +export type UsePayWithSectionsResult = { + sections: PayWithSectionConfig[]; +}; + +type UsePayWithSectionsArgs = { + onClose: () => void; + onOtherAssetsPress: () => void; +}; + +export function usePayWithSections({ + onClose, + onOtherAssetsPress, +}: UsePayWithSectionsArgs): UsePayWithSectionsResult { + const moneyAccountSection = usePayWithMoneyAccountSection({ onClose }); + const cryptoSection = usePayWithCryptoSection({ + onClose, + onOtherAssetsPress, + }); + + return useMemo( + () => ({ + sections: [moneyAccountSection, cryptoSection].filter( + (section): section is PayWithSectionConfig => section !== null, + ), + }), + [cryptoSection, moneyAccountSection], + ); +} diff --git a/ui/pages/confirmations/hooks/pay/usePayWithToken.test.tsx b/ui/pages/confirmations/hooks/pay/usePayWithToken.test.tsx new file mode 100644 index 000000000000..e8406940d8f6 --- /dev/null +++ b/ui/pages/confirmations/hooks/pay/usePayWithToken.test.tsx @@ -0,0 +1,216 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { + PaymentOverride, + type TransactionPaymentToken, +} from '@metamask/transaction-pay-controller'; +import { + TransactionType, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import { useSelector } from 'react-redux'; +import { useConfirmContext } from '../../context/confirm'; +import { getInternalAccountByAddress } from '../../../../selectors/accounts'; +import { selectPaymentOverrideByTransactionId } from '../../../../selectors/transactionPayController'; +import { useTransactionPayToken } from './useTransactionPayToken'; +import { useTransactionPayRequiredTokens } from './useTransactionPayData'; +import { MONEY_ACCOUNT_DUMMY_BALANCE_FIAT } from './sections/usePayWithMoneyAccountSection'; +import { usePayWithToken } from './usePayWithToken'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); +jest.mock('../../context/confirm', () => ({ + useConfirmContext: jest.fn(), +})); +jest.mock('./useTransactionPayToken', () => ({ + useTransactionPayToken: jest.fn(), +})); +jest.mock('./useTransactionPayData', () => ({ + useTransactionPayRequiredTokens: jest.fn(), +})); +jest.mock('../../../../selectors/accounts', () => ({ + getInternalAccountByAddress: jest.fn(), +})); +jest.mock('../../../../selectors/transactionPayController', () => ({ + selectPaymentOverrideByTransactionId: jest.fn(), +})); +jest.mock('../../../../hooks/useI18nContext', () => ({ + useI18nContext: () => (key: string) => { + const messages: Record = { + payWith: 'Pay with', + withdrawTo: 'Withdraw to', + payWithMoneyAccount: 'Money account', + }; + return messages[key] ?? key; + }, +})); +jest.mock('../../../../hooks/useFiatFormatter', () => ({ + useFiatFormatter: () => (value: number) => `$${value.toFixed(2)}`, +})); +jest.mock( + '../../../multichain-accounts/account-details/account-type-utils', + () => ({ + isHardwareAccount: jest.fn(() => false), + }), +); +jest.mock('../../components/modals/pay-with-modal', () => ({ + PayWithModal: ({ isOpen }: { isOpen: boolean }) => + isOpen ?
: null, +})); + +const FROM_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12'; + +const PAY_TOKEN = { + address: '0x1111111111111111111111111111111111111111', + chainId: '0x1', + symbol: 'USDC', + balanceUsd: '25', + balanceFiat: '$25.00', + balanceHuman: '25', + balanceRaw: '25000000', + decimals: 6, +} as TransactionPaymentToken; + +const ACCOUNT = { + address: FROM_ADDRESS, + metadata: { keyring: { type: 'HD Key Tree' } }, +}; + +describe('usePayWithToken', () => { + const useSelectorMock = jest.mocked(useSelector); + const useConfirmContextMock = jest.mocked(useConfirmContext); + const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); + const useTransactionPayRequiredTokensMock = jest.mocked( + useTransactionPayRequiredTokens, + ); + const getInternalAccountByAddressMock = jest.mocked( + getInternalAccountByAddress, + ); + const selectPaymentOverrideByTransactionIdMock = jest.mocked( + selectPaymentOverrideByTransactionId, + ); + const { isHardwareAccount } = jest.requireMock( + '../../../multichain-accounts/account-details/account-type-utils', + ) as { isHardwareAccount: jest.Mock }; + + beforeEach(() => { + jest.resetAllMocks(); + + useConfirmContextMock.mockReturnValue({ + currentConfirmation: { + id: 'tx-1', + type: TransactionType.perpsDeposit, + txParams: { from: FROM_ADDRESS }, + } as TransactionMeta, + } as ReturnType); + + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN, + setPayToken: jest.fn(), + isNative: false, + }); + useTransactionPayRequiredTokensMock.mockReturnValue([]); + getInternalAccountByAddressMock.mockReturnValue(ACCOUNT as never); + selectPaymentOverrideByTransactionIdMock.mockReturnValue(undefined); + isHardwareAccount.mockReturnValue(false); + + useSelectorMock.mockImplementation( + (selector: (state: unknown) => unknown) => selector({}), + ); + }); + + it('returns the crypto pay token display values by default', () => { + const { result } = renderHook(() => usePayWithToken()); + + expect(result.current.displayToken).toMatchObject({ + address: PAY_TOKEN.address, + chainId: PAY_TOKEN.chainId, + symbol: 'USDC', + }); + expect(result.current.balanceUsdFormatted).toBe('$25.00'); + expect(result.current.label).toBe('Pay with'); + expect(result.current.isMoneyAccountSelected).toBe(false); + expect(result.current.canEdit).toBe(true); + }); + + it('returns Money account display values when paymentOverride is MoneyAccount', () => { + selectPaymentOverrideByTransactionIdMock.mockReturnValue( + PaymentOverride.MoneyAccount, + ); + + const { result } = renderHook(() => usePayWithToken()); + + expect(result.current.isMoneyAccountSelected).toBe(true); + expect(result.current.displayToken).toMatchObject({ + address: '', + symbol: 'Money account', + balanceUsd: MONEY_ACCOUNT_DUMMY_BALANCE_FIAT, + }); + expect(result.current.balanceUsdFormatted).toBe( + MONEY_ACCOUNT_DUMMY_BALANCE_FIAT, + ); + }); + + it('uses the withdraw label for perps withdraw', () => { + useConfirmContextMock.mockReturnValue({ + currentConfirmation: { + id: 'tx-1', + type: TransactionType.perpsWithdraw, + txParams: { from: FROM_ADDRESS }, + } as TransactionMeta, + } as ReturnType); + + const { result } = renderHook(() => usePayWithToken()); + + expect(result.current.label).toBe('Withdraw to'); + expect(result.current.isPerpsWithdraw).toBe(true); + }); + + it('opens the pay with modal when editable', () => { + const { result } = renderHook(() => usePayWithToken()); + + expect(result.current.modal).toBeNull(); + + act(() => { + result.current.openModal(); + }); + + expect(result.current.modal).not.toBeNull(); + }); + + it('does not open the modal for hardware accounts', () => { + isHardwareAccount.mockReturnValue(true); + + const { result } = renderHook(() => usePayWithToken()); + + expect(result.current.canEdit).toBe(false); + + act(() => { + result.current.openModal(); + }); + + expect(result.current.modal).toBeNull(); + }); + + it('waits for payToken on perps withdraw instead of falling back to required token', () => { + useConfirmContextMock.mockReturnValue({ + currentConfirmation: { + id: 'tx-1', + type: TransactionType.perpsWithdraw, + txParams: { from: FROM_ADDRESS }, + } as TransactionMeta, + } as ReturnType); + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + setPayToken: jest.fn(), + isNative: false, + }); + useTransactionPayRequiredTokensMock.mockReturnValue([PAY_TOKEN as never]); + + const { result } = renderHook(() => usePayWithToken()); + + expect(result.current.displayToken).toBeUndefined(); + }); +}); diff --git a/ui/pages/confirmations/hooks/pay/usePayWithToken.tsx b/ui/pages/confirmations/hooks/pay/usePayWithToken.tsx index 987fdde82d14..eae09dd049f1 100644 --- a/ui/pages/confirmations/hooks/pay/usePayWithToken.tsx +++ b/ui/pages/confirmations/hooks/pay/usePayWithToken.tsx @@ -3,6 +3,7 @@ import { TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; +import { PaymentOverride } from '@metamask/transaction-pay-controller'; import { useSelector } from 'react-redux'; import { BigNumber } from 'bignumber.js'; import { @@ -12,12 +13,17 @@ import { import { useI18nContext } from '../../../../hooks/useI18nContext'; import { useFiatFormatter } from '../../../../hooks/useFiatFormatter'; import { getInternalAccountByAddress } from '../../../../selectors/accounts'; +import { + selectPaymentOverrideByTransactionId, + type TransactionPayState, +} from '../../../../selectors/transactionPayController'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0021): route-isolation backlog import { isHardwareAccount } from '../../../multichain-accounts/account-details/account-type-utils'; import { useConfirmContext } from '../../context/confirm'; import { PayWithModal } from '../../components/modals/pay-with-modal'; import { useTransactionPayToken } from './useTransactionPayToken'; import { useTransactionPayRequiredTokens } from './useTransactionPayData'; +import { MONEY_ACCOUNT_DUMMY_BALANCE_FIAT } from './sections/usePayWithMoneyAccountSection'; export type PayWithDisplayToken = { chainId: string; @@ -34,6 +40,7 @@ type PayWithToken = { from: string | undefined; ownerId: string; isPerpsWithdraw: boolean; + isMoneyAccountSelected: boolean; openModal: () => void; modal: React.ReactNode; }; @@ -55,10 +62,16 @@ export function usePayWithToken(): PayWithToken { const { currentConfirmation } = useConfirmContext(); const from = currentConfirmation?.txParams?.from; + const transactionId = currentConfirmation?.id ?? ''; const fromAccount = useSelector((state) => getInternalAccountByAddress(state, from ?? ''), ); + const paymentOverride = useSelector((state: TransactionPayState) => + selectPaymentOverrideByTransactionId(state, transactionId), + ); + const isMoneyAccountSelected = + paymentOverride === PaymentOverride.MoneyAccount; const canEdit = fromAccount ? !isHardwareAccount(fromAccount) : true; const isPerpsWithdraw = isPerpsWithdrawTransaction(currentConfirmation); @@ -84,20 +97,31 @@ export function usePayWithToken(): PayWithToken { const resolvedToken = payToken ?? (shouldWaitForPayToken ? undefined : firstRequiredToken); - const balanceUsdFormatted = useMemo( - () => - fiatFormatter(new BigNumber(resolvedToken?.balanceUsd ?? '0').toNumber()), - [fiatFormatter, resolvedToken?.balanceUsd], - ); + const balanceUsdFormatted = useMemo(() => { + if (isMoneyAccountSelected) { + return MONEY_ACCOUNT_DUMMY_BALANCE_FIAT; + } + return fiatFormatter( + new BigNumber(resolvedToken?.balanceUsd ?? '0').toNumber(), + ); + }, [fiatFormatter, isMoneyAccountSelected, resolvedToken?.balanceUsd]); - const displayToken = resolvedToken?.chainId - ? { - chainId: resolvedToken.chainId, - address: resolvedToken.address, - symbol: resolvedToken.symbol, - balanceUsd: resolvedToken.balanceUsd, - } - : undefined; + let displayToken: PayWithDisplayToken | undefined; + if (isMoneyAccountSelected) { + displayToken = { + chainId: resolvedToken?.chainId ?? '', + address: '', + symbol: t('payWithMoneyAccount'), + balanceUsd: MONEY_ACCOUNT_DUMMY_BALANCE_FIAT, + }; + } else if (resolvedToken?.chainId) { + displayToken = { + chainId: resolvedToken.chainId, + address: resolvedToken.address, + symbol: resolvedToken.symbol, + balanceUsd: resolvedToken.balanceUsd, + }; + } return { displayToken, @@ -107,6 +131,7 @@ export function usePayWithToken(): PayWithToken { from, ownerId: currentConfirmation?.id ?? '', isPerpsWithdraw, + isMoneyAccountSelected, openModal, modal: isModalOpen ? ( diff --git a/ui/pages/confirmations/selectors/feature-flags.test.ts b/ui/pages/confirmations/selectors/feature-flags.test.ts index ff64bc1d2094..619f7b56882d 100644 --- a/ui/pages/confirmations/selectors/feature-flags.test.ts +++ b/ui/pages/confirmations/selectors/feature-flags.test.ts @@ -2,9 +2,11 @@ import { DEFAULT_ENFORCED_SIMULATIONS_SLIPPAGE } from '../../../../shared/lib/transaction/enforced-simulations'; import { selectBlockedPayTokens, + selectEnableMoneyAccountTransactions, selectEnforcedSimulationsSlippage, selectIsEnforcedSimulationsEnabled, selectIsMetaMaskPayDappsEnabled, + selectIsMoneyAccountTransactionEnabled, selectIsPayAmountPrefillEnabled, selectIsPayHardwareEnabled, selectMinimumRequiredTokenBalance, @@ -70,6 +72,7 @@ type PayExtendedFlag = { overrides?: Record; musdConversion?: PayPrefilledAmountConfig; }; + enableMoneyAccountTransactions?: Record; }; type HardwareWalletFlag = { @@ -519,6 +522,66 @@ describe('Confirmations Pay Feature Flags', () => { }); }); + describe('selectEnableMoneyAccountTransactions', () => { + it('returns the map from the flag', () => { + const state = getMockPayExtendedState({ + enableMoneyAccountTransactions: { + perpsDeposit: true, + predictDeposit: false, + }, + }); + + expect(selectEnableMoneyAccountTransactions(state)).toStrictEqual({ + perpsDeposit: true, + predictDeposit: false, + }); + }); + + it('defaults to an empty map when the flag is absent', () => { + const state = getMockPayExtendedState(); + expect(selectEnableMoneyAccountTransactions(state)).toStrictEqual({}); + }); + }); + + describe('selectIsMoneyAccountTransactionEnabled', () => { + it('returns true when the transaction type is enabled', () => { + const state = getMockPayExtendedState({ + enableMoneyAccountTransactions: { perpsDeposit: true }, + }); + + expect( + selectIsMoneyAccountTransactionEnabled(state, 'perpsDeposit'), + ).toBe(true); + }); + + it('returns false when the transaction type is disabled', () => { + const state = getMockPayExtendedState({ + enableMoneyAccountTransactions: { perpsDeposit: false }, + }); + + expect( + selectIsMoneyAccountTransactionEnabled(state, 'perpsDeposit'), + ).toBe(false); + }); + + it('returns false when the transaction type is absent', () => { + const state = getMockPayExtendedState({ + enableMoneyAccountTransactions: { predictDeposit: true }, + }); + + expect( + selectIsMoneyAccountTransactionEnabled(state, 'perpsDeposit'), + ).toBe(false); + }); + + it('returns false when the flag map is missing', () => { + const state = getMockPayExtendedState(); + expect( + selectIsMoneyAccountTransactionEnabled(state, 'perpsDeposit'), + ).toBe(false); + }); + }); + describe('selectIsPayHardwareEnabled', () => { const getMockPayHardwareState = ( confirmations_pay_hardware?: HardwareWalletFlag, diff --git a/ui/pages/confirmations/selectors/feature-flags.ts b/ui/pages/confirmations/selectors/feature-flags.ts index 2f3bc4da51e5..e36c4ff9dfd1 100644 --- a/ui/pages/confirmations/selectors/feature-flags.ts +++ b/ui/pages/confirmations/selectors/feature-flags.ts @@ -241,6 +241,46 @@ export const selectIsPayHardwareEnabled = createSelector( (flag): boolean => flag?.enabled ?? false, ); +type PayExtendedFlag = { + enableMoneyAccountTransactions?: Record; +}; + +const selectPayExtendedFlag = createSelector( + getRemoteFeatureFlags, + (flags) => + /* eslint-disable @typescript-eslint/naming-convention */ + ( + flags as unknown as { + confirmations_pay_extended?: PayExtendedFlag; + } + ).confirmations_pay_extended, + /* eslint-enable @typescript-eslint/naming-convention */ +); + +/** + * Map of transaction types that may use Money Account as a pay method, from + * `confirmations_pay_extended.enableMoneyAccountTransactions`. + */ +export const selectEnableMoneyAccountTransactions = createSelector( + selectPayExtendedFlag, + (flag): Record => flag?.enableMoneyAccountTransactions ?? {}, +); + +/** + * Whether Money Account pay is enabled for a given transaction type. + * + * @param _state + * @param transactionType + */ +export const selectIsMoneyAccountTransactionEnabled = createSelector( + [ + selectEnableMoneyAccountTransactions, + (_state, transactionType?: string) => transactionType, + ], + (enableMoneyAccountTransactions, transactionType): boolean => + Boolean(transactionType && enableMoneyAccountTransactions[transactionType]), +); + function getPreferredTokensForTransaction( config?: PreferredTokensConfig, transactionType?: string, diff --git a/ui/pages/confirmations/utils/transaction-pay-money-account.test.ts b/ui/pages/confirmations/utils/transaction-pay-money-account.test.ts new file mode 100644 index 000000000000..a4dd1dcdfe93 --- /dev/null +++ b/ui/pages/confirmations/utils/transaction-pay-money-account.test.ts @@ -0,0 +1,58 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { PaymentOverride } from '@metamask/transaction-pay-controller'; +import { setPaymentOverride } from '../../../store/controller-actions/transaction-pay-controller'; +import { + applyMoneyAccountOverride, + clearPaymentOverride, +} from './transaction-pay'; + +jest.mock( + '../../../store/controller-actions/transaction-pay-controller', + () => ({ + setPaymentOverride: jest.fn().mockResolvedValue(undefined), + }), +); + +describe('money account payment override helpers', () => { + const setPaymentOverrideMock = jest.mocked(setPaymentOverride); + const moneyAddress = '0xc4ff9e84b5754570812d891ade0bad3952bb5946' as const; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('applyMoneyAccountOverride', () => { + it('sets MoneyAccount override and refundTo for deposit flows', () => { + applyMoneyAccountOverride('tx-1', moneyAddress, { + id: 'tx-1', + type: TransactionType.perpsDeposit, + } as never); + + expect(setPaymentOverrideMock).toHaveBeenCalledWith('tx-1', { + paymentOverride: PaymentOverride.MoneyAccount, + refundTo: moneyAddress, + }); + }); + + it('sets MoneyAccount override without refundTo for withdraw flows', () => { + applyMoneyAccountOverride('tx-2', moneyAddress, { + id: 'tx-2', + type: TransactionType.perpsWithdraw, + } as never); + + expect(setPaymentOverrideMock).toHaveBeenCalledWith('tx-2', { + paymentOverride: PaymentOverride.MoneyAccount, + }); + }); + }); + + describe('clearPaymentOverride', () => { + it('clears the payment override', () => { + clearPaymentOverride('tx-3'); + + expect(setPaymentOverrideMock).toHaveBeenCalledWith('tx-3', { + paymentOverride: undefined, + }); + }); + }); +}); diff --git a/ui/pages/confirmations/utils/transaction-pay.ts b/ui/pages/confirmations/utils/transaction-pay.ts index 55076fe54c48..f8837f1eec9d 100644 --- a/ui/pages/confirmations/utils/transaction-pay.ts +++ b/ui/pages/confirmations/utils/transaction-pay.ts @@ -1,11 +1,14 @@ import { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import type { - TransactionPayRequiredToken, - TransactionPaymentToken, +import { + PaymentOverride, + type TransactionPayRequiredToken, + type TransactionPaymentToken, } from '@metamask/transaction-pay-controller'; import { BigNumber } from 'bignumber.js'; import { isTestNetwork } from '../../../helpers/utils/network-helper'; +import { isPostQuoteWithdrawTransaction } from '../../../../shared/lib/transactions.utils'; +import { setPaymentOverride } from '../../../store/controller-actions/transaction-pay-controller'; import type { BlockedPayTokensListConfig } from '../selectors/feature-flags'; import { Asset, AssetStandard } from '../types/send'; @@ -163,3 +166,42 @@ export function isTokenBlocked( blocked.chainId.toLowerCase() === chainId.toLowerCase(), ); } + +/** + * Selects Money Account as the payment method for a confirmation. + * Sets `paymentOverride` and, for deposit flows, refunds leftover funds to the + * money account address. + * + * @param transactionId - Confirmation transaction id. + * @param moneyAccountAddress - Derived money account address, when known. + * @param transactionMeta - Current confirmation metadata. + */ +export function applyMoneyAccountOverride( + transactionId: string, + moneyAccountAddress: string | undefined, + transactionMeta: TransactionMeta | undefined, +): void { + const isWithdraw = isPostQuoteWithdrawTransaction(transactionMeta); + + setPaymentOverride(transactionId, { + paymentOverride: PaymentOverride.MoneyAccount, + ...(!isWithdraw && moneyAccountAddress + ? { refundTo: moneyAccountAddress as Hex } + : {}), + }).catch((error) => { + console.error('Failed to apply money account payment override', error); + }); +} + +/** + * Clears a Money Account (or other) payment override on the confirmation. + * + * @param transactionId - Confirmation transaction id. + */ +export function clearPaymentOverride(transactionId: string): void { + setPaymentOverride(transactionId, { + paymentOverride: undefined, + }).catch((error) => { + console.error('Failed to clear payment override', error); + }); +} diff --git a/ui/selectors/transactionPayController.test.ts b/ui/selectors/transactionPayController.test.ts index 9a4fd752e556..0ccd2e246174 100644 --- a/ui/selectors/transactionPayController.test.ts +++ b/ui/selectors/transactionPayController.test.ts @@ -1,4 +1,5 @@ import type { TransactionPayControllerState } from '@metamask/transaction-pay-controller'; +import { PaymentOverride } from '@metamask/transaction-pay-controller'; import { selectTransactionDataByTransactionId, selectTransactionPayTotalsByTransactionId, @@ -9,6 +10,7 @@ import { selectTransactionPaySourceAmountsByTransactionId, selectTransactionPayIsMaxAmountByTransactionId, selectTransactionPayAccountOverrideByTransactionId, + selectPaymentOverrideByTransactionId, TransactionPayState, } from './transactionPayController'; @@ -296,4 +298,30 @@ describe('transactionPayController selectors', () => { expect(result).toBeUndefined(); }); }); + + describe('selectPaymentOverrideByTransactionId', () => { + it('returns paymentOverride when present', () => { + const state = createMockState({ + paymentOverride: PaymentOverride.MoneyAccount, + }); + + const result = selectPaymentOverrideByTransactionId( + state, + TRANSACTION_ID, + ); + + expect(result).toBe(PaymentOverride.MoneyAccount); + }); + + it('returns undefined when paymentOverride is absent', () => { + const state = createMockState({ isLoading: false }); + + const result = selectPaymentOverrideByTransactionId( + state, + TRANSACTION_ID, + ); + + expect(result).toBeUndefined(); + }); + }); }); diff --git a/ui/selectors/transactionPayController.ts b/ui/selectors/transactionPayController.ts index 0e664c0cb9bf..376d20c78c9e 100644 --- a/ui/selectors/transactionPayController.ts +++ b/ui/selectors/transactionPayController.ts @@ -59,3 +59,11 @@ export const selectTransactionPayAccountOverrideByTransactionId = selectTransactionDataByTransactionId, (transactionData) => transactionData?.accountOverride, ); + +/** + * Alternate payment strategy override (e.g. Money Account) for a transaction. + */ +export const selectPaymentOverrideByTransactionId = createSelector( + selectTransactionDataByTransactionId, + (transactionData) => transactionData?.paymentOverride, +); diff --git a/ui/store/controller-actions/transaction-pay-controller.test.ts b/ui/store/controller-actions/transaction-pay-controller.test.ts index 344f38c4bd6e..40a9eea67a8d 100644 --- a/ui/store/controller-actions/transaction-pay-controller.test.ts +++ b/ui/store/controller-actions/transaction-pay-controller.test.ts @@ -1,9 +1,11 @@ +import { PaymentOverride } from '@metamask/transaction-pay-controller'; import * as BackgroundConnectionModule from '../background-connection'; import { updateTransactionPaymentToken, setIsMaxAmount, setPostQuote, setAccountOverride, + setPaymentOverride, } from './transaction-pay-controller'; jest.mock('../background-connection'); @@ -119,4 +121,37 @@ describe('transaction-pay-controller actions', () => { ); }); }); + + describe('setPaymentOverride', () => { + it('calls submitRequestToBackground with setTransactionPayPaymentOverride', async () => { + const transactionId = 'tx-pay-override'; + const refundTo = '0xabcdef1234567890abcdef1234567890abcdef12' as const; + + await setPaymentOverride(transactionId, { + paymentOverride: PaymentOverride.MoneyAccount, + refundTo, + }); + + expect(mockSubmitRequestToBackground).toHaveBeenCalledTimes(1); + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'setTransactionPayPaymentOverride', + [ + transactionId, + { + paymentOverride: PaymentOverride.MoneyAccount, + refundTo, + }, + ], + ); + }); + + it('defaults options to an empty object when omitted', async () => { + await setPaymentOverride('tx-clear'); + + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'setTransactionPayPaymentOverride', + ['tx-clear', { paymentOverride: undefined, refundTo: undefined }], + ); + }); + }); }); diff --git a/ui/store/controller-actions/transaction-pay-controller.ts b/ui/store/controller-actions/transaction-pay-controller.ts index b295a7a00784..334b2331994f 100644 --- a/ui/store/controller-actions/transaction-pay-controller.ts +++ b/ui/store/controller-actions/transaction-pay-controller.ts @@ -1,3 +1,4 @@ +import type { PaymentOverride } from '@metamask/transaction-pay-controller'; import type { Hex } from '@metamask/utils'; import { submitRequestToBackground } from '../background-connection'; @@ -19,6 +20,22 @@ export async function updateTransactionPaymentToken({ ]); } +export async function setPaymentOverride( + transactionId: string, + { + paymentOverride, + refundTo, + }: { + paymentOverride?: PaymentOverride; + refundTo?: Hex; + } = {}, +): Promise { + return await submitRequestToBackground('setTransactionPayPaymentOverride', [ + transactionId, + { paymentOverride, refundTo }, + ]); +} + export async function setIsMaxAmount( transactionId: string, isMaxAmount: boolean,