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",