diff --git a/app/components/UI/Money/Views/MoneyActivityView/MoneyActivityView.test.tsx b/app/components/UI/Money/Views/MoneyActivityView/MoneyActivityView.test.tsx index 3c30e0f4e74..968eac4a5fd 100644 --- a/app/components/UI/Money/Views/MoneyActivityView/MoneyActivityView.test.tsx +++ b/app/components/UI/Money/Views/MoneyActivityView/MoneyActivityView.test.tsx @@ -11,6 +11,7 @@ import MoneyActivityView from './MoneyActivityView'; import { MoneyActivityViewTestIds } from './MoneyActivityView.testIds'; const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => { const actualReactNavigation = jest.requireActual('@react-navigation/native'); @@ -18,7 +19,7 @@ jest.mock('@react-navigation/native', () => { ...actualReactNavigation, useNavigation: () => ({ goBack: mockGoBack, - navigate: jest.fn(), + navigate: mockNavigate, }), }; }); @@ -32,13 +33,26 @@ jest.mock('../../hooks/useMoneyAccountTransactions', () => ({ })); jest.mock('../../components/MoneyActivityItem/MoneyActivityItem', () => { - const { View, Text } = jest.requireActual('react-native'); + const { + View, + Text, + Pressable: RNPressable, + } = jest.requireActual('react-native'); return { __esModule: true, - default: ({ tx }: { tx: { id: string } }) => ( - + default: ({ + tx, + onPress, + }: { + tx: { id: string }; + onPress?: (id: string) => void; + }) => ( + onPress?.(tx.id)} + > {tx.id} - + ), }; }); @@ -74,6 +88,7 @@ describe('MoneyActivityView', () => { transfers: MOCK_TRANSFERS, submittedTransactions: [], moneyAddress: '0x0000000000000000000000000000000000000001', + mockDataEnabled: false, }); }); @@ -144,6 +159,7 @@ describe('MoneyActivityView', () => { transfers: [], submittedTransactions: [], moneyAddress: '0x0000000000000000000000000000000000000001', + mockDataEnabled: false, }); const { getByTestId } = renderWithProvider(); @@ -153,4 +169,12 @@ describe('MoneyActivityView', () => { getByTestId(MoneyActivityViewTestIds.EMPTY_LIST_MESSAGE), ).toBeOnTheScreen(); }); + + it('pressing a row navigates to the transaction details sheet when mockDataEnabled is false', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId('activity-mock-tx-money-tx-1')); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/components/UI/Money/Views/MoneyActivityView/MoneyActivityView.tsx b/app/components/UI/Money/Views/MoneyActivityView/MoneyActivityView.tsx index 3b3b7f05d36..0a6406d6aed 100644 --- a/app/components/UI/Money/Views/MoneyActivityView/MoneyActivityView.tsx +++ b/app/components/UI/Money/Views/MoneyActivityView/MoneyActivityView.tsx @@ -25,7 +25,7 @@ import MoneyActivityItem from '../../components/MoneyActivityItem'; import { useMoneyAccountTransactions } from '../../hooks/useMoneyAccountTransactions'; import { getMoneyActivityDateKeyUtc } from '../../constants/moneyActivityFilters'; import { MoneyActivityFilter } from '../../constants/mockActivityData'; -import { showMoneyActivityUnderConstructionAlert } from '../../constants/showMoneyActivityUnderConstructionAlert'; +import Routes from '../../../../../constants/navigation/Routes'; import { MoneyActivityViewTestIds } from './MoneyActivityView.testIds'; const styles = StyleSheet.create({ @@ -67,16 +67,27 @@ const MoneyActivityView = () => { const { colors } = useTheme(); const [filter, setFilter] = useState(MoneyActivityFilter.All); - const { allTransactions, deposits, transfers, moneyAddress } = - useMoneyAccountTransactions(); + const { + allTransactions, + deposits, + transfers, + moneyAddress, + mockDataEnabled, + } = useMoneyAccountTransactions(); const handleBackPress = useCallback(() => { navigation.goBack(); }, [navigation]); - const handleItemPress = useCallback(() => { - showMoneyActivityUnderConstructionAlert(); - }, []); + const handleItemPress = useCallback( + (transactionId: string) => { + navigation.navigate(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.TRANSACTION_DETAILS_SHEET, + params: { transactionId }, + }); + }, + [navigation], + ); const filtered = useMemo(() => { if (filter === MoneyActivityFilter.All) { @@ -110,7 +121,7 @@ const MoneyActivityView = () => { ); diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index 631e6e2fe78..daab2438635 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -239,6 +239,7 @@ describe('MoneyHomeView', () => { transfers: [], submittedTransactions: [], moneyAddress: '0x0000000000000000000000000000000000000001', + mockDataEnabled: false, }); }); @@ -435,6 +436,7 @@ describe('MoneyHomeView', () => { transfers: [], submittedTransactions: [], moneyAddress: '0x0000000000000000000000000000000000000001', + mockDataEnabled: false, }); const { getByTestId } = renderWithProvider(); @@ -533,6 +535,7 @@ describe('MoneyHomeView', () => { transfers: [], submittedTransactions: [], moneyAddress: '0x0000000000000000000000000000000000000001', + mockDataEnabled: false, }); }); @@ -594,6 +597,7 @@ describe('MoneyHomeView', () => { transfers: [], submittedTransactions: [], moneyAddress: '0x0000000000000000000000000000000000000001', + mockDataEnabled: false, }); mockSelectIsCardholder.mockReturnValue(true); // Non-US so MetaMask card renders in link mode (manage mode is US-only). @@ -774,6 +778,7 @@ describe('MoneyHomeView', () => { transfers: [], submittedTransactions: [], moneyAddress: '0x0000000000000000000000000000000000000001', + mockDataEnabled: false, }); }); @@ -910,14 +915,6 @@ describe('MoneyHomeView', () => { }), ); }); - - it('triggers the under-construction alert when an activity item is pressed', () => { - const { getByTestId } = renderWithProvider(); - - fireEvent.press(getByTestId('money-activity-item-padded-0')); - - expect(global.alert).toHaveBeenCalled(); - }); }); describe('card upsell mode β€” Get Now handler', () => { @@ -996,6 +993,7 @@ describe('MoneyHomeView', () => { transfers: [], submittedTransactions: [], moneyAddress: '0x0000000000000000000000000000000000000001', + mockDataEnabled: false, }); }); diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index 1ed33fc7e64..409f725ea9d 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -26,7 +26,6 @@ import styleSheet from './MoneyHomeView.styles'; import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; import { useMoneyAccountTransactions } from '../../hooks/useMoneyAccountTransactions'; -import { showMoneyActivityUnderConstructionAlert } from '../../constants/showMoneyActivityUnderConstructionAlert'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; import { moneyFormatFiat } from '../../utils/moneyFormatFiat'; @@ -68,7 +67,8 @@ const MoneyHomeView = () => { const { tokens: conversionTokens } = useMusdConversionTokens(); const { initiateCustomConversion } = useMusdConversion(); - const { allTransactions, moneyAddress } = useMoneyAccountTransactions(); + const { allTransactions, moneyAddress, mockDataEnabled } = + useMoneyAccountTransactions(); const isCardholder = useSelector(selectIsCardholder); const { startLinkFlow } = useMoneyAccountCardLinkage(); @@ -203,9 +203,15 @@ const MoneyHomeView = () => { }, [navigation]); const handleActivityHeaderPress = handleViewAllActivityPress; - const handleActivityItemPress = useCallback(() => { - showMoneyActivityUnderConstructionAlert(); - }, []); + const handleActivityItemPress = useCallback( + (transactionId: string) => { + navigation.navigate(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.TRANSACTION_DETAILS_SHEET, + params: { transactionId }, + }); + }, + [navigation], + ); const handleOnboardingCtaPress = useCallback(() => { if (isCardholderWithMilestone) { @@ -302,7 +308,9 @@ const MoneyHomeView = () => { : handleViewAllActivityPress } onHeaderPress={handleActivityHeaderPress} - onItemPress={handleActivityItemPress} + onItemPress={ + mockDataEnabled ? undefined : handleActivityItemPress + } /> diff --git a/app/components/UI/Money/components/MoneyActivityItem/MoneyActivityItem.test.tsx b/app/components/UI/Money/components/MoneyActivityItem/MoneyActivityItem.test.tsx index 1d1d76b6222..5e6bb8ee95a 100644 --- a/app/components/UI/Money/components/MoneyActivityItem/MoneyActivityItem.test.tsx +++ b/app/components/UI/Money/components/MoneyActivityItem/MoneyActivityItem.test.tsx @@ -2,13 +2,13 @@ import React from 'react'; import { fireEvent } from '@testing-library/react-native'; import { type TransactionMeta, + TransactionStatus, TransactionType, } from '@metamask/transaction-controller'; import { IconName } from '@metamask/design-system-react-native'; import type { Hex } from '@metamask/utils'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { useMoneyTransactionDisplayInfo } from '../../hooks/useMoneyTransactionDisplayInfo'; -import { MUSD_TOKEN_ADDRESS } from '../../../Earn/constants/musd'; import MoneyActivityItem from './MoneyActivityItem'; import { MoneyActivityItemTestIds } from './MoneyActivityItem.testIds'; @@ -18,12 +18,6 @@ const baseTx = { id: 'tx-row-1', chainId: MOCK_CHAIN, type: TransactionType.incoming, - transferInformation: { - amount: '1000000000', - symbol: 'mUSD', - decimals: 6, - contractAddress: MUSD_TOKEN_ADDRESS, - }, } as unknown as TransactionMeta; jest.mock('../../hooks/useMoneyTransactionDisplayInfo'); @@ -109,6 +103,14 @@ describe('MoneyActivityItem', () => { ).toBeOnTheScreen(); }); + it('renders the avatar icon', () => { + const { getByTestId } = renderWithProvider( + , + ); + + expect(getByTestId(MoneyActivityItemTestIds.ICON)).toBeOnTheScreen(); + }); + it('omits description when hook returns undefined description', () => { mockUseMoneyTransactionDisplayInfo.mockReturnValue({ label: 'Label', @@ -126,7 +128,7 @@ describe('MoneyActivityItem', () => { expect(queryByText('Description')).toBeNull(); }); - it('invokes onPress when the row is pressed', () => { + it('invokes onPress with transaction id when the row is pressed', () => { const onPress = jest.fn(); const { getByTestId } = renderWithProvider( , @@ -135,6 +137,22 @@ describe('MoneyActivityItem', () => { fireEvent.press(getByTestId(`${MoneyActivityItemTestIds.ROW}-tx-row-1`)); expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith('tx-row-1'); + }); + + it('shows "Failed" in the description slot for a failed transaction', () => { + const failedTx = { + ...baseTx, + status: TransactionStatus.failed, + } as unknown as TransactionMeta; + + const { getByText, queryByText } = renderWithProvider( + , + ); + + expect(getByText('Failed')).toBeOnTheScreen(); + // Normal description should not appear for failed rows + expect(queryByText('Description')).toBeNull(); }); it('renders network badge subtree when showNetworkBadge is true', () => { @@ -144,6 +162,7 @@ describe('MoneyActivityItem', () => { expect(getByTestId('mock-badge-wrapper')).toBeOnTheScreen(); expect(getByTestId('mock-network-badge')).toBeOnTheScreen(); + expect(getByTestId(MoneyActivityItemTestIds.ICON)).toBeOnTheScreen(); }); it('renders the AvatarIcon and no longer renders the token avatar', () => { @@ -155,14 +174,6 @@ describe('MoneyActivityItem', () => { expect(queryByTestId('mock-avatar-token')).toBeNull(); }); - it('renders the AvatarIcon inside the network badge subtree', () => { - const { getByTestId } = renderWithProvider( - , - ); - - expect(getByTestId(MoneyActivityItemTestIds.ICON)).toBeOnTheScreen(); - }); - it('forwards the icon name from useMoneyTransactionDisplayInfo', () => { mockUseMoneyTransactionDisplayInfo.mockReturnValue({ label: 'Label', diff --git a/app/components/UI/Money/components/MoneyActivityItem/MoneyActivityItem.tsx b/app/components/UI/Money/components/MoneyActivityItem/MoneyActivityItem.tsx index 44972dca0b6..6ae64fc5f04 100644 --- a/app/components/UI/Money/components/MoneyActivityItem/MoneyActivityItem.tsx +++ b/app/components/UI/Money/components/MoneyActivityItem/MoneyActivityItem.tsx @@ -12,7 +12,11 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + type TransactionMeta, + TransactionStatus, +} from '@metamask/transaction-controller'; +import { strings } from '../../../../../../locales/i18n'; import { getNetworkImageSource } from '../../../../../util/networks'; import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; import BadgeWrapper from '../../../../../component-library/components/Badges/BadgeWrapper'; @@ -29,8 +33,8 @@ import { MoneyActivityItemTestIds } from './MoneyActivityItem.testIds'; export interface MoneyActivityItemProps { tx: TransactionMeta; moneyAddress: string | undefined; - onPress?: () => void; - /** When true, shows the chain network badge on the token avatar. Defaults to false. */ + onPress?: (transactionId: string) => void; + /** When true, shows the chain network badge on the icon avatar. Defaults to false. */ showNetworkBadge?: boolean; } @@ -52,14 +56,18 @@ const MoneyActivityItem = ({ [tx.chainId, showNetworkBadge], ); - const amountColor = display.isIncoming - ? TextColor.SuccessDefault - : TextColor.TextDefault; + const isFailed = tx.status === TransactionStatus.failed; + + const amountColor = isFailed + ? TextColor.TextAlternative + : display.isIncoming + ? TextColor.SuccessDefault + : TextColor.TextDefault; return ( onPress(tx.id) : undefined} testID={`${MoneyActivityItemTestIds.ROW}-${tx.id}`} style={({ pressed }) => tw.style( @@ -107,7 +115,16 @@ const MoneyActivityItem = ({ > {display.label} - {display.description ? ( + {isFailed ? ( + + {strings('money.transaction.failed')} + + ) : display.description ? ( void; onHeaderPress?: () => void; - onItemPress?: () => void; + onItemPress?: (transactionId: string) => void; } const MoneyActivityList = ({ diff --git a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx new file mode 100644 index 00000000000..2b44b2dc26f --- /dev/null +++ b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useRef } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { + BottomSheet, + type BottomSheetRef, + BottomSheetHeader, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { + type TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { strings } from '../../../../../../locales/i18n'; +import { TransactionDetails } from '../../../../Views/confirmations/components/activity/transaction-details/transaction-details'; +import { useTransactionDetails } from '../../../../Views/confirmations/hooks/activity/useTransactionDetails'; + +const TITLE_KEYS: Partial> = { + [TransactionType.moneyAccountDeposit]: + 'transaction_details.title.money_account_deposit', + [TransactionType.moneyAccountWithdraw]: + 'transaction_details.title.money_account_withdraw', + [TransactionType.musdConversion]: 'transaction_details.title.musd_conversion', + [TransactionType.musdClaim]: 'transaction_details.title.musd_claim', + [TransactionType.perpsDeposit]: 'transaction_details.title.perps_deposit', + [TransactionType.perpsWithdraw]: 'transaction_details.title.perps_withdraw', + [TransactionType.predictClaim]: 'transaction_details.title.predict_claim', + [TransactionType.predictDeposit]: 'transaction_details.title.predict_deposit', + [TransactionType.predictWithdraw]: + 'transaction_details.title.predict_withdraw', +}; + +function getTitle(tx: TransactionMeta | undefined): string { + const type = + tx?.type === TransactionType.batch + ? ((tx.nestedTransactions?.find((n) => n.type && n.type in TITLE_KEYS) + ?.type as TransactionType | undefined) ?? tx.type) + : tx?.type; + return strings( + (type && TITLE_KEYS[type]) ?? 'transaction_details.title.default', + ); +} + +const MoneyTransactionDetailsSheet = () => { + const sheetRef = useRef(null); + const navigation = useNavigation(); + const { transactionMeta } = useTransactionDetails(); + const title = getTitle(transactionMeta); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + return ( + + + {title} + + + + ); +}; + +export default MoneyTransactionDetailsSheet; diff --git a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/index.ts b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/index.ts new file mode 100644 index 00000000000..c8526e271ef --- /dev/null +++ b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/index.ts @@ -0,0 +1 @@ +export { default } from './MoneyTransactionDetailsSheet'; diff --git a/app/components/UI/Money/constants/activityStyles.test.ts b/app/components/UI/Money/constants/activityStyles.test.ts index 65798881f78..bd4423bd2b0 100644 --- a/app/components/UI/Money/constants/activityStyles.test.ts +++ b/app/components/UI/Money/constants/activityStyles.test.ts @@ -50,6 +50,18 @@ describe('activityStyles', () => { ).toBe('-'); }); + it('returns minus for a batch tx with an outgoing nested type', () => { + expect( + getMoneyAmountPrefixForTransactionMeta( + makeTx(TransactionType.batch, { + nestedTransactions: [ + { type: TransactionType.moneyAccountWithdraw } as TransactionMeta, + ], + }), + ), + ).toBe('-'); + }); + it('returns plus for other classified types', () => { expect( getMoneyAmountPrefixForTransactionMeta( @@ -86,6 +98,29 @@ describe('activityStyles', () => { expect(line.startsWith('-')).toBe(true); expect(line).toContain('mUSD'); }); + + it('returns empty string when transferInformation has no symbol', () => { + expect( + getMusdDisplayAmountFromTransactionMeta( + makeTx(TransactionType.incoming, { + transferInformation: { + amount: '1000000', + symbol: '', + decimals: 6, + contractAddress: MUSD_TOKEN_ADDRESS, + } as unknown as NonNullable, + }), + ), + ).toBe(''); + }); + + it('returns a formatted positive amount for incoming deposits', () => { + const line = getMusdDisplayAmountFromTransactionMeta( + makeTx(TransactionType.incoming), + ); + expect(line.startsWith('+')).toBe(true); + expect(line).toContain('mUSD'); + }); }); describe('isIncomingMoneyTransactionMeta', () => { @@ -97,7 +132,7 @@ describe('activityStyles', () => { ).toBe(false); }); - it('matches incoming deposit and conversion types', () => { + it('matches incoming and moneyAccountDeposit types', () => { expect( isIncomingMoneyTransactionMeta(makeTx(TransactionType.incoming)), ).toBe(true); @@ -106,11 +141,38 @@ describe('activityStyles', () => { makeTx(TransactionType.moneyAccountDeposit), ), ).toBe(true); + }); + + it('returns false for musdConversion (no longer classified as incoming)', () => { expect( isIncomingMoneyTransactionMeta(makeTx(TransactionType.musdConversion)), + ).toBe(false); + }); + + it('returns true for a batch tx with a nested moneyAccountDeposit', () => { + expect( + isIncomingMoneyTransactionMeta( + makeTx(TransactionType.batch, { + nestedTransactions: [ + { type: TransactionType.moneyAccountDeposit } as TransactionMeta, + ], + }), + ), ).toBe(true); }); + it('returns false for a batch tx with no deposit nested type', () => { + expect( + isIncomingMoneyTransactionMeta( + makeTx(TransactionType.batch, { + nestedTransactions: [ + { type: TransactionType.simpleSend } as TransactionMeta, + ], + }), + ), + ).toBe(false); + }); + it('returns false for withdraw', () => { expect( isIncomingMoneyTransactionMeta( diff --git a/app/components/UI/Money/constants/activityStyles.ts b/app/components/UI/Money/constants/activityStyles.ts index c1d8637076f..737933d089c 100644 --- a/app/components/UI/Money/constants/activityStyles.ts +++ b/app/components/UI/Money/constants/activityStyles.ts @@ -4,7 +4,7 @@ import { } from '@metamask/transaction-controller'; import I18n from '../../../../../locales/i18n'; import { getIntlNumberFormatter } from '../../../../util/intl'; -import { fromTokenMinimalUnit } from '../../../../util/number'; +import { fromTokenMinimalUnit } from '../../../../util/number/bigint'; function formatNumber(num: number): string { return getIntlNumberFormatter(I18n.locale, { @@ -19,13 +19,24 @@ const OUTGOING_EVM_TYPES: EvmTransactionType[] = [ EvmTransactionType.simpleSend, ]; +function hasOutgoingNestedType(tx: TransactionMeta): boolean { + return ( + tx.nestedTransactions?.some( + (nested) => nested.type && OUTGOING_EVM_TYPES.includes(nested.type), + ) ?? false + ); +} + /** * +/- prefix for Money rows backed by {@link TransactionMeta} (mUSD pegged 1:1 to USD). */ export function getMoneyAmountPrefixForTransactionMeta( tx: TransactionMeta, ): string { - if (tx.type && OUTGOING_EVM_TYPES.includes(tx.type)) { + if ( + (tx.type && OUTGOING_EVM_TYPES.includes(tx.type)) || + hasOutgoingNestedType(tx) + ) { return '-'; } return '+'; @@ -49,10 +60,16 @@ export function getMusdDisplayAmountFromTransactionMeta( export function isIncomingMoneyTransactionMeta(tx: TransactionMeta): boolean { const t = tx.type; - if (!t) return false; - return ( + if ( t === EvmTransactionType.incoming || - t === EvmTransactionType.moneyAccountDeposit || - t === EvmTransactionType.musdConversion + t === EvmTransactionType.moneyAccountDeposit + ) { + return true; + } + // EIP-7702 batch deposits: moneyAccountDeposit sits in nestedTransactions + return ( + tx.nestedTransactions?.some( + (nested) => nested.type === EvmTransactionType.moneyAccountDeposit, + ) ?? false ); } diff --git a/app/components/UI/Money/constants/moneyActivityFilters.test.ts b/app/components/UI/Money/constants/moneyActivityFilters.test.ts index 1026ff4f435..851802eab03 100644 --- a/app/components/UI/Money/constants/moneyActivityFilters.test.ts +++ b/app/components/UI/Money/constants/moneyActivityFilters.test.ts @@ -23,7 +23,7 @@ function tx(overrides: Partial): TransactionMeta { describe('moneyActivityFilters', () => { describe('isMoneyActivityDeposit', () => { - it('returns true for incoming, moneyAccountDeposit, and musdConversion', () => { + it('returns true for incoming and moneyAccountDeposit', () => { expect( isMoneyActivityDeposit(tx({ type: TransactionType.incoming })), ).toBe(true); @@ -32,8 +32,24 @@ describe('moneyActivityFilters', () => { tx({ type: TransactionType.moneyAccountDeposit }), ), ).toBe(true); + }); + + it('returns false for musdConversion (no longer classified as a deposit)', () => { expect( isMoneyActivityDeposit(tx({ type: TransactionType.musdConversion })), + ).toBe(false); + }); + + it('returns true for a batch tx with a nested moneyAccountDeposit', () => { + expect( + isMoneyActivityDeposit( + tx({ + type: TransactionType.batch, + nestedTransactions: [ + { type: TransactionType.moneyAccountDeposit } as TransactionMeta, + ], + }), + ), ).toBe(true); }); @@ -64,6 +80,19 @@ describe('moneyActivityFilters', () => { ).toBe(true); }); + it('returns true for a batch tx with a nested moneyAccountWithdraw', () => { + expect( + isMoneyActivityTransfer( + tx({ + type: TransactionType.batch, + nestedTransactions: [ + { type: TransactionType.moneyAccountWithdraw } as TransactionMeta, + ], + }), + ), + ).toBe(true); + }); + it('returns false for deposit-like types', () => { expect( isMoneyActivityTransfer( diff --git a/app/components/UI/Money/constants/moneyActivityFilters.ts b/app/components/UI/Money/constants/moneyActivityFilters.ts index bbaeb07e0db..30625a9f67f 100644 --- a/app/components/UI/Money/constants/moneyActivityFilters.ts +++ b/app/components/UI/Money/constants/moneyActivityFilters.ts @@ -5,18 +5,33 @@ import { export function isMoneyActivityDeposit(tx: TransactionMeta): boolean { const t = tx.type; - return ( + if ( t === TransactionType.incoming || - t === TransactionType.moneyAccountDeposit || - t === TransactionType.musdConversion + t === TransactionType.moneyAccountDeposit + ) { + return true; + } + // EIP-7702 batch deposits: moneyAccountDeposit sits in nestedTransactions + return ( + tx.nestedTransactions?.some( + (nested) => nested.type === TransactionType.moneyAccountDeposit, + ) ?? false ); } export function isMoneyActivityTransfer(tx: TransactionMeta): boolean { const t = tx.type; - return ( + if ( t === TransactionType.moneyAccountWithdraw || t === TransactionType.simpleSend + ) { + return true; + } + // EIP-7702 batch withdrawals: moneyAccountWithdraw sits in nestedTransactions + return ( + tx.nestedTransactions?.some( + (nested) => nested.type === TransactionType.moneyAccountWithdraw, + ) ?? false ); } diff --git a/app/components/UI/Money/constants/showMoneyActivityUnderConstructionAlert.test.ts b/app/components/UI/Money/constants/showMoneyActivityUnderConstructionAlert.test.ts deleted file mode 100644 index 8a7e46928cb..00000000000 --- a/app/components/UI/Money/constants/showMoneyActivityUnderConstructionAlert.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { showMoneyActivityUnderConstructionAlert } from './showMoneyActivityUnderConstructionAlert'; - -describe('showMoneyActivityUnderConstructionAlert', () => { - const originalAlert = global.alert; - - beforeEach(() => { - global.alert = jest.fn(); - }); - - afterEach(() => { - global.alert = originalAlert; - }); - - it('shows the under-construction alert', () => { - showMoneyActivityUnderConstructionAlert(); - - expect(global.alert).toHaveBeenCalledTimes(1); - expect(global.alert).toHaveBeenCalledWith( - expect.stringContaining('Under construction'), - ); - }); -}); diff --git a/app/components/UI/Money/constants/showMoneyActivityUnderConstructionAlert.ts b/app/components/UI/Money/constants/showMoneyActivityUnderConstructionAlert.ts deleted file mode 100644 index 32072621d59..00000000000 --- a/app/components/UI/Money/constants/showMoneyActivityUnderConstructionAlert.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Placeholder until Money activity rows navigate to transaction details - * (requires a real transaction id in the TransactionController store). - */ -export function showMoneyActivityUnderConstructionAlert(): void { - // eslint-disable-next-line no-alert - alert('Under construction 🚧 - needs a real transactionId'); -} diff --git a/app/components/UI/Money/hooks/useMoneyAccountTransactions.test.tsx b/app/components/UI/Money/hooks/useMoneyAccountTransactions.test.tsx index 449515d3663..d320e7cfc74 100644 --- a/app/components/UI/Money/hooks/useMoneyAccountTransactions.test.tsx +++ b/app/components/UI/Money/hooks/useMoneyAccountTransactions.test.tsx @@ -1,5 +1,10 @@ import type { MoneyAccount } from '@metamask/money-account-controller'; import { MONEY_DERIVATION_PATH } from '@metamask/eth-money-keyring'; +import { + TransactionStatus, + TransactionType, + type TransactionMeta, +} from '@metamask/transaction-controller'; import { renderHookWithProvider, type ProviderValues, @@ -41,6 +46,7 @@ const MOCK_TRANSFERS = MOCK_MONEY_TRANSACTIONS.filter(isMoneyActivityTransfer); function engineState( remoteFeatureFlags: Record, + transactions: Partial[] = [], ): ProviderValues['state'] { return { engine: { @@ -52,11 +58,28 @@ function engineState( moneyAccounts: MOCK_MONEY_ACCOUNTS, }, KeyringController: MOCK_KEYRING_CONTROLLER, + TransactionController: { + transactions, + }, }, }, } as ProviderValues['state']; } +function makeTx( + type: TransactionType, + overrides: Partial = {}, +): Partial { + return { + id: `tx-${type}`, + chainId: '0x1', + type, + status: TransactionStatus.confirmed, + time: Date.now(), + ...overrides, + }; +} + describe('useMoneyAccountTransactions', () => { const originalEnv = process.env; @@ -123,4 +146,90 @@ describe('useMoneyAccountTransactions', () => { expect(result.current.moneyAddress).toBeDefined(); expect(result.current.moneyAddress).toMatch(/^0x[a-fA-F0-9]{40}$/); }); + + describe('real transaction filtering (mock flag off)', () => { + it('includes direct moneyAccountDeposit transactions', () => { + const tx = makeTx(TransactionType.moneyAccountDeposit); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(1); + expect(result.current.deposits).toHaveLength(1); + expect(result.current.transfers).toHaveLength(0); + }); + + it('includes direct moneyAccountWithdraw transactions', () => { + const tx = makeTx(TransactionType.moneyAccountWithdraw); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(1); + expect(result.current.transfers).toHaveLength(1); + expect(result.current.deposits).toHaveLength(0); + }); + + it('includes EIP-7702 batch with nested moneyAccountDeposit', () => { + const tx = makeTx(TransactionType.batch, { + nestedTransactions: [ + { type: TransactionType.moneyAccountDeposit } as TransactionMeta, + ], + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(1); + expect(result.current.deposits).toHaveLength(1); + }); + + it('includes EIP-7702 batch with nested moneyAccountWithdraw', () => { + const tx = makeTx(TransactionType.batch, { + nestedTransactions: [ + { type: TransactionType.moneyAccountWithdraw } as TransactionMeta, + ], + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(1); + expect(result.current.transfers).toHaveLength(1); + }); + + it('excludes unrelated transaction types', () => { + const tx = makeTx(TransactionType.swap); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(0); + }); + + it('sorts correctly when one transaction has an undefined time (covers ?? 0 fallback)', () => { + const older = makeTx(TransactionType.moneyAccountDeposit, { + id: 'tx-older', + time: 1000, + }); + const noTime = makeTx(TransactionType.moneyAccountWithdraw, { + id: 'tx-no-time', + time: undefined, + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { + state: engineState({ moneyActivityMockDataEnabled: false }, [ + noTime, + older, + ]), + }, + ); + // Both transactions should be included; the one with a real timestamp + // sorts before the one with undefined time (which sorts as 0). + expect(result.current.allTransactions).toHaveLength(2); + expect(result.current.allTransactions[0].id).toBe('tx-older'); + expect(result.current.allTransactions[1].id).toBe('tx-no-time'); + }); + }); }); diff --git a/app/components/UI/Money/hooks/useMoneyAccountTransactions.ts b/app/components/UI/Money/hooks/useMoneyAccountTransactions.ts index 37bd407ec31..58e70d5c8ac 100644 --- a/app/components/UI/Money/hooks/useMoneyAccountTransactions.ts +++ b/app/components/UI/Money/hooks/useMoneyAccountTransactions.ts @@ -1,7 +1,11 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { toChecksumHexAddress } from '@metamask/controller-utils'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + type TransactionMeta, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; import { selectPrimaryMoneyAccount } from '../../../../selectors/moneyAccountController'; import { selectMoneyActivityMockDataEnabledFlag } from '../selectors/featureFlags'; import MOCK_MONEY_TRANSACTIONS from '../constants/mockActivityData'; @@ -9,6 +13,7 @@ import { isMoneyActivityDeposit, isMoneyActivityTransfer, } from '../constants/moneyActivityFilters'; +import { selectNonReplacedTransactions } from '../../../../selectors/transactionController'; export interface UseMoneyAccountTransactionsResult { /** Confirmed + submitted (filtered) merged, sorted by time descending */ @@ -20,17 +25,20 @@ export interface UseMoneyAccountTransactionsResult { /** Transactions awaiting confirmation (not in a final on-chain state) */ submittedTransactions: TransactionMeta[]; moneyAddress: string | undefined; + // TODO: remove this after design implementation of the activity view is done + mockDataEnabled: boolean; } /** * Money account activity. When `moneyActivityMockDataEnabled` is on (remote or * `MM_MONEY_ACTIVITY_MOCK_DATA_ENABLED`), returns static mock rows for UI/QA. - * Otherwise returns empty lists until a dedicated Money transactions controller - * is integrated. + * Otherwise reads real transactions from TransactionController, filtered to + * those involving the primary Money account address. */ export function useMoneyAccountTransactions(): UseMoneyAccountTransactionsResult { const primaryMoneyAccount = useSelector(selectPrimaryMoneyAccount); const mockDataEnabled = useSelector(selectMoneyActivityMockDataEnabledFlag); + const nonReplacedTransactions = useSelector(selectNonReplacedTransactions); const moneyAddress = useMemo(() => { const raw = primaryMoneyAccount?.address; @@ -38,25 +46,49 @@ export function useMoneyAccountTransactions(): UseMoneyAccountTransactionsResult }, [primaryMoneyAccount]); return useMemo(() => { - const empty = { - allTransactions: [] as TransactionMeta[], - deposits: [] as TransactionMeta[], - transfers: [] as TransactionMeta[], - submittedTransactions: [] as TransactionMeta[], - moneyAddress, - }; - - if (!mockDataEnabled) { - return empty; + if (mockDataEnabled) { + const allTransactions = [...MOCK_MONEY_TRANSACTIONS]; + return { + allTransactions, + deposits: allTransactions.filter(isMoneyActivityDeposit), + transfers: allTransactions.filter(isMoneyActivityTransfer), + submittedTransactions: [], + moneyAddress, + mockDataEnabled: true, + }; } - const allTransactions = [...MOCK_MONEY_TRANSACTIONS]; + const moneyTransactions = nonReplacedTransactions + .filter((tx) => { + // Direct Money account transactions. + if ( + tx.type === TransactionType.moneyAccountDeposit || + tx.type === TransactionType.moneyAccountWithdraw + ) { + return true; + } + // EIP-7702 batch where a Money account call is a nested call. + return ( + tx.nestedTransactions?.some( + (nested) => + nested.type === TransactionType.moneyAccountDeposit || + nested.type === TransactionType.moneyAccountWithdraw, + ) ?? false + ); + }) + .sort((a, b) => (b?.time ?? 0) - (a?.time ?? 0)); + + const submittedTransactions = moneyTransactions.filter( + (tx) => tx.status === TransactionStatus.submitted, + ); + return { - allTransactions, - deposits: allTransactions.filter(isMoneyActivityDeposit), - transfers: allTransactions.filter(isMoneyActivityTransfer), - submittedTransactions: [], + allTransactions: moneyTransactions, + deposits: moneyTransactions.filter(isMoneyActivityDeposit), + transfers: moneyTransactions.filter(isMoneyActivityTransfer), + submittedTransactions, moneyAddress, + mockDataEnabled: false, }; - }, [mockDataEnabled, moneyAddress]); + }, [mockDataEnabled, moneyAddress, nonReplacedTransactions]); } diff --git a/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.ts b/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.ts new file mode 100644 index 00000000000..1901f920585 --- /dev/null +++ b/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.ts @@ -0,0 +1,712 @@ +import { + type TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { IconName } from '@metamask/design-system-react-native'; +import type { Hex } from '@metamask/utils'; +import { + renderHookWithProvider, + type ProviderValues, +} from '../../../../util/test/renderWithProvider'; +import { safeToChecksumAddress } from '../../../../util/address'; +import { MUSD_TOKEN_ADDRESS } from '../../Earn/constants/musd'; +import { useMoneyTransactionDisplayInfo } from './useMoneyTransactionDisplayInfo'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +jest.mock('../../../../../locales/i18n', () => ({ + __esModule: true, + default: { locale: 'en-US' }, + strings: (key: string) => key, +})); + +jest.mock('@metamask/assets-controllers', () => ({ + getNativeTokenAddress: (chainId: string) => { + // Return a well-known address only for mainnet so we can control which + // tokens are treated as native in tests. + if (chainId === '0x1') return '0x0000000000000000000000000000000000000000'; + throw new Error(`unknown chainId ${chainId}`); + }, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const CHAIN_ID: Hex = '0x1'; +const USDC_ADDRESS: Hex = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; +// ETH native address on mainnet +const ETH_ADDRESS: Hex = '0x0000000000000000000000000000000000000000'; + +function makeState( + overrides: { + currentCurrency?: string; + currencyRates?: Record< + string, + { conversionRate?: number; usdConversionRate?: number } + >; + tokenMarketData?: Record; + tokens?: TransactionMeta[]; + } = {}, +): ProviderValues['state'] { + return { + engine: { + backgroundState: { + CurrencyRateController: { + currentCurrency: overrides.currentCurrency ?? 'usd', + currencyRates: overrides.currencyRates ?? {}, + }, + TokenRatesController: { + marketData: overrides.tokenMarketData ?? {}, + }, + TokensController: { + allTokens: {}, + }, + NetworkController: { + networkConfigurationsByChainId: { + [CHAIN_ID]: { nativeCurrency: 'ETH' }, + }, + }, + }, + }, + } as unknown as ProviderValues['state']; +} + +function makeTx( + type: TransactionType, + extra: Record = {}, +): TransactionMeta { + return { + id: 'tx-1', + chainId: CHAIN_ID, + type, + ...extra, + } as unknown as TransactionMeta; +} + +// --------------------------------------------------------------------------- +// Label β€” titleKeyToLabel exhaustive coverage +// --------------------------------------------------------------------------- + +describe('useMoneyTransactionDisplayInfo β€” titleKeyToLabel all keys', () => { + const cases: [string, string][] = [ + ['added', 'money.transaction.added'], + ['deposited', 'money.transaction.deposited'], + ['received', 'money.transaction.received'], + ['card_transaction', 'money.transaction.card_transaction'], + ['converted', 'money.transaction.converted'], + ['sent', 'money.transaction.sent'], + ['transferred', 'money.transaction.transferred'], + // unknown key hits the default branch β†’ 'received' + ['unknown_key_xyz', 'money.transaction.received'], + ]; + + it.each(cases)( + 'moneyActivityTitleKey "%s" produces label "%s"', + (key, expected) => { + const tx = makeTx(TransactionType.moneyAccountDeposit, { + moneyActivityTitleKey: key, + }); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.label).toBe(expected); + }, + ); +}); + +// --------------------------------------------------------------------------- +// Label β€” getLabelForTransactionType exhaustive coverage +// --------------------------------------------------------------------------- + +describe('useMoneyTransactionDisplayInfo β€” getLabelForTransactionType', () => { + it('returns deposited when type is undefined', () => { + const tx = makeTx(TransactionType.moneyAccountDeposit, { type: undefined }); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.label).toBe('money.transaction.deposited'); + }); + + it('returns deposited for moneyAccountDeposit type', () => { + const tx = makeTx(TransactionType.moneyAccountDeposit); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.label).toBe('money.transaction.deposited'); + }); + + it('returns deposited for incoming type', () => { + const tx = makeTx(TransactionType.incoming); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.label).toBe('money.transaction.deposited'); + }); + + it('returns sent for moneyAccountWithdraw type', () => { + const tx = makeTx(TransactionType.moneyAccountWithdraw); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.label).toBe('money.transaction.sent'); + }); + + it('returns sent for simpleSend type', () => { + const tx = makeTx(TransactionType.simpleSend); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.label).toBe('money.transaction.sent'); + }); + + it('returns converted for musdConversion type', () => { + const tx = makeTx(TransactionType.musdConversion); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.label).toBe('money.transaction.converted'); + }); + + it('returns received (default) for unrecognised type', () => { + const tx = makeTx(TransactionType.swap); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.label).toBe('money.transaction.received'); + }); + + it('derives label from nested type for an EIP-7702 batch deposit', () => { + const tx = makeTx(TransactionType.batch, { + nestedTransactions: [{ type: TransactionType.moneyAccountDeposit }], + }); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.label).toBe('money.transaction.deposited'); + }); + + it('derives label from nested type for an EIP-7702 batch withdraw', () => { + const tx = makeTx(TransactionType.batch, { + nestedTransactions: [{ type: TransactionType.moneyAccountWithdraw }], + }); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.label).toBe('money.transaction.sent'); + }); + + it('returns received for a batch tx with no money-type nested transaction', () => { + const tx = makeTx(TransactionType.batch, { + nestedTransactions: [{ type: TransactionType.swap }], + }); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + // batch with no money nested type hits getLabelForTransactionType(batch) β†’ default β†’ 'received' + expect(result.current.label).toBe('money.transaction.received'); + }); +}); + +// --------------------------------------------------------------------------- +// Icon β€” titleKeyToIcon exhaustive coverage +// --------------------------------------------------------------------------- + +describe('useMoneyTransactionDisplayInfo β€” titleKeyToIcon all keys', () => { + const cases: [string, IconName][] = [ + ['added', IconName.Add], + ['deposited', IconName.Add], + ['received', IconName.Arrow2Down], + ['card_transaction', IconName.Card], + ['converted', IconName.Refresh], + ['sent', IconName.Arrow2UpRight], + ['transferred', IconName.SwapHorizontal], + // unknown key β†’ default β†’ Arrow2Down + ['unknown_key_xyz', IconName.Arrow2Down], + ]; + + it.each(cases)( + 'moneyActivityTitleKey "%s" produces icon %s', + (key, expected) => { + const tx = makeTx(TransactionType.moneyAccountDeposit, { + moneyActivityTitleKey: key, + }); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.icon).toBe(expected); + }, + ); +}); + +// --------------------------------------------------------------------------- +// Icon β€” getIconForTransactionType exhaustive coverage +// --------------------------------------------------------------------------- + +describe('useMoneyTransactionDisplayInfo β€” getIconForTransactionType', () => { + it('returns Arrow2Down when type is undefined', () => { + const tx = makeTx(TransactionType.moneyAccountDeposit, { type: undefined }); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.icon).toBe(IconName.Arrow2Down); + }); + + it('returns Add for moneyAccountDeposit type', () => { + const tx = makeTx(TransactionType.moneyAccountDeposit); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.icon).toBe(IconName.Add); + }); + + it('returns Arrow2Down for incoming type', () => { + const tx = makeTx(TransactionType.incoming); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.icon).toBe(IconName.Arrow2Down); + }); + + it('returns Refresh for musdConversion type', () => { + const tx = makeTx(TransactionType.musdConversion); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.icon).toBe(IconName.Refresh); + }); + + it('returns SwapHorizontal for moneyAccountWithdraw type', () => { + const tx = makeTx(TransactionType.moneyAccountWithdraw); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.icon).toBe(IconName.SwapHorizontal); + }); + + it('returns Arrow2UpRight for simpleSend type', () => { + const tx = makeTx(TransactionType.simpleSend); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.icon).toBe(IconName.Arrow2UpRight); + }); + + it('returns Arrow2Down (default) for unrecognised type', () => { + const tx = makeTx(TransactionType.swap); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.icon).toBe(IconName.Arrow2Down); + }); + + it('returns Add for a batch tx with a nested moneyAccountDeposit', () => { + const tx = makeTx(TransactionType.batch, { + nestedTransactions: [{ type: TransactionType.moneyAccountDeposit }], + }); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.icon).toBe(IconName.Add); + }); + + it('returns SwapHorizontal for a batch tx with a nested moneyAccountWithdraw', () => { + const tx = makeTx(TransactionType.batch, { + nestedTransactions: [{ type: TransactionType.moneyAccountWithdraw }], + }); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.icon).toBe(IconName.SwapHorizontal); + }); +}); + +// --------------------------------------------------------------------------- +// Primary amount β€” ERC-20 (USDC) +// --------------------------------------------------------------------------- + +describe('useMoneyTransactionDisplayInfo β€” ERC-20 primary amount', () => { + it('formats USDC requiredAssets amount as +X.XX USDC', () => { + // 1_000_000 minimal units = 1.00 USDC (6 decimals) + const tx = makeTx(TransactionType.moneyAccountDeposit, { + metamaskPay: { tokenAddress: USDC_ADDRESS, chainId: CHAIN_ID }, + requiredAssets: [{ address: USDC_ADDRESS, amount: '1000000' }], + }); + + // Provide the token in state so it looks like an ERC-20 (not native). + const stateWithUsdc = { + engine: { + backgroundState: { + CurrencyRateController: { currentCurrency: 'usd', currencyRates: {} }, + TokenRatesController: { marketData: {} }, + TokensController: { + allTokens: { + [CHAIN_ID]: { + '0xSomeWallet': [ + { + address: USDC_ADDRESS, + symbol: 'USDC', + decimals: 6, + image: undefined, + }, + ], + }, + }, + }, + NetworkController: { + networkConfigurationsByChainId: { + [CHAIN_ID]: { nativeCurrency: 'ETH' }, + }, + }, + }, + }, + } as unknown as ProviderValues['state']; + + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: stateWithUsdc }, + ); + expect(result.current.primaryAmount).toBe('+1.00 USDC'); + }); +}); + +// --------------------------------------------------------------------------- +// Primary amount β€” native token (ETH) +// --------------------------------------------------------------------------- + +describe('useMoneyTransactionDisplayInfo β€” native token (ETH) primary amount', () => { + /** + * requiredAssets[0].amount is stored as a 6-decimal USDC-equivalent + * (i.e. the USD value of the deposit). Given amount=998537 ($0.998537) + * and ETH/USD rate=2242, the expected ETH amount is + * 0.998537 / 2242 β‰ˆ 0.000445 ETH (rounded down to 6dp). + * + * Note: the code must use usdConversionRate (ETHβ†’USD), not conversionRate + * (ETHβ†’currentCurrency), because requiredAsset.amount is always in USD units. + */ + it('converts 6-decimal USD amount to ETH via usdConversionRate', () => { + const tx = makeTx(TransactionType.moneyAccountDeposit, { + metamaskPay: { tokenAddress: ETH_ADDRESS, chainId: CHAIN_ID }, + requiredAssets: [{ address: ETH_ADDRESS, amount: '998537' }], + }); + + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { + state: makeState({ + currencyRates: { ETH: { usdConversionRate: 2242 } }, + }), + }, + ); + + // 0.998537 / 2242 = 0.00044538... β†’ toFixed(6, ROUND_DOWN) = "0.000445" + expect(result.current.primaryAmount).toBe('+0.000445 ETH'); + }); + + it('uses usdConversionRate not conversionRate β€” correct result in non-USD currency', () => { + // currentCurrency = EUR; ETH/EUR = 2000, ETH/USD = 2242 + // requiredAsset.amount = 998537 (= $0.998537 USD) + // Correct: 0.998537 / 2242 β‰ˆ 0.000445 ETH (uses usdConversionRate) + // Incorrect: 0.998537 / 2000 β‰ˆ 0.000499 ETH (would use conversionRate) + const tx = makeTx(TransactionType.moneyAccountDeposit, { + metamaskPay: { tokenAddress: ETH_ADDRESS, chainId: CHAIN_ID }, + requiredAssets: [{ address: ETH_ADDRESS, amount: '998537' }], + }); + + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { + state: makeState({ + currentCurrency: 'eur', + currencyRates: { + ETH: { conversionRate: 2000, usdConversionRate: 2242 }, + }, + }), + }, + ); + + expect(result.current.primaryAmount).toBe('+0.000445 ETH'); + }); + + it('leaves primaryAmount empty when exchange rate is unavailable', () => { + const tx = makeTx(TransactionType.moneyAccountDeposit, { + metamaskPay: { tokenAddress: ETH_ADDRESS, chainId: CHAIN_ID }, + requiredAssets: [{ address: ETH_ADDRESS, amount: '998537' }], + }); + + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState({ currencyRates: {} }) }, + ); + + expect(result.current.primaryAmount).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// Fiat amount fallback +// --------------------------------------------------------------------------- + +describe('useMoneyTransactionDisplayInfo β€” fiat amount fallback', () => { + it('falls back to metamaskPay.targetFiat when no market-rate value is available', () => { + const tx = makeTx(TransactionType.moneyAccountDeposit, { + metamaskPay: { targetFiat: 1.5 }, + }); + + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState({ currentCurrency: 'usd' }) }, + ); + + // moneyFormatFiat formats to a currency string; we just confirm it's non-empty and positive + expect(result.current.fiatAmount).toBeTruthy(); + expect(result.current.fiatAmount.startsWith('+')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Token resolution β€” catch branch and no-token fallback +// --------------------------------------------------------------------------- + +describe('useMoneyTransactionDisplayInfo β€” token resolution edge cases', () => { + it('leaves primaryAmount empty when token is not in state and address is not native', () => { + // USDC address but NOT present in TokensController state β†’ not native, not erc-20 + // β†’ sourceTokenSymbol is undefined β†’ primaryAmount stays empty + const tx = makeTx(TransactionType.moneyAccountDeposit, { + metamaskPay: { tokenAddress: USDC_ADDRESS, chainId: CHAIN_ID }, + requiredAssets: [{ address: USDC_ADDRESS, amount: '1000000' }], + }); + + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + // No token in allTokens β€” selectSingleTokenByAddressAndChainId returns undefined + { state: makeState() }, + ); + + expect(result.current.primaryAmount).toBe(''); + }); + + it('leaves primaryAmount empty when getNativeTokenAddress throws for unknown chainId', () => { + // Use a chainId our mock does not support β†’ isNativeTokenAddress catch β†’ returns false. + // We also include the chain in networkConfigurationsByChainId so that + // selectTickerByChainId does not error if the reselect stability check + // ever exercises that path. + const UNKNOWN_CHAIN: Hex = '0x89'; // Polygon (not in our mock) + const tx = makeTx(TransactionType.moneyAccountDeposit, { + metamaskPay: { tokenAddress: USDC_ADDRESS, chainId: UNKNOWN_CHAIN }, + requiredAssets: [{ address: USDC_ADDRESS, amount: '1000000' }], + }); + + const stateWithPolygon = { + engine: { + backgroundState: { + CurrencyRateController: { currentCurrency: 'usd', currencyRates: {} }, + TokenRatesController: { marketData: {} }, + TokensController: { allTokens: {} }, + NetworkController: { + networkConfigurationsByChainId: { + [CHAIN_ID]: { nativeCurrency: 'ETH' }, + [UNKNOWN_CHAIN]: { nativeCurrency: 'MATIC' }, + }, + }, + }, + }, + } as unknown as import('../../../../util/test/renderWithProvider').ProviderValues['state']; + + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: stateWithPolygon }, + ); + + expect(result.current.primaryAmount).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// Description +// --------------------------------------------------------------------------- + +describe('useMoneyTransactionDisplayInfo β€” description', () => { + it('uses moneySubtitle when present', () => { + const tx = makeTx(TransactionType.moneyAccountDeposit, { + moneySubtitle: 'My custom subtitle', + }); + + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + + expect(result.current.description).toBe('My custom subtitle'); + }); + + it('falls back to sourceTokenSymbol as description when no moneySubtitle', () => { + // USDC in state so sourceTokenSymbol is 'USDC' + const tx = makeTx(TransactionType.moneyAccountDeposit, { + metamaskPay: { tokenAddress: USDC_ADDRESS, chainId: CHAIN_ID }, + }); + const stateWithUsdc = { + engine: { + backgroundState: { + CurrencyRateController: { currentCurrency: 'usd', currencyRates: {} }, + TokenRatesController: { marketData: {} }, + TokensController: { + allTokens: { + [CHAIN_ID]: { + '0xSomeWallet': [ + { + address: USDC_ADDRESS, + symbol: 'USDC', + decimals: 6, + image: undefined, + }, + ], + }, + }, + }, + NetworkController: { + networkConfigurationsByChainId: { + [CHAIN_ID]: { nativeCurrency: 'ETH' }, + }, + }, + }, + }, + } as unknown as import('../../../../util/test/renderWithProvider').ProviderValues['state']; + + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: stateWithUsdc }, + ); + + expect(result.current.description).toBe('USDC'); + }); +}); + +// --------------------------------------------------------------------------- +// Fiat formatting β€” mUSD via market rate +// --------------------------------------------------------------------------- + +const MUSD_CHECKSUM = safeToChecksumAddress(MUSD_TOKEN_ADDRESS) as string; + +const musedTx: TransactionMeta = { + id: 'tx-musd', + type: TransactionType.incoming, + chainId: CHAIN_ID, + transferInformation: { + amount: '1000000000', + symbol: 'mUSD', + decimals: 6, + contractAddress: MUSD_TOKEN_ADDRESS, + }, +} as unknown as TransactionMeta; + +function musedMarketState(tokenPrice: number) { + return { + engine: { + backgroundState: { + CurrencyRateController: { + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionRate: 3000, + usdConversionRate: 3000, + conversionDate: null, + }, + }, + }, + TokenRatesController: { + marketData: { + [CHAIN_ID]: { + [MUSD_CHECKSUM]: { price: tokenPrice }, + }, + }, + }, + TokensController: { allTokens: {} }, + NetworkController: { + networkConfigurationsByChainId: { + [CHAIN_ID]: { nativeCurrency: 'ETH' }, + }, + }, + }, + }, + } as unknown as ProviderValues['state']; +} + +describe('useMoneyTransactionDisplayInfo β€” mUSD fiat formatting', () => { + it('formats fiat in USD via market rate', () => { + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(musedTx, undefined), + { state: musedMarketState(1 / 3000) }, + ); + + expect(result.current.fiatAmount).toMatch(/^\+/); + expect(result.current.fiatAmount).toMatch(/1,000\.00/); + expect(result.current.primaryAmount).toMatch(/1,000\.00/); + expect(result.current.primaryAmount).toContain('mUSD'); + }); + + it('uses market rate and ETHβ†’fiat conversion for non-USD currencies', () => { + const state = { + engine: { + backgroundState: { + CurrencyRateController: { + currentCurrency: 'eur', + currencyRates: { + ETH: { + conversionRate: 2300, + usdConversionRate: 2500, + conversionDate: null, + }, + }, + }, + TokenRatesController: { + marketData: { + [CHAIN_ID]: { + [MUSD_CHECKSUM]: { price: 0.0004 }, + }, + }, + }, + TokensController: { allTokens: {} }, + NetworkController: { + networkConfigurationsByChainId: { + [CHAIN_ID]: { nativeCurrency: 'ETH' }, + }, + }, + }, + }, + } as unknown as ProviderValues['state']; + + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(musedTx, undefined), + { state }, + ); + + expect(result.current.fiatAmount).toMatch(/^\+/); + expect(result.current.fiatAmount).toMatch(/920/); + expect(result.current.primaryAmount).toMatch(/1,000\.00/); + expect(result.current.primaryAmount).toContain('mUSD'); + }); +}); diff --git a/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.tsx b/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.tsx deleted file mode 100644 index ab29e9dfbfd..00000000000 --- a/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { - type TransactionMeta, - TransactionType, -} from '@metamask/transaction-controller'; -import { IconName } from '@metamask/design-system-react-native'; -import type { Hex } from '@metamask/utils'; -import { safeToChecksumAddress } from '../../../../util/address'; -import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; -import { useMoneyTransactionDisplayInfo } from './useMoneyTransactionDisplayInfo'; -import { MUSD_TOKEN_ADDRESS } from '../../Earn/constants/musd'; -import type { MoneyActivityTitleKey } from '../constants/mockActivityData'; - -const MOCK_CHAIN: Hex = '0x1'; -const checksumToken = safeToChecksumAddress(MUSD_TOKEN_ADDRESS) as string; - -const baseTx = { - id: 'tx-1', - type: TransactionType.incoming, - chainId: MOCK_CHAIN, - transferInformation: { - amount: '1000000000', - symbol: 'mUSD', - decimals: 6, - contractAddress: MUSD_TOKEN_ADDRESS, - }, -} as unknown as TransactionMeta; - -function tokenMarketState(tokenPrice: number) { - return { - marketData: { - [MOCK_CHAIN]: { - [checksumToken]: { price: tokenPrice }, - }, - }, - }; -} - -describe('useMoneyTransactionDisplayInfo', () => { - it('formats fiat in USD when current currency is USD', () => { - const { result } = renderHookWithProvider( - () => useMoneyTransactionDisplayInfo(baseTx, undefined), - { - state: { - engine: { - backgroundState: { - CurrencyRateController: { - currentCurrency: 'usd', - currencyRates: { - ETH: { - conversionRate: 3000, - usdConversionRate: 3000, - conversionDate: null, - }, - }, - }, - TokenRatesController: tokenMarketState(1 / 3000), - }, - }, - }, - }, - ); - - expect(result.current.fiatAmount).toMatch(/^\+/); - expect(result.current.fiatAmount).toMatch(/1,000\.00/); - expect(result.current.primaryAmount).toMatch(/1,000\.00/); - expect(result.current.primaryAmount).toContain('mUSD'); - }); - - it('uses token market rate and ETHβ†’fiat for non-USD', () => { - const { result } = renderHookWithProvider( - () => useMoneyTransactionDisplayInfo(baseTx, undefined), - { - state: { - engine: { - backgroundState: { - CurrencyRateController: { - currentCurrency: 'eur', - currencyRates: { - ETH: { - conversionRate: 2300, - usdConversionRate: 2500, - conversionDate: null, - }, - }, - }, - TokenRatesController: tokenMarketState(0.0004), - }, - }, - }, - }, - ); - - expect(result.current.fiatAmount).toMatch(/^\+/); - expect(result.current.fiatAmount).toMatch(/920/); - expect(result.current.primaryAmount).toMatch(/1,000\.00/); - expect(result.current.primaryAmount).toContain('mUSD'); - }); - - function renderIcon(tx: TransactionMeta): IconName { - const { result } = renderHookWithProvider( - () => useMoneyTransactionDisplayInfo(tx, undefined), - { - state: { - engine: { - backgroundState: { - CurrencyRateController: { - currentCurrency: 'usd', - currencyRates: { - ETH: { - conversionRate: 3000, - usdConversionRate: 3000, - conversionDate: null, - }, - }, - }, - TokenRatesController: tokenMarketState(1 / 3000), - }, - }, - }, - }, - ); - return result.current.icon; - } - - function txWithTitleKey(key: MoneyActivityTitleKey): TransactionMeta { - return { - ...baseTx, - moneyActivityTitleKey: key, - } as unknown as TransactionMeta; - } - - function txWithType(type: TransactionType | undefined): TransactionMeta { - return { - ...baseTx, - type, - } as unknown as TransactionMeta; - } - - describe('icon', () => { - it.each<[MoneyActivityTitleKey, IconName]>([ - ['added', IconName.Add], - ['deposited', IconName.Add], - ['received', IconName.Arrow2Down], - ['converted', IconName.Refresh], - ['transferred', IconName.SwapHorizontal], - ['card_transaction', IconName.Card], - ['sent', IconName.Arrow2UpRight], - ])('maps title key "%s" to %s', (key, expected) => { - expect(renderIcon(txWithTitleKey(key))).toBe(expected); - }); - - it.each<[TransactionType, IconName]>([ - [TransactionType.moneyAccountDeposit, IconName.Add], - [TransactionType.incoming, IconName.Arrow2Down], - [TransactionType.musdConversion, IconName.Refresh], - [TransactionType.moneyAccountWithdraw, IconName.SwapHorizontal], - [TransactionType.simpleSend, IconName.Arrow2UpRight], - ])('falls back to type "%s" mapping %s', (type, expected) => { - expect(renderIcon(txWithType(type))).toBe(expected); - }); - - it('defaults to Arrow2Down when type is undefined and no title key', () => { - expect(renderIcon(txWithType(undefined))).toBe(IconName.Arrow2Down); - }); - - it('defaults to Arrow2Down for an unmapped transaction type', () => { - expect(renderIcon(txWithType(TransactionType.contractInteraction))).toBe( - IconName.Arrow2Down, - ); - }); - - it('disambiguates moneyAccountWithdraw (no title key) to SwapHorizontal', () => { - expect(renderIcon(txWithType(TransactionType.moneyAccountWithdraw))).toBe( - IconName.SwapHorizontal, - ); - }); - - it('disambiguates simpleSend (no title key) to Arrow2UpRight', () => { - expect(renderIcon(txWithType(TransactionType.simpleSend))).toBe( - IconName.Arrow2UpRight, - ); - }); - - it('prefers the title key over the transaction type', () => { - const tx = { - ...baseTx, - type: TransactionType.simpleSend, - moneyActivityTitleKey: 'received', - } as unknown as TransactionMeta; - expect(renderIcon(tx)).toBe(IconName.Arrow2Down); - }); - }); -}); diff --git a/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.ts b/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.ts index 5998d123513..d898edc3619 100644 --- a/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.ts +++ b/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.ts @@ -2,20 +2,30 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { type TransactionMeta, + type RequiredAsset, TransactionType, } from '@metamask/transaction-controller'; +import { type Hex } from '@metamask/utils'; +import BigNumber from 'bignumber.js'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; import { IconName } from '@metamask/design-system-react-native'; -import { strings } from '../../../../../locales/i18n'; +import I18n, { strings } from '../../../../../locales/i18n'; +import { getIntlNumberFormatter } from '../../../../util/intl'; import { selectCurrencyRates, selectCurrentCurrency, } from '../../../../selectors/currencyRateController'; import { selectTokenMarketData } from '../../../../selectors/tokenRatesController'; +import { selectSingleTokenByAddressAndChainId } from '../../../../selectors/tokensController'; +import { selectTickerByChainId } from '../../../../selectors/networkController'; +import type { RootState } from '../../../../reducers'; import { getMusdDisplayAmountFromTransactionMeta, isIncomingMoneyTransactionMeta, } from '../constants/activityStyles'; import { buildMoneyActivityFiatLine } from '../utils/moneyActivityFiat'; +import { moneyFormatFiat } from '../utils/moneyFormatFiat'; +import { fromTokenMinimalUnit } from '../../../../util/number/bigint'; import type { MoneyActivityTitleKey, MoneyActivityTransactionMeta, @@ -53,12 +63,12 @@ function titleKeyToLabel(key: MoneyActivityTitleKey): string { function getLabelForTransactionType(type: TransactionType | undefined): string { if (!type) { - return strings('money.transaction.received'); + return strings('money.transaction.deposited'); } switch (type) { - case TransactionType.incoming: case TransactionType.moneyAccountDeposit: - return strings('money.transaction.received'); + case TransactionType.incoming: + return strings('money.transaction.deposited'); case TransactionType.moneyAccountWithdraw: case TransactionType.simpleSend: return strings('money.transaction.sent'); @@ -79,6 +89,18 @@ function getLabel(tx: TransactionMeta): string { if (extended.moneyActivityTitleKey) { return titleKeyToLabel(extended.moneyActivityTitleKey); } + // For EIP-7702 batch transactions, derive the label from the most significant + // nested transaction type (e.g. moneyAccountDeposit, moneyAccountWithdraw). + if (tx.type === TransactionType.batch) { + const moneyNestedType = tx.nestedTransactions?.find( + (nested) => + nested.type === TransactionType.moneyAccountDeposit || + nested.type === TransactionType.moneyAccountWithdraw, + )?.type; + if (moneyNestedType) { + return getLabelForTransactionType(moneyNestedType); + } + } return getLabelForTransactionType(tx.type); } @@ -130,9 +152,63 @@ function getIcon(tx: TransactionMeta): IconName { if (extended.moneyActivityTitleKey) { return titleKeyToIcon(extended.moneyActivityTitleKey); } + // For EIP-7702 batch transactions, derive the icon from the most significant + // nested transaction type. + if (tx.type === TransactionType.batch) { + const moneyNestedType = tx.nestedTransactions?.find( + (nested) => + nested.type === TransactionType.moneyAccountDeposit || + nested.type === TransactionType.moneyAccountWithdraw, + )?.type; + if (moneyNestedType) { + return getIconForTransactionType(moneyNestedType); + } + } return getIconForTransactionType(tx.type); } +/** + * Returns the first required asset from a pay transaction, if present. + */ +function getRequiredAsset(tx: TransactionMeta): RequiredAsset | undefined { + return tx.requiredAssets?.[0]; +} + +/** + * Formats a hex or decimal token minimal-unit amount into a human-readable + * string with symbol, e.g. "+1.00 USDC". + */ +function buildSourceTokenAmount( + rawAmount: string, + decimals: number, + symbol: string, +): string { + const humanReadable = fromTokenMinimalUnit(rawAmount, decimals); + const num = parseFloat(humanReadable); + if (isNaN(num)) { + return ''; + } + const formatted = getIntlNumberFormatter(I18n.locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + useGrouping: true, + }).format(num); + return `+${formatted} ${symbol}`; +} + +/** + * Returns true when `tokenAddress` is the native currency on `chainId` + * (e.g. ETH on mainnet). + */ +function isNativeTokenAddress(tokenAddress: string, chainId: Hex): boolean { + try { + const nativeAddress = getNativeTokenAddress(chainId); + return tokenAddress.toLowerCase() === nativeAddress.toLowerCase(); + } catch { + return false; + } +} + /** * Derives display strings for a Money activity row backed by {@link TransactionMeta}. */ @@ -145,20 +221,110 @@ export function useMoneyTransactionDisplayInfo( const currencyRates = useSelector(selectCurrencyRates); const tokenMarketData = useSelector(selectTokenMarketData); - return useMemo( - () => ({ + const payTokenAddress = tx.metamaskPay?.tokenAddress as Hex | undefined; + const payTokenChainId = tx.metamaskPay?.chainId as Hex | undefined; + + // look up erc-20 tokens + const payToken = useSelector((state: RootState) => + payTokenAddress && payTokenChainId + ? selectSingleTokenByAddressAndChainId( + state, + payTokenAddress, + payTokenChainId, + ) + : undefined, + ); + + // Native token fallback - these are not in the token registry + const nativeTicker = useSelector((state: RootState) => { + if (payToken || !payTokenAddress || !payTokenChainId) { + return undefined; + } + if (isNativeTokenAddress(payTokenAddress, payTokenChainId)) { + return selectTickerByChainId(state, payTokenChainId); + } + return undefined; + }); + + return useMemo(() => { + const isNative = Boolean(nativeTicker); + + const sourceTokenSymbol = payToken?.symbol ?? nativeTicker; + + // --- Primary amount --- + // Prefer transferInformation (set on simple confirmed txs). + // For batch deposits it's absent, so fall back to requiredAssets. + let primaryAmount = getMusdDisplayAmountFromTransactionMeta(tx); + if (!primaryAmount && sourceTokenSymbol) { + const requiredAsset = getRequiredAsset(tx); + if (requiredAsset) { + if (isNative) { + // For native tokens requiredAssets[0].amount is stored + // in USDC-equivalent 6-decimal units (the USD value of the deposit), + // NOT in wei. + + const nativeToUsdRate = nativeTicker + ? currencyRates?.[nativeTicker]?.usdConversionRate + : undefined; + const usdValue = new BigNumber(requiredAsset.amount).dividedBy(1e6); + if ( + usdValue.isGreaterThan(0) && + nativeToUsdRate && + nativeToUsdRate > 0 + ) { + const nativeAmount = usdValue.dividedBy(nativeToUsdRate); + // Show up to 6 decimal places, trim trailing zeros. + const fixed = nativeAmount.toFixed(6, BigNumber.ROUND_DOWN); + const trimmed = fixed + .replace(/(\.\d*[1-9])0+$/, '$1') + .replace(/\.0+$/, ''); + primaryAmount = `+${trimmed} ${sourceTokenSymbol}`; + } + // If the rate isn't available, primaryAmount stays empty and we fall + // through β€” the fiatAmount line will still show the correct value. + } else { + primaryAmount = buildSourceTokenAmount( + requiredAsset.amount, + payToken?.decimals ?? 6, + sourceTokenSymbol, + ); + } + } + } + + // --- Fiat amount --- + // Prefer calculated market-rate value. + let fiatAmount = buildMoneyActivityFiatLine( + tx, + currencyRates, + currentCurrency, + tokenMarketData, + ); + if (!fiatAmount && currentCurrency) { + const rawFiat = Number(tx.metamaskPay?.targetFiat); + if (!isNaN(rawFiat) && rawFiat > 0) { + fiatAmount = `+${moneyFormatFiat(new BigNumber(rawFiat), currentCurrency)}`; + } + } + + // Explicit moneySubtitle takes priority; otherwise use source token symbol + const description = subtitle ?? sourceTokenSymbol; + + return { label: getLabel(tx), - description: subtitle, - primaryAmount: getMusdDisplayAmountFromTransactionMeta(tx), - fiatAmount: buildMoneyActivityFiatLine( - tx, - currencyRates, - currentCurrency, - tokenMarketData, - ), + description, + primaryAmount, + fiatAmount, isIncoming: isIncomingMoneyTransactionMeta(tx), icon: getIcon(tx), - }), - [tx, subtitle, currentCurrency, currencyRates, tokenMarketData], - ); + }; + }, [ + tx, + subtitle, + currentCurrency, + currencyRates, + tokenMarketData, + payToken, + nativeTicker, + ]); } diff --git a/app/components/UI/Money/routes/index.tsx b/app/components/UI/Money/routes/index.tsx index ad4a32b6929..8469a81f993 100644 --- a/app/components/UI/Money/routes/index.tsx +++ b/app/components/UI/Money/routes/index.tsx @@ -20,6 +20,7 @@ import MoneyEarningsInfoSheet from '../components/MoneyEarningsInfoSheet'; import MoneyBalanceInfoSheet from '../components/MoneyBalanceInfoSheet'; import MoneyLinkCardSheet from '../components/MoneyLinkCardSheet'; import MoneyEarnCryptoInfoSheet from '../components/MoneyEarnCryptoInfoSheet'; +import MoneyTransactionDetailsSheet from '../components/MoneyTransactionDetailsSheet'; import { Confirm } from '../../../Views/confirmations/components/confirm'; import { useEmptyNavHeaderForConfirmations } from '../../../Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations'; @@ -107,6 +108,11 @@ const MoneyModalStack = () => ( component={MoneyEarnCryptoInfoSheet} options={{ headerShown: false }} /> + ); diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 522163ab78b..abc02d57790 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -446,6 +446,7 @@ const Routes = { MONEY_BALANCE_INFO_SHEET: 'MoneyBalanceInfoSheet', LINK_CARD_SHEET: 'MoneyLinkCardSheet', EARN_CRYPTO_INFO_SHEET: 'MoneyEarnCryptoInfoSheet', + TRANSACTION_DETAILS_SHEET: 'MoneyTransactionDetailsSheet', }, }, FULL_SCREEN_CONFIRMATIONS: { diff --git a/locales/languages/en.json b/locales/languages/en.json index d0a9bd0a245..67d6df146ff 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6802,7 +6802,8 @@ "sent": "Sent", "transferred": "Transferred", "card_transaction": "Card transaction", - "converted": "Converted" + "converted": "Converted", + "failed": "Failed" }, "convert_stablecoins": { "title": "Convert your stablecoins",