diff --git a/app/components/UI/Earn/constants/musd.test.ts b/app/components/UI/Earn/constants/musd.test.ts index 6c44ed7bcdb..f4f6b828b04 100644 --- a/app/components/UI/Earn/constants/musd.test.ts +++ b/app/components/UI/Earn/constants/musd.test.ts @@ -1,5 +1,10 @@ import { CHAIN_IDS } from '@metamask/transaction-controller'; -import { isMusdToken, MUSD_TOKEN_ADDRESS_BY_CHAIN } from './musd'; +import { + isMusdOnMoneyAccountChain, + isMusdToken, + isMusdTokenOnChain, + MUSD_TOKEN_ADDRESS_BY_CHAIN, +} from './musd'; describe('isMusdToken', () => { const MUSD_ADDRESS = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]; @@ -44,3 +49,65 @@ describe('isMusdToken', () => { expect(result).toBe(false); }); }); + +describe('isMusdTokenOnChain', () => { + const MUSD_ADDRESS = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]; + + it('returns true for the mUSD address on a supported chain', () => { + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.MAINNET)).toBe(true); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.LINEA_MAINNET)).toBe( + true, + ); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.BSC)).toBe(true); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.MONAD)).toBe(true); + }); + + it('returns false for the mUSD address on an unsupported chain', () => { + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.POLYGON)).toBe(false); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.ARBITRUM)).toBe(false); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.OPTIMISM)).toBe(false); + }); + + it('is case-insensitive', () => { + expect( + isMusdTokenOnChain(MUSD_ADDRESS.toUpperCase(), CHAIN_IDS.MAINNET), + ).toBe(true); + }); + + it('returns false for missing address or chainId', () => { + expect(isMusdTokenOnChain(undefined, CHAIN_IDS.MAINNET)).toBe(false); + expect(isMusdTokenOnChain(MUSD_ADDRESS, undefined)).toBe(false); + }); +}); + +describe('isMusdOnMoneyAccountChain', () => { + const MUSD_ADDRESS = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]; + + it('returns true only for mUSD on Monad', () => { + expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.MONAD)).toBe(true); + }); + + it('returns false for mUSD on chains where mUSD is deployed but the Money Account is not active', () => { + expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.MAINNET)).toBe( + false, + ); + expect( + isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.LINEA_MAINNET), + ).toBe(false); + expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.BSC)).toBe(false); + }); + + it('returns false for missing arguments', () => { + expect(isMusdOnMoneyAccountChain(undefined, CHAIN_IDS.MONAD)).toBe(false); + expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, undefined)).toBe(false); + }); + + it('returns false for a non-mUSD address on Monad', () => { + expect( + isMusdOnMoneyAccountChain( + '0x1234567890123456789012345678901234567890', + CHAIN_IDS.MONAD, + ), + ).toBe(false); + }); +}); diff --git a/app/components/UI/Earn/constants/musd.ts b/app/components/UI/Earn/constants/musd.ts index 52f910c968a..22f4c9364e7 100644 --- a/app/components/UI/Earn/constants/musd.ts +++ b/app/components/UI/Earn/constants/musd.ts @@ -50,6 +50,21 @@ export const isMusdToken = (address?: string): boolean => { return address.toLowerCase() === musdAddress.toLowerCase(); }; +/** + * Like {@link isMusdToken} but also requires `chainId` to be a chain where + * mUSD is actually deployed. Prevents a same-address token on an unsupported + * chain from being misclassified as mUSD. + */ +export const isMusdTokenOnChain = ( + address?: string, + chainId?: Hex, +): boolean => { + if (!address || !chainId) return false; + const expected = MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId]; + if (!expected) return false; + return address.toLowerCase() === expected.toLowerCase(); +}; + /** * Chains where mUSD CTA should show (buy routes available). * BSC is excluded as buy routes are not yet available. @@ -60,6 +75,26 @@ export const MUSD_BUYABLE_CHAIN_IDS: Hex[] = [ // CHAIN_IDS.BSC, // TODO: Uncomment once buy routes are available ]; +/** + * Chains where the Money Account surfaces mUSD activity. mUSD exists on + * several chains for buy/convert flows, but Money Account currently only + * tracks Monad — inbound mUSD on Mainnet/Linea/BSC is unrelated to it and + * must not appear in Money activity. + */ +export const MUSD_MONEY_ACCOUNT_CHAIN_IDS: Hex[] = [CHAIN_IDS.MONAD]; + +/** + * Like {@link isMusdTokenOnChain} but restricted to chains where the Money + * Account is active (currently Monad only). + */ +export const isMusdOnMoneyAccountChain = ( + address?: string, + chainId?: Hex, +): boolean => { + if (!chainId || !MUSD_MONEY_ACCOUNT_CHAIN_IDS.includes(chainId)) return false; + return isMusdTokenOnChain(address, chainId); +}; + export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record = { [CHAIN_IDS.MAINNET]: 'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', diff --git a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyReceivedDetails.styles.ts b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyReceivedDetails.styles.ts new file mode 100644 index 00000000000..815f8fd8f4f --- /dev/null +++ b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyReceivedDetails.styles.ts @@ -0,0 +1,14 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (_params: { theme: Theme }) => + StyleSheet.create({ + container: { + paddingInline: 16, + }, + hero: { + marginVertical: 12, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyReceivedDetails.test.tsx b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyReceivedDetails.test.tsx new file mode 100644 index 00000000000..0300fa63c12 --- /dev/null +++ b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyReceivedDetails.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { + type TransactionMeta, + TransactionStatus, + TransactionType, + CHAIN_IDS, +} from '@metamask/transaction-controller'; +import { merge } from 'lodash'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { useTransactionDetails } from '../../../../Views/confirmations/hooks/activity/useTransactionDetails'; +import { otherControllersMock } from '../../../../Views/confirmations/__mocks__/controllers/other-controllers-mock'; +import { MUSD_TOKEN_ADDRESS } from '../../../Earn/constants/musd'; +import { MoneyReceivedDetails } from './MoneyReceivedDetails'; + +jest.mock( + '../../../../Views/confirmations/hooks/activity/useTransactionDetails', +); +jest.mock( + '../../../../Views/confirmations/components/activity/transaction-details-status-row', + () => ({ + TransactionDetailsStatusRow: jest.fn(() => null), + }), +); +jest.mock( + '../../../../Views/confirmations/components/activity/transaction-details-date-row', + () => ({ + TransactionDetailsDateRow: jest.fn(() => null), + }), +); +jest.mock('../../../Name/Name', () => { + const { Text } = jest.requireActual('react-native'); + // eslint-disable-next-line react/display-name + return ({ value }: { value: string }) => ( + {value} + ); +}); +jest.mock('../../../../Views/confirmations/components/token-icon', () => ({ + TokenIcon: () => null, + TokenIconVariant: { Row: 'row' }, +})); + +const FROM_MOCK = '0x1111111111111111111111111111111111111111'; +const RECIPIENT_MOCK = '0x2222222222222222222222222222222222222222'; + +const baseTransactionMeta = { + id: 'tx-1', + chainId: CHAIN_IDS.MONAD, + status: TransactionStatus.confirmed, + time: Date.UTC(2026, 4, 21, 14, 16), + type: TransactionType.incoming, + txParams: { + from: FROM_MOCK, + to: RECIPIENT_MOCK, + }, + transferInformation: { + contractAddress: MUSD_TOKEN_ADDRESS, + decimals: 6, + symbol: 'mUSD', + amount: '500000', + }, +} as unknown as TransactionMeta; + +function render(overrides: Partial = {}) { + const useTransactionDetailsMock = jest.mocked(useTransactionDetails); + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + ...baseTransactionMeta, + ...overrides, + } as TransactionMeta, + }); + return renderWithProvider(, { + state: merge({}, otherControllersMock), + }); +} + +describe('MoneyReceivedDetails', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the From row with the sender address for an incoming transfer', () => { + const { getByTestId } = render(); + expect(getByTestId('name-mock').props.children).toBe(FROM_MOCK); + }); + + it('renders the token received label with the mUSD symbol', () => { + const { getByText } = render(); + expect(getByText('Token received')).toBeTruthy(); + expect(getByText('mUSD')).toBeTruthy(); + }); + + it('renders the fiat hero when buildMoneyActivityFiatLine returns a value', () => { + const { queryByTestId } = render(); + expect(queryByTestId('money-received-hero')).toBeTruthy(); + }); + + it('omits the From row when txParams.from is missing', () => { + const { queryByTestId, queryByText } = render({ + txParams: { to: RECIPIENT_MOCK } as TransactionMeta['txParams'], + }); + expect(queryByText('From')).toBeNull(); + expect(queryByTestId('name-mock')).toBeNull(); + }); + + it('omits the token received row when no mUSD transfer meta can be resolved', () => { + const { queryByText } = render({ + transferInformation: undefined, + txParams: { + from: FROM_MOCK, + to: RECIPIENT_MOCK, + data: '0x', + } as TransactionMeta['txParams'], + }); + expect(queryByText('Token received')).toBeNull(); + }); +}); diff --git a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyReceivedDetails.tsx b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyReceivedDetails.tsx new file mode 100644 index 00000000000..3e5e6ed9e4e --- /dev/null +++ b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyReceivedDetails.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { ScrollView } from 'react-native'; +import { useSelector } from 'react-redux'; +import { type Hex } from '@metamask/utils'; +import { Text, TextVariant } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import { useStyles } from '../../../../../component-library/hooks'; +import { + selectCurrencyRates, + selectCurrentCurrency, +} from '../../../../../selectors/currencyRateController'; +import { selectTokenMarketData } from '../../../../../selectors/tokenRatesController'; +import { Box } from '../../../Box/Box'; +import { AlignItems, FlexDirection } from '../../../Box/box.types'; +import Name from '../../../Name/Name'; +import { NameType } from '../../../Name/Name.types'; +import { TransactionDetailDivider } from '../../../../Views/confirmations/components/activity/transaction-detail-divider/transaction-detail-divider'; +import { TransactionDetailsDateRow } from '../../../../Views/confirmations/components/activity/transaction-details-date-row'; +import { TransactionDetailsRow } from '../../../../Views/confirmations/components/activity/transaction-details-row/transaction-details-row'; +import { TransactionDetailsStatusRow } from '../../../../Views/confirmations/components/activity/transaction-details-status-row'; +import { + TokenIcon, + TokenIconVariant, +} from '../../../../Views/confirmations/components/token-icon'; +import { useTransactionDetails } from '../../../../Views/confirmations/hooks/activity/useTransactionDetails'; +import { resolveMusdTransferMeta } from '../../constants/activityStyles'; +import { buildMoneyActivityFiatLine } from '../../utils/moneyActivityFiat'; +import styleSheet from './MoneyReceivedDetails.styles'; + +export function MoneyReceivedDetails() { + const { styles } = useStyles(styleSheet, {}); + const { transactionMeta } = useTransactionDetails(); + const currentCurrency = useSelector(selectCurrentCurrency); + const currencyRates = useSelector(selectCurrencyRates); + const tokenMarketData = useSelector(selectTokenMarketData); + + const fiatAmount = buildMoneyActivityFiatLine( + transactionMeta, + currencyRates, + currentCurrency, + tokenMarketData, + ); + const tokenMeta = resolveMusdTransferMeta(transactionMeta); + const from = transactionMeta.txParams?.from; + const chainId = transactionMeta.chainId as Hex; + + return ( + + + {fiatAmount ? ( + + {fiatAmount} + + ) : null} + + + + {from ? ( + + + + ) : null} + {tokenMeta ? ( + + + + {tokenMeta.symbol} + + + ) : null} + + + ); +} diff --git a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.test.tsx b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.test.tsx new file mode 100644 index 00000000000..f0b8778ab08 --- /dev/null +++ b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { + type TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { merge } from 'lodash'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { useTransactionDetails } from '../../../../Views/confirmations/hooks/activity/useTransactionDetails'; +import { otherControllersMock } from '../../../../Views/confirmations/__mocks__/controllers/other-controllers-mock'; +import MoneyTransactionDetailsSheet from './MoneyTransactionDetailsSheet'; + +jest.mock('../../../../Views/confirmations/hooks/activity/useTransactionDetails'); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ goBack: jest.fn(), setOptions: jest.fn() }), +})); + +jest.mock( + '../../../../Views/confirmations/components/activity/transaction-details/transaction-details', + () => { + const ReactActual = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + TransactionDetails: () => + ReactActual.createElement( + Text, + { testID: 'shared-transaction-details' }, + 'shared', + ), + }; + }, +); +jest.mock('./MoneyReceivedDetails', () => { + const ReactActual = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + MoneyReceivedDetails: () => + ReactActual.createElement( + Text, + { testID: 'money-received-details' }, + 'received', + ), + }; +}); + +const CHAIN_ID_MOCK = '0x8f'; + +function render(type: TransactionType) { + jest.mocked(useTransactionDetails).mockReturnValue({ + transactionMeta: { + id: 'tx-1', + chainId: CHAIN_ID_MOCK, + type, + } as unknown as TransactionMeta, + }); + return renderWithProvider(, { + state: merge({}, otherControllersMock), + }); +} + +describe('MoneyTransactionDetailsSheet', () => { + beforeEach(() => jest.clearAllMocks()); + + it.each([ + TransactionType.incoming, + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, + ])('renders MoneyReceivedDetails for %s', (type) => { + const { getByTestId, queryByTestId } = render(type); + expect(getByTestId('money-received-details')).toBeTruthy(); + expect(queryByTestId('shared-transaction-details')).toBeNull(); + }); + + it.each([ + TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, + TransactionType.musdConversion, + ])('renders shared TransactionDetails for %s', (type) => { + const { getByTestId, queryByTestId } = render(type); + expect(getByTestId('shared-transaction-details')).toBeTruthy(); + expect(queryByTestId('money-received-details')).toBeNull(); + }); +}); diff --git a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx index 2b44b2dc26f..09d66cb94f9 100644 --- a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx +++ b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx @@ -14,12 +14,25 @@ import { import { strings } from '../../../../../../locales/i18n'; import { TransactionDetails } from '../../../../Views/confirmations/components/activity/transaction-details/transaction-details'; import { useTransactionDetails } from '../../../../Views/confirmations/hooks/activity/useTransactionDetails'; +import { MoneyReceivedDetails } from './MoneyReceivedDetails'; + +const RECEIVED_TYPES: TransactionType[] = [ + TransactionType.incoming, + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, +]; const TITLE_KEYS: Partial> = { [TransactionType.moneyAccountDeposit]: 'transaction_details.title.money_account_deposit', [TransactionType.moneyAccountWithdraw]: 'transaction_details.title.money_account_withdraw', + [TransactionType.incoming]: + 'transaction_details.title.money_account_received', + [TransactionType.tokenMethodTransfer]: + 'transaction_details.title.money_account_received', + [TransactionType.tokenMethodTransferFrom]: + 'transaction_details.title.money_account_received', [TransactionType.musdConversion]: 'transaction_details.title.musd_conversion', [TransactionType.musdClaim]: 'transaction_details.title.musd_claim', [TransactionType.perpsDeposit]: 'transaction_details.title.perps_deposit', @@ -46,6 +59,9 @@ const MoneyTransactionDetailsSheet = () => { const navigation = useNavigation(); const { transactionMeta } = useTransactionDetails(); const title = getTitle(transactionMeta); + const isReceived = Boolean( + transactionMeta?.type && RECEIVED_TYPES.includes(transactionMeta.type), + ); const handleClose = useCallback(() => { sheetRef.current?.onCloseBottomSheet(); @@ -61,7 +77,7 @@ const MoneyTransactionDetailsSheet = () => { {title} - + {isReceived ? : } ); }; diff --git a/app/components/UI/Money/constants/activityStyles.test.ts b/app/components/UI/Money/constants/activityStyles.test.ts index bd4423bd2b0..685b4cfbcb5 100644 --- a/app/components/UI/Money/constants/activityStyles.test.ts +++ b/app/components/UI/Money/constants/activityStyles.test.ts @@ -1,4 +1,5 @@ import { + CHAIN_IDS, type TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; @@ -13,9 +14,10 @@ import { jest.mock('../../../../../locales/i18n', () => ({ __esModule: true, default: { locale: 'en-US' }, + strings: (key: string) => key, })); -const MOCK_CHAIN: Hex = '0x1'; +const MOCK_CHAIN: Hex = CHAIN_IDS.MONAD; function makeTx( type: TransactionType, @@ -50,6 +52,19 @@ describe('activityStyles', () => { ).toBe('-'); }); + it('returns plus for mUSD ERC-20 transfer types (deposits into Money)', () => { + expect( + getMoneyAmountPrefixForTransactionMeta( + makeTx(TransactionType.tokenMethodTransfer), + ), + ).toBe('+'); + expect( + getMoneyAmountPrefixForTransactionMeta( + makeTx(TransactionType.tokenMethodTransferFrom), + ), + ).toBe('+'); + }); + it('returns minus for a batch tx with an outgoing nested type', () => { expect( getMoneyAmountPrefixForTransactionMeta( @@ -121,6 +136,50 @@ describe('activityStyles', () => { expect(line.startsWith('+')).toBe(true); expect(line).toContain('mUSD'); }); + + it('falls back to calldata-decoded amount for locally-signed mUSD tokenMethodTransfer', () => { + // 1,000.000000 mUSD = 1_000_000_000 in 6-decimal minimal units + const amountHex = 1_000_000_000n.toString(16).padStart(64, '0'); + const recipientHex = + '000000000000000000000000bf4bc559f929ce3994ba12d71d564737357bc8c2'; + const data = `0xa9059cbb${recipientHex}${amountHex}`; + + const tx = makeTx(TransactionType.tokenMethodTransfer, { + transferInformation: undefined, + txParams: { to: MUSD_TOKEN_ADDRESS, data } as never, + }); + + const line = getMusdDisplayAmountFromTransactionMeta(tx); + expect(line.startsWith('+')).toBe(true); + expect(line).toContain('mUSD'); + expect(line).toContain('1,000'); + }); + + it('returns empty for non-mUSD tokenMethodTransfer with no transferInformation', () => { + const tx = makeTx(TransactionType.tokenMethodTransfer, { + transferInformation: undefined, + txParams: { + to: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + data: '0xa9059cbb', + } as never, + }); + expect(getMusdDisplayAmountFromTransactionMeta(tx)).toBe(''); + }); + + it('returns empty when mUSD tokenMethodTransfer calldata has a recipient but no amount', () => { + // selector + valid recipient slot, but no amount slot — `decodeTransferData` + // returns "NaN" for the amount instead of throwing. + const recipientHex = + '000000000000000000000000bf4bc559f929ce3994ba12d71d564737357bc8c2'; + const tx = makeTx(TransactionType.tokenMethodTransfer, { + transferInformation: undefined, + txParams: { + to: MUSD_TOKEN_ADDRESS, + data: `0xa9059cbb${recipientHex}`, + } as never, + }); + expect(getMusdDisplayAmountFromTransactionMeta(tx)).toBe(''); + }); }); describe('isIncomingMoneyTransactionMeta', () => { diff --git a/app/components/UI/Money/constants/activityStyles.ts b/app/components/UI/Money/constants/activityStyles.ts index 737933d089c..89e6f94ed3a 100644 --- a/app/components/UI/Money/constants/activityStyles.ts +++ b/app/components/UI/Money/constants/activityStyles.ts @@ -5,6 +5,11 @@ import { import I18n from '../../../../../locales/i18n'; import { getIntlNumberFormatter } from '../../../../util/intl'; import { fromTokenMinimalUnit } from '../../../../util/number/bigint'; +import { + isMusdOnMoneyAccountChain, + MUSD_DECIMALS, + MUSD_TOKEN, +} from '../../Earn/constants/musd'; function formatNumber(num: number): string { return getIntlNumberFormatter(I18n.locale, { @@ -42,27 +47,123 @@ export function getMoneyAmountPrefixForTransactionMeta( return '+'; } +// `0x` + 8 hex chars selector + 64 hex chars (address) + 64 hex chars (uint256). +const ERC20_TRANSFER_CALLDATA_LENGTH = 138; +// `0x` + 8 hex chars selector + 3 × 64 hex chars (from, to, uint256). +const ERC20_TRANSFER_FROM_CALLDATA_LENGTH = 202; +// Slot offsets into calldata (chars). 10 = `0x` + 8-char selector; each slot +// is 64 chars (32 bytes). transfer: [recipient, amount]. transferFrom: +// [from, to, amount]. +const TRANSFER_AMOUNT_START = 10 + 64; +const TRANSFER_AMOUNT_END = 10 + 64 + 64; +const TRANSFER_FROM_AMOUNT_START = 10 + 64 + 64; +const TRANSFER_FROM_AMOUNT_END = 10 + 64 + 64 + 64; + +/** + * Decoded ERC-20 transfer amount from `txParams.data` (decimal string), or + * `undefined` if calldata is missing/truncated/non-hex. We slice the uint256 + * slot ourselves and parse with `BigInt` to avoid the precision loss in + * `decodeTransferData`'s `parseInt(slot, 16)` (which truncates above 2^53). + */ +function getErc20TransferAmount(tx: TransactionMeta): string | undefined { + const data = tx.txParams?.data; + if (!data) return undefined; + let slot: string | undefined; + if ( + tx.type === EvmTransactionType.tokenMethodTransfer && + data.length >= ERC20_TRANSFER_CALLDATA_LENGTH + ) { + slot = data.substring(TRANSFER_AMOUNT_START, TRANSFER_AMOUNT_END); + } else if ( + tx.type === EvmTransactionType.tokenMethodTransferFrom && + data.length >= ERC20_TRANSFER_FROM_CALLDATA_LENGTH + ) { + slot = data.substring(TRANSFER_FROM_AMOUNT_START, TRANSFER_FROM_AMOUNT_END); + } + if (!slot) return undefined; + try { + return BigInt(`0x${slot}`).toString(); + } catch { + return undefined; + } +} + +export interface ResolvedMusdTransferMeta { + amount: string; + decimals: number; + symbol: string; + contractAddress: string; +} + +/** + * Resolves the token metadata for an mUSD `transfer`/`transferFrom`/`incoming` + * row on a Money Account chain, preferring `transferInformation` (set by + * incoming polling + the standard send flow) and falling back to decoded + * calldata + known mUSD constants for locally-signed rows where + * `transferInformation` is not yet populated. Returns `undefined` when the + * row isn't mUSD on a Money Account chain or the calldata is malformed. + * + * Enforced precondition matters even though current callers pre-filter — the + * name promises "mUSD" semantics and downstream code (e.g. the peg-fiat path) + * must not be applied to other tokens. + */ +export function resolveMusdTransferMeta( + tx: TransactionMeta, +): ResolvedMusdTransferMeta | undefined { + const ti = tx.transferInformation; + let amount = ti?.amount; + let decimals = ti?.decimals; + let symbol = ti?.symbol; + let contractAddress = ti?.contractAddress; + + const isErc20TransferType = + tx.type === EvmTransactionType.tokenMethodTransfer || + tx.type === EvmTransactionType.tokenMethodTransferFrom; + + if ( + (!amount || !symbol || decimals === undefined || !contractAddress) && + isErc20TransferType && + isMusdOnMoneyAccountChain(tx.txParams?.to, tx.chainId) + ) { + amount = amount ?? getErc20TransferAmount(tx); + decimals = decimals ?? MUSD_DECIMALS; + symbol = symbol ?? MUSD_TOKEN.symbol; + contractAddress = contractAddress ?? tx.txParams?.to; + } + + if (!amount || !symbol || decimals === undefined || !contractAddress) { + return undefined; + } + if (!isMusdOnMoneyAccountChain(contractAddress, tx.chainId)) { + return undefined; + } + return { amount, decimals, symbol, contractAddress }; +} + /** - * Formatted token amount from `transferInformation`, e.g. "+1,000.00 mUSD". + * Formatted token amount, e.g. "+1,000.00 mUSD". See {@link resolveMusdTransferMeta}. */ export function getMusdDisplayAmountFromTransactionMeta( tx: TransactionMeta, ): string { - const ti = tx.transferInformation; - if (!ti?.amount || !ti.symbol) return ''; - if (ti.decimals === undefined) return ''; - const humanReadable = fromTokenMinimalUnit(ti.amount, ti.decimals); + const meta = resolveMusdTransferMeta(tx); + if (!meta) return ''; + // `isRounding = false` keeps the BigInt-decoded amount precise — the default + // `Number()` cast would lose precision for amounts above 2^53 minimal units. + const humanReadable = fromTokenMinimalUnit(meta.amount, meta.decimals, false); const num = parseFloat(humanReadable); if (isNaN(num)) return ''; const prefix = getMoneyAmountPrefixForTransactionMeta(tx); - return `${prefix}${formatNumber(num)} ${ti.symbol}`; + return `${prefix}${formatNumber(num)} ${meta.symbol}`; } export function isIncomingMoneyTransactionMeta(tx: TransactionMeta): boolean { const t = tx.type; if ( t === EvmTransactionType.incoming || - t === EvmTransactionType.moneyAccountDeposit + t === EvmTransactionType.moneyAccountDeposit || + t === EvmTransactionType.tokenMethodTransfer || + t === EvmTransactionType.tokenMethodTransferFrom ) { return true; } diff --git a/app/components/UI/Money/constants/moneyActivityFilters.test.ts b/app/components/UI/Money/constants/moneyActivityFilters.test.ts index 851802eab03..8d4c6071184 100644 --- a/app/components/UI/Money/constants/moneyActivityFilters.test.ts +++ b/app/components/UI/Money/constants/moneyActivityFilters.test.ts @@ -1,16 +1,20 @@ import { + CHAIN_IDS, type TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; +import { MUSD_TOKEN_ADDRESS } from '../../Earn/constants/musd'; import { getMoneyActivityDateKeyUtc, isMoneyActivityDeposit, isMoneyActivityTransaction, isMoneyActivityTransfer, + isMusdErc20Transfer, } from './moneyActivityFilters'; -const MOCK_CHAIN: Hex = '0x1'; +const MOCK_CHAIN: Hex = CHAIN_IDS.MONAD; +const OTHER_ERC20: Hex = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; function tx(overrides: Partial): TransactionMeta { return { @@ -21,6 +25,17 @@ function tx(overrides: Partial): TransactionMeta { } as TransactionMeta; } +function transferInfo( + contractAddress: Hex, +): NonNullable { + return { + amount: '1000000', + decimals: 6, + symbol: 'mUSD', + contractAddress, + }; +} + describe('moneyActivityFilters', () => { describe('isMoneyActivityDeposit', () => { it('returns true for incoming and moneyAccountDeposit', () => { @@ -66,6 +81,28 @@ describe('moneyActivityFilters', () => { false, ); }); + + it('returns true for an mUSD tokenMethodTransfer', () => { + expect( + isMoneyActivityDeposit( + tx({ + type: TransactionType.tokenMethodTransfer, + transferInformation: transferInfo(MUSD_TOKEN_ADDRESS), + }), + ), + ).toBe(true); + }); + + it('returns false for a non-mUSD tokenMethodTransfer', () => { + expect( + isMoneyActivityDeposit( + tx({ + type: TransactionType.tokenMethodTransfer, + transferInformation: transferInfo(OTHER_ERC20), + }), + ), + ).toBe(false); + }); }); describe('isMoneyActivityTransfer', () => { @@ -100,6 +137,109 @@ describe('moneyActivityFilters', () => { ), ).toBe(false); }); + + it('returns false for an mUSD tokenMethodTransfer (now a deposit)', () => { + expect( + isMoneyActivityTransfer( + tx({ + type: TransactionType.tokenMethodTransfer, + transferInformation: transferInfo(MUSD_TOKEN_ADDRESS), + }), + ), + ).toBe(false); + }); + }); + + describe('isMusdErc20Transfer', () => { + it('returns true for tokenMethodTransfer with mUSD contract', () => { + expect( + isMusdErc20Transfer( + tx({ + type: TransactionType.tokenMethodTransfer, + transferInformation: transferInfo(MUSD_TOKEN_ADDRESS), + }), + ), + ).toBe(true); + }); + + it('returns true for tokenMethodTransferFrom with mUSD contract', () => { + expect( + isMusdErc20Transfer( + tx({ + type: TransactionType.tokenMethodTransferFrom, + transferInformation: transferInfo(MUSD_TOKEN_ADDRESS), + }), + ), + ).toBe(true); + }); + + it('returns false when contract address is a different ERC-20', () => { + expect( + isMusdErc20Transfer( + tx({ + type: TransactionType.tokenMethodTransfer, + transferInformation: transferInfo(OTHER_ERC20), + }), + ), + ).toBe(false); + }); + + it('returns false for non-ERC-20-transfer types', () => { + expect( + isMusdErc20Transfer( + tx({ + type: TransactionType.moneyAccountWithdraw, + transferInformation: transferInfo(MUSD_TOKEN_ADDRESS), + }), + ), + ).toBe(false); + }); + + it('returns true when transferInformation is missing but txParams.to is mUSD', () => { + expect( + isMusdErc20Transfer( + tx({ + type: TransactionType.tokenMethodTransfer, + txParams: { to: MUSD_TOKEN_ADDRESS } as never, + }), + ), + ).toBe(true); + }); + + it('returns false when both transferInformation and txParams.to are missing', () => { + expect( + isMusdErc20Transfer(tx({ type: TransactionType.tokenMethodTransfer })), + ).toBe(false); + }); + + it('returns false on chains where mUSD exists but the Money Account does not (Mainnet/Linea/BSC) — ticket AC excludes non-Monad', () => { + const nonMoneyChains: Hex[] = [ + CHAIN_IDS.MAINNET, + CHAIN_IDS.LINEA_MAINNET, + CHAIN_IDS.BSC, + '0x89', // Polygon — mUSD not deployed at all + ]; + for (const chainId of nonMoneyChains) { + expect( + isMusdErc20Transfer( + tx({ + chainId, + type: TransactionType.tokenMethodTransfer, + transferInformation: transferInfo(MUSD_TOKEN_ADDRESS), + }), + ), + ).toBe(false); + expect( + isMusdErc20Transfer( + tx({ + chainId, + type: TransactionType.tokenMethodTransfer, + txParams: { to: MUSD_TOKEN_ADDRESS } as never, + }), + ), + ).toBe(false); + } + }); }); describe('isMoneyActivityTransaction', () => { diff --git a/app/components/UI/Money/constants/moneyActivityFilters.ts b/app/components/UI/Money/constants/moneyActivityFilters.ts index 30625a9f67f..cc84a7c9303 100644 --- a/app/components/UI/Money/constants/moneyActivityFilters.ts +++ b/app/components/UI/Money/constants/moneyActivityFilters.ts @@ -2,12 +2,37 @@ import { type TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; +import { isMusdOnMoneyAccountChain } from '../../Earn/constants/musd'; + +const ERC20_TRANSFER_TYPES: TransactionType[] = [ + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, +]; + +/** + * True when the transaction is an ERC-20 transfer of mUSD on a chain where + * the Money Account is active (currently Monad only — see + * {@link MUSD_MONEY_ACCOUNT_CHAIN_IDS}). `transferInformation` is only + * populated by incoming-transaction polling; for locally-signed sends we + * fall back to `txParams.to`, which for ERC-20 transfer types is always the + * token contract. + */ +export function isMusdErc20Transfer(tx: TransactionMeta): boolean { + if (!tx.type || !ERC20_TRANSFER_TYPES.includes(tx.type)) return false; + return ( + isMusdOnMoneyAccountChain( + tx.transferInformation?.contractAddress, + tx.chainId, + ) || isMusdOnMoneyAccountChain(tx.txParams?.to, tx.chainId) + ); +} export function isMoneyActivityDeposit(tx: TransactionMeta): boolean { const t = tx.type; if ( t === TransactionType.incoming || - t === TransactionType.moneyAccountDeposit + t === TransactionType.moneyAccountDeposit || + isMusdErc20Transfer(tx) ) { return true; } diff --git a/app/components/UI/Money/hooks/useMoneyAccountTransactions.test.tsx b/app/components/UI/Money/hooks/useMoneyAccountTransactions.test.tsx index d320e7cfc74..96cb9fec7bd 100644 --- a/app/components/UI/Money/hooks/useMoneyAccountTransactions.test.tsx +++ b/app/components/UI/Money/hooks/useMoneyAccountTransactions.test.tsx @@ -1,10 +1,13 @@ import type { MoneyAccount } from '@metamask/money-account-controller'; import { MONEY_DERIVATION_PATH } from '@metamask/eth-money-keyring'; import { + CHAIN_IDS, TransactionStatus, TransactionType, type TransactionMeta, } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { MUSD_TOKEN_ADDRESS } from '../../Earn/constants/musd'; import { renderHookWithProvider, type ProviderValues, @@ -37,6 +40,60 @@ const MOCK_MONEY_ACCOUNT: MoneyAccount = { }, }; +const MONEY_ADDRESS = MOCK_MONEY_ACCOUNT.address as Hex; +const OTHER_ADDRESS: Hex = '0x0000000000000000000000000000000000000def'; +const OTHER_ERC20: Hex = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + +const ERC20_TRANSFER_SELECTOR = '0xa9059cbb'; +const ERC20_TRANSFER_FROM_SELECTOR = '0x23b872dd'; + +function padAddress(addr: string): string { + return addr.replace(/^0x/, '').toLowerCase().padStart(64, '0'); +} + +function padAmount(amount: bigint): string { + return amount.toString(16).padStart(64, '0'); +} + +function makeTransferCalldata(recipient: string, amount = 1_000_000n): string { + return ERC20_TRANSFER_SELECTOR + padAddress(recipient) + padAmount(amount); +} + +function makeTransferFromCalldata( + from: string, + to: string, + amount = 1_000_000n, +): string { + return ( + ERC20_TRANSFER_FROM_SELECTOR + + padAddress(from) + + padAddress(to) + + padAmount(amount) + ); +} + +function musdTransferInfo(): NonNullable< + TransactionMeta['transferInformation'] +> { + return { + amount: '1000000', + decimals: 6, + symbol: 'mUSD', + contractAddress: MUSD_TOKEN_ADDRESS, + }; +} + +function otherTransferInfo(): NonNullable< + TransactionMeta['transferInformation'] +> { + return { + amount: '1000000', + decimals: 6, + symbol: 'USDC', + contractAddress: OTHER_ERC20, + }; +} + const MOCK_MONEY_ACCOUNTS = { [MOCK_MONEY_ACCOUNT.id]: MOCK_MONEY_ACCOUNT, }; @@ -72,7 +129,7 @@ function makeTx( ): Partial { return { id: `tx-${type}`, - chainId: '0x1', + chainId: CHAIN_IDS.MONAD, type, status: TransactionStatus.confirmed, time: Date.now(), @@ -207,6 +264,233 @@ describe('useMoneyAccountTransactions', () => { expect(result.current.allTransactions).toHaveLength(0); }); + it('includes inbound mUSD landing at the money account', () => { + const tx = makeTx(TransactionType.incoming, { + txParams: { from: OTHER_ADDRESS, to: MONEY_ADDRESS } as never, + transferInformation: musdTransferInfo(), + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(1); + expect(result.current.deposits).toHaveLength(1); + }); + + it('excludes inbound mUSD on non-Monad chains (ticket: non-Monad transactions are not shown)', () => { + const nonMoneyChains = [ + CHAIN_IDS.MAINNET, + CHAIN_IDS.LINEA_MAINNET, + CHAIN_IDS.BSC, + ]; + for (const chainId of nonMoneyChains) { + const tx = makeTx(TransactionType.incoming, { + chainId, + txParams: { from: OTHER_ADDRESS, to: MONEY_ADDRESS } as never, + transferInformation: musdTransferInfo(), + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(0); + } + }); + + it('excludes inbound mUSD landing at a non-money address', () => { + const tx = makeTx(TransactionType.incoming, { + txParams: { from: OTHER_ADDRESS, to: OTHER_ADDRESS } as never, + transferInformation: musdTransferInfo(), + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(0); + }); + + it('excludes inbound incoming transfers of non-mUSD ERC-20s', () => { + const tx = makeTx(TransactionType.incoming, { + txParams: { from: OTHER_ADDRESS, to: MONEY_ADDRESS } as never, + transferInformation: otherTransferInfo(), + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(0); + }); + + it('includes mUSD tokenMethodTransfer whose decoded recipient is the money account', () => { + const tx = makeTx(TransactionType.tokenMethodTransfer, { + txParams: { + from: OTHER_ADDRESS, + to: MUSD_TOKEN_ADDRESS, + data: makeTransferCalldata(MONEY_ADDRESS), + } as never, + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(1); + expect(result.current.deposits).toHaveLength(1); + }); + + it('excludes mUSD tokenMethodTransfer whose decoded recipient is not the money account', () => { + const tx = makeTx(TransactionType.tokenMethodTransfer, { + txParams: { + from: OTHER_ADDRESS, + to: MUSD_TOKEN_ADDRESS, + data: makeTransferCalldata(OTHER_ADDRESS), + } as never, + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(0); + }); + + it('excludes non-mUSD tokenMethodTransfer even when the call recipient is the money account', () => { + const tx = makeTx(TransactionType.tokenMethodTransfer, { + txParams: { + from: OTHER_ADDRESS, + to: OTHER_ERC20, + data: makeTransferCalldata(MONEY_ADDRESS), + } as never, + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(0); + }); + + it('excludes mUSD tokenMethodTransfer with malformed calldata', () => { + const tx = makeTx(TransactionType.tokenMethodTransfer, { + txParams: { + from: OTHER_ADDRESS, + to: MUSD_TOKEN_ADDRESS, + data: '0xa9059cbb', // selector only, no recipient/amount + } as never, + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(0); + }); + + it('includes mUSD tokenMethodTransferFrom whose decoded recipient is the money account', () => { + const tx = makeTx(TransactionType.tokenMethodTransferFrom, { + txParams: { + from: OTHER_ADDRESS, + to: MUSD_TOKEN_ADDRESS, + data: makeTransferFromCalldata(OTHER_ADDRESS, MONEY_ADDRESS), + } as never, + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(1); + expect(result.current.deposits).toHaveLength(1); + }); + + it('excludes mUSD tokenMethodTransferFrom whose decoded recipient is not the money account', () => { + const tx = makeTx(TransactionType.tokenMethodTransferFrom, { + txParams: { + from: OTHER_ADDRESS, + to: MUSD_TOKEN_ADDRESS, + data: makeTransferFromCalldata(OTHER_ADDRESS, OTHER_ADDRESS), + } as never, + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(0); + }); + + it('excludes mUSD tokenMethodTransfer with recipient but missing amount slot', () => { + // Recipient decodes to the money account, but amount slot is absent — + // we must not include this row (would otherwise render with NaN amount). + const tx = makeTx(TransactionType.tokenMethodTransfer, { + txParams: { + from: OTHER_ADDRESS, + to: MUSD_TOKEN_ADDRESS, + data: `0xa9059cbb${padAddress(MONEY_ADDRESS)}`, + } as never, + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(0); + }); + + describe.each([ + TransactionStatus.unapproved, + TransactionStatus.approved, + TransactionStatus.signed, + TransactionStatus.rejected, + TransactionStatus.dropped, + TransactionStatus.cancelled, + ])('mid-compose / aborted status %s', (status) => { + it('is excluded for inbound mUSD `incoming` rows', () => { + const tx = makeTx(TransactionType.incoming, { + status, + txParams: { from: OTHER_ADDRESS, to: MONEY_ADDRESS } as never, + transferInformation: musdTransferInfo(), + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(0); + }); + + it('is excluded for locally-signed mUSD `tokenMethodTransfer` rows', () => { + const tx = makeTx(TransactionType.tokenMethodTransfer, { + status, + txParams: { + from: OTHER_ADDRESS, + to: MUSD_TOKEN_ADDRESS, + data: makeTransferCalldata(MONEY_ADDRESS), + } as never, + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(0); + }); + }); + + it.each([ + TransactionStatus.confirmed, + TransactionStatus.submitted, + TransactionStatus.failed, + ])( + 'includes locally-signed mUSD `tokenMethodTransfer` with visible status %s', + (status) => { + const tx = makeTx(TransactionType.tokenMethodTransfer, { + status, + txParams: { + from: OTHER_ADDRESS, + to: MUSD_TOKEN_ADDRESS, + data: makeTransferCalldata(MONEY_ADDRESS), + } as never, + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + expect(result.current.allTransactions).toHaveLength(1); + }, + ); + it('sorts correctly when one transaction has an undefined time (covers ?? 0 fallback)', () => { const older = makeTx(TransactionType.moneyAccountDeposit, { id: 'tx-older', diff --git a/app/components/UI/Money/hooks/useMoneyAccountTransactions.ts b/app/components/UI/Money/hooks/useMoneyAccountTransactions.ts index 58e70d5c8ac..e337d16245b 100644 --- a/app/components/UI/Money/hooks/useMoneyAccountTransactions.ts +++ b/app/components/UI/Money/hooks/useMoneyAccountTransactions.ts @@ -12,8 +12,65 @@ import MOCK_MONEY_TRANSACTIONS from '../constants/mockActivityData'; import { isMoneyActivityDeposit, isMoneyActivityTransfer, + isMusdErc20Transfer, } from '../constants/moneyActivityFilters'; import { selectNonReplacedTransactions } from '../../../../selectors/transactionController'; +import { areAddressesEqual } from '../../../../util/address'; +import { decodeTransferData } from '../../../../util/transactions'; +import { isMusdOnMoneyAccountChain } from '../../Earn/constants/musd'; + +// `0x` + 8 hex chars selector + 64 hex chars (address) + 64 hex chars (uint256). +const ERC20_TRANSFER_CALLDATA_LENGTH = 138; +// `0x` + 8 hex chars selector + 3 × 64 hex chars (from, to, uint256). +const ERC20_TRANSFER_FROM_CALLDATA_LENGTH = 202; + +// Statuses that should surface in money activity. `unapproved`/`approved`/ +// `signed` are mid-compose and shouldn't appear yet; `rejected`/`dropped`/ +// `cancelled` were explicitly aborted and would mislead as "Received". +const VISIBLE_ACTIVITY_STATUSES: TransactionStatus[] = [ + TransactionStatus.confirmed, + TransactionStatus.submitted, + TransactionStatus.failed, +]; + +function hasVisibleStatus(tx: TransactionMeta): boolean { + return VISIBLE_ACTIVITY_STATUSES.includes(tx.status); +} + +/** + * Extracts the call's recipient from ERC-20 `transfer`/`transferFrom` calldata. + * For both types, `txParams.to` is the token contract, not the recipient — the + * recipient must be decoded from the calldata. Returns `undefined` if the + * calldata is missing or truncated; `decodeTransferData` does not throw on + * short input, so length must be checked. + */ +function getErc20TransferRecipient(tx: TransactionMeta): string | undefined { + const data = tx.txParams?.data; + if (!data) return undefined; + try { + if ( + tx.type === TransactionType.tokenMethodTransfer && + data.length >= ERC20_TRANSFER_CALLDATA_LENGTH + ) { + const [recipient] = decodeTransferData('transfer', data) as string[]; + return recipient; + } + if ( + tx.type === TransactionType.tokenMethodTransferFrom && + data.length >= ERC20_TRANSFER_FROM_CALLDATA_LENGTH + ) { + // transferFrom(address from, address to, uint256 amount) → recipient at [1]. + const [, recipient] = decodeTransferData( + 'transferFrom', + data, + ) as string[]; + return recipient; + } + return undefined; + } catch { + return undefined; + } +} export interface UseMoneyAccountTransactionsResult { /** Confirmed + submitted (filtered) merged, sorted by time descending */ @@ -68,13 +125,45 @@ export function useMoneyAccountTransactions(): UseMoneyAccountTransactionsResult return true; } // EIP-7702 batch where a Money account call is a nested call. - return ( + if ( tx.nestedTransactions?.some( (nested) => nested.type === TransactionType.moneyAccountDeposit || nested.type === TransactionType.moneyAccountWithdraw, - ) ?? false - ); + ) + ) { + return true; + } + if (moneyAddress === undefined) return false; + // The inbound-mUSD and locally-signed mUSD branches below must skip + // mid-compose and explicitly-aborted rows, which would otherwise + // render as phantom "Received +X mUSD" entries. + if (!hasVisibleStatus(tx)) return false; + // Inbound mUSD landing at the money account (from incoming-transaction + // polling — `transferInformation.contractAddress` is the token, and + // `txParams.to` is the recipient). + if ( + tx.type === TransactionType.incoming && + isMusdOnMoneyAccountChain( + tx.transferInformation?.contractAddress, + tx.chainId, + ) && + areAddressesEqual(tx.txParams?.to ?? '', moneyAddress) + ) { + return true; + } + // Locally-signed `transfer`/`transferFrom` of mUSD whose call recipient + // is the money account (e.g. user's EOA depositing into Money). + if (isMusdErc20Transfer(tx)) { + const recipient = getErc20TransferRecipient(tx); + if ( + recipient !== undefined && + areAddressesEqual(recipient, moneyAddress) + ) { + return true; + } + } + return false; }) .sort((a, b) => (b?.time ?? 0) - (a?.time ?? 0)); diff --git a/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.ts b/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.ts index 1901f920585..c93e01164a6 100644 --- a/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.ts +++ b/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.ts @@ -141,13 +141,13 @@ describe('useMoneyTransactionDisplayInfo — getLabelForTransactionType', () => expect(result.current.label).toBe('money.transaction.deposited'); }); - it('returns deposited for incoming type', () => { + it('returns received for incoming type (external mUSD arriving at the money account)', () => { const tx = makeTx(TransactionType.incoming); const { result } = renderHookWithProvider( () => useMoneyTransactionDisplayInfo(tx, undefined), { state: makeState() }, ); - expect(result.current.label).toBe('money.transaction.deposited'); + expect(result.current.label).toBe('money.transaction.received'); }); it('returns sent for moneyAccountWithdraw type', () => { @@ -168,6 +168,24 @@ describe('useMoneyTransactionDisplayInfo — getLabelForTransactionType', () => expect(result.current.label).toBe('money.transaction.sent'); }); + it('returns received for tokenMethodTransfer type', () => { + const tx = makeTx(TransactionType.tokenMethodTransfer); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.label).toBe('money.transaction.received'); + }); + + it('returns received for tokenMethodTransferFrom type', () => { + const tx = makeTx(TransactionType.tokenMethodTransferFrom); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.label).toBe('money.transaction.received'); + }); + it('returns converted for musdConversion type', () => { const tx = makeTx(TransactionType.musdConversion); const { result } = renderHookWithProvider( @@ -312,6 +330,24 @@ describe('useMoneyTransactionDisplayInfo — getIconForTransactionType', () => { expect(result.current.icon).toBe(IconName.Arrow2UpRight); }); + it('returns Arrow2Down for tokenMethodTransfer type', () => { + const tx = makeTx(TransactionType.tokenMethodTransfer); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.icon).toBe(IconName.Arrow2Down); + }); + + it('returns Arrow2Down for tokenMethodTransferFrom type', () => { + const tx = makeTx(TransactionType.tokenMethodTransferFrom); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState() }, + ); + expect(result.current.icon).toBe(IconName.Arrow2Down); + }); + it('returns Arrow2Down (default) for unrecognised type', () => { const tx = makeTx(TransactionType.swap); const { result } = renderHookWithProvider( @@ -606,15 +642,19 @@ describe('useMoneyTransactionDisplayInfo — description', () => { }); // --------------------------------------------------------------------------- -// Fiat formatting — mUSD via market rate +// Fiat formatting — mUSD pegged via the Money Account chain (Monad) // --------------------------------------------------------------------------- const MUSD_CHECKSUM = safeToChecksumAddress(MUSD_TOKEN_ADDRESS) as string; +// mUSD activity is gated to the Money Account chain (Monad). `CHAIN_IDS.MONAD` +// would be cleaner but we keep the literal here to avoid pulling the constants +// import into this large test file. +const MUSD_CHAIN_ID: Hex = '0x8f'; const musedTx: TransactionMeta = { id: 'tx-musd', type: TransactionType.incoming, - chainId: CHAIN_ID, + chainId: MUSD_CHAIN_ID, transferInformation: { amount: '1000000000', symbol: 'mUSD', @@ -639,7 +679,7 @@ function musedMarketState(tokenPrice: number) { }, TokenRatesController: { marketData: { - [CHAIN_ID]: { + [MUSD_CHAIN_ID]: { [MUSD_CHECKSUM]: { price: tokenPrice }, }, }, @@ -647,7 +687,7 @@ function musedMarketState(tokenPrice: number) { TokensController: { allTokens: {} }, NetworkController: { networkConfigurationsByChainId: { - [CHAIN_ID]: { nativeCurrency: 'ETH' }, + [MUSD_CHAIN_ID]: { nativeCurrency: 'ETH' }, }, }, }, @@ -656,7 +696,7 @@ function musedMarketState(tokenPrice: number) { } describe('useMoneyTransactionDisplayInfo — mUSD fiat formatting', () => { - it('formats fiat in USD via market rate', () => { + it('formats fiat in USD via the peg (ignoring market rate)', () => { const { result } = renderHookWithProvider( () => useMoneyTransactionDisplayInfo(musedTx, undefined), { state: musedMarketState(1 / 3000) }, @@ -668,7 +708,7 @@ describe('useMoneyTransactionDisplayInfo — mUSD fiat formatting', () => { expect(result.current.primaryAmount).toContain('mUSD'); }); - it('uses market rate and ETH→fiat conversion for non-USD currencies', () => { + it('converts mUSD to EUR via the peg (ignoring market rate)', () => { const state = { engine: { backgroundState: { @@ -684,7 +724,7 @@ describe('useMoneyTransactionDisplayInfo — mUSD fiat formatting', () => { }, TokenRatesController: { marketData: { - [CHAIN_ID]: { + [MUSD_CHAIN_ID]: { [MUSD_CHECKSUM]: { price: 0.0004 }, }, }, @@ -692,7 +732,7 @@ describe('useMoneyTransactionDisplayInfo — mUSD fiat formatting', () => { TokensController: { allTokens: {} }, NetworkController: { networkConfigurationsByChainId: { - [CHAIN_ID]: { nativeCurrency: 'ETH' }, + [MUSD_CHAIN_ID]: { nativeCurrency: 'ETH' }, }, }, }, diff --git a/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.ts b/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.ts index d898edc3619..c31857be405 100644 --- a/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.ts +++ b/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.ts @@ -67,8 +67,11 @@ function getLabelForTransactionType(type: TransactionType | undefined): string { } switch (type) { case TransactionType.moneyAccountDeposit: - case TransactionType.incoming: return strings('money.transaction.deposited'); + case TransactionType.incoming: + case TransactionType.tokenMethodTransfer: + case TransactionType.tokenMethodTransferFrom: + return strings('money.transaction.received'); case TransactionType.moneyAccountWithdraw: case TransactionType.simpleSend: return strings('money.transaction.sent'); @@ -135,6 +138,8 @@ function getIconForTransactionType( case TransactionType.moneyAccountDeposit: return IconName.Add; case TransactionType.incoming: + case TransactionType.tokenMethodTransfer: + case TransactionType.tokenMethodTransferFrom: return IconName.Arrow2Down; case TransactionType.musdConversion: return IconName.Refresh; diff --git a/app/components/UI/Money/utils/moneyActivityFiat.test.ts b/app/components/UI/Money/utils/moneyActivityFiat.test.ts index 01f2b245c72..dc4dd0032a8 100644 --- a/app/components/UI/Money/utils/moneyActivityFiat.test.ts +++ b/app/components/UI/Money/utils/moneyActivityFiat.test.ts @@ -1,4 +1,5 @@ import { + CHAIN_IDS, type TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; @@ -8,7 +9,7 @@ import { buildMoneyActivityFiatLine } from './moneyActivityFiat'; import { getMusdDisplayAmountFromTransactionMeta } from '../constants/activityStyles'; import { MUSD_TOKEN_ADDRESS } from '../../Earn/constants/musd'; -const MOCK_CHAIN_ID = '0x1' as Hex; +const MOCK_CHAIN_ID = CHAIN_IDS.MONAD as Hex; /** Non-mUSD ERC-20 address for tests that need a token without mUSD fallback. */ const OTHER_TOKEN_CONTRACT = '0x00000000000000000000000000000000000000aa'; @@ -64,7 +65,7 @@ function makeIncomingTx( describe('moneyActivityFiat', () => { describe('buildMoneyActivityFiatLine', () => { - it('prefixes with + and formats fiat in USD using market rate', () => { + it('prefixes mUSD deposits with + and formats fiat in USD via the peg', () => { const tx = makeIncomingTx('1000000000'); const line = buildMoneyActivityFiatLine( @@ -92,7 +93,7 @@ describe('moneyActivityFiat', () => { expect(line).toMatch(/^-/); }); - it('converts to EUR using token→ETH price and ETH→fiat rate', () => { + it('converts mUSD to EUR via the peg (ignoring market data)', () => { const tx = makeIncomingTx('1000000000'); const line = buildMoneyActivityFiatLine( @@ -173,6 +174,49 @@ describe('moneyActivityFiat', () => { expect(line).toBe(''); }); + + it('ignores wrong mUSD market data and uses the peg', () => { + // Reproduces the Monad case: backend reports an absurd + // tokenToEth price for mUSD. We must not propagate that into fiat. + const wrongMarket = { + [MOCK_CHAIN_ID]: { + [checksumMusdToken]: { price: 37.71 }, + }, + }; + // 0.50 mUSD = 500_000 in 6-decimal minimal units. + const tx = makeIncomingTx('500000'); + + const line = buildMoneyActivityFiatLine( + tx, + mockRates, + 'usd', + wrongMarket, + ); + + // Pegged to USD: 0.50 mUSD ≈ $0.50, not the market-data-derived ~$39,977. + expect(line).toMatch(/^\+.*0\.50/); + expect(line).not.toMatch(/39,977/); + }); + + it('falls back to calldata-decoded amount for locally-signed mUSD tokenMethodTransfer without transferInformation', () => { + // 1,000.000000 mUSD = 1_000_000_000 in 6-decimal minimal units. + const amountHex = 1_000_000_000n.toString(16).padStart(64, '0'); + const recipientHex = + '000000000000000000000000bf4bc559f929ce3994ba12d71d564737357bc8c2'; + const tx = { + id: '1', + chainId: MOCK_CHAIN_ID, + type: TransactionType.tokenMethodTransfer, + txParams: { + to: MUSD_TOKEN_ADDRESS, + data: `0xa9059cbb${recipientHex}${amountHex}`, + }, + } as unknown as TransactionMeta; + + const line = buildMoneyActivityFiatLine(tx, mockRates, 'usd', {}); + + expect(line).toMatch(/^\+.*1,000\.00/); + }); }); describe('token amount from raw minimal units', () => { diff --git a/app/components/UI/Money/utils/moneyActivityFiat.ts b/app/components/UI/Money/utils/moneyActivityFiat.ts index db067a125a0..655c0fac43a 100644 --- a/app/components/UI/Money/utils/moneyActivityFiat.ts +++ b/app/components/UI/Money/utils/moneyActivityFiat.ts @@ -4,12 +4,13 @@ import type { Hex } from '@metamask/utils'; import BigNumber from 'bignumber.js'; import { safeToChecksumAddress } from '../../../../util/address'; import { moneyFormatFiat } from './moneyFormatFiat'; -import { - balanceToFiatNumber, - fromTokenMinimalUnit, -} from '../../../../util/number'; +import { balanceToFiatNumber } from '../../../../util/number'; +import { fromTokenMinimalUnit } from '../../../../util/number/bigint'; import { isMusdToken } from '../../Earn/constants/musd'; -import { getMoneyAmountPrefixForTransactionMeta } from '../constants/activityStyles'; +import { + getMoneyAmountPrefixForTransactionMeta, + resolveMusdTransferMeta, +} from '../constants/activityStyles'; import { ETH_TICKER } from '../constants/moneyTokens'; export type CurrencyRatesMap = NonNullable; @@ -146,15 +147,17 @@ export function buildMoneyActivityFiatLine( currentCurrency: string | undefined, tokenMarketData: TokenMarketDataMap | undefined, ): string { - const ti = tx.transferInformation; - if (!ti?.amount || !ti.contractAddress || ti.decimals === undefined) { + const meta = resolveMusdTransferMeta(tx); + if (!meta) { return ''; } if (!currentCurrency) { return ''; } - const humanReadable = fromTokenMinimalUnit(ti.amount, ti.decimals); + // `isRounding = false` keeps the BigInt-decoded amount precise — the default + // `Number()` cast would lose precision for amounts above 2^53 minimal units. + const humanReadable = fromTokenMinimalUnit(meta.amount, meta.decimals, false); const humanAmount = parseFloat(humanReadable); if (Number.isNaN(humanAmount)) { return ''; @@ -165,23 +168,41 @@ export function buildMoneyActivityFiatLine( return ''; } - if (!safeToChecksumAddress(ti.contractAddress as Hex)) { + if (!safeToChecksumAddress(meta.contractAddress as Hex)) { return ''; } const prefix = getMoneyAmountPrefixForTransactionMeta(tx); const absAmount = Math.abs(humanAmount); + const isMusdLike = isMusdLikeForFiatFallback( + meta.contractAddress, + meta.symbol, + ); const tokenToEthRate = getTokenToEthPrice( tokenMarketData, chainId, - ti.contractAddress, + meta.contractAddress, ); const ethToFiatRate = getEthToFiatConversionRate(currencyRates); let fiatNumber: number | undefined; - if ( + // mUSD is pegged 1:1 to USD by design — `tokenMarketData` has been observed + // to report wildly wrong prices for it on some chains, so we always derive + // fiat from the peg and never trust the market-rate path for mUSD. + if (isMusdLike) { + const rateEntry = resolveCurrencyRateEntry(currencyRates, ETH_TICKER); + if (rateEntry !== undefined && rateEntry.usdConversionRate !== 0) { + const tokenToEthPricePeg = 1 / rateEntry.usdConversionRate; + fiatNumber = balanceToFiatNumber( + absAmount, + rateEntry.conversionRate, + tokenToEthPricePeg, + 2, + ); + } + } else if ( tokenToEthRate !== undefined && tokenToEthRate !== null && tokenToEthRate !== 0 && @@ -193,19 +214,6 @@ export function buildMoneyActivityFiatLine( tokenToEthRate, 2, ); - } else if (isMusdLikeForFiatFallback(ti.contractAddress, ti.symbol)) { - const rateEntry = resolveCurrencyRateEntry(currencyRates, ETH_TICKER); - if (rateEntry === undefined || rateEntry.usdConversionRate === 0) { - fiatNumber = undefined; - } else { - const tokenToEthPricePeg = 1 / rateEntry.usdConversionRate; - fiatNumber = balanceToFiatNumber( - absAmount, - rateEntry.conversionRate, - tokenToEthPricePeg, - 2, - ); - } } if (fiatNumber === undefined) { diff --git a/locales/languages/en.json b/locales/languages/en.json index a80bf2ed637..ccc49ea15fb 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -9085,6 +9085,7 @@ "predict_withdraw": "Withdrawal", "money_account_deposit": "Deposit to Money Account", "money_account_withdraw": "Transfer from Money Account", + "money_account_received": "Received", "default": "Transaction details" }, "label": { @@ -9096,7 +9097,9 @@ "retry_button": "Try again", "total": "Total", "account": "Account", - "received_total": "Received total" + "received_total": "Received total", + "from": "From", + "token_received": "Token received" }, "summary_title": { "bridge_approval": "Approve {{approveSymbol}}",