diff --git a/app/components/UI/Money/constants/activityStyles.test.ts b/app/components/UI/Money/constants/activityStyles.test.ts index 007974e38ca6..b9c70c60c4fc 100644 --- a/app/components/UI/Money/constants/activityStyles.test.ts +++ b/app/components/UI/Money/constants/activityStyles.test.ts @@ -282,5 +282,21 @@ describe('activityStyles', () => { ), ).toBe(false); }); + + it('returns true for a Perps/Predict withdraw landing in the Money account', () => { + expect( + isIncomingMoneyTransactionMeta( + makeTx(TransactionType.batch, { + nestedTransactions: [ + { type: TransactionType.predictWithdraw } as TransactionMeta, + ], + metamaskPay: { + tokenAddress: MUSD_TOKEN_ADDRESS, + chainId: MOCK_CHAIN, + }, + }), + ), + ).toBe(true); + }); }); }); diff --git a/app/components/UI/Money/constants/activityStyles.ts b/app/components/UI/Money/constants/activityStyles.ts index c642fafd137d..d74718974ef4 100644 --- a/app/components/UI/Money/constants/activityStyles.ts +++ b/app/components/UI/Money/constants/activityStyles.ts @@ -10,6 +10,7 @@ import { MUSD_DECIMALS, MUSD_TOKEN, } from '../../Earn/constants/musd'; +import { isPerpsPredictMoneyWithdraw } from '../utils/moneyTransactionGuards'; function formatNumber(num: number): string { return getIntlNumberFormatter(I18n.locale, { @@ -185,6 +186,10 @@ export function getMusdDisplayAmountFromTransactionMeta( } export function isIncomingMoneyTransactionMeta(tx: TransactionMeta): boolean { + if (isPerpsPredictMoneyWithdraw(tx)) { + return true; + } + const t = tx.type; if ( t === EvmTransactionType.incoming || @@ -194,6 +199,7 @@ export function isIncomingMoneyTransactionMeta(tx: TransactionMeta): boolean { ) { return true; } + // EIP-7702 batch deposits: moneyAccountDeposit sits in nestedTransactions return ( tx.nestedTransactions?.some( diff --git a/app/components/UI/Money/constants/moneyActivityFilters.test.ts b/app/components/UI/Money/constants/moneyActivityFilters.test.ts index 8d4c60711840..d9a04e22423d 100644 --- a/app/components/UI/Money/constants/moneyActivityFilters.test.ts +++ b/app/components/UI/Money/constants/moneyActivityFilters.test.ts @@ -15,6 +15,8 @@ import { const MOCK_CHAIN: Hex = CHAIN_IDS.MONAD; const OTHER_ERC20: Hex = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; +// A MetaMask Pay token of mUSD on Monad marks a Perps/Predict ↔ Money transfer. +const MUSD_ON_MONAD = { tokenAddress: MUSD_TOKEN_ADDRESS, chainId: MOCK_CHAIN }; function tx(overrides: Partial): TransactionMeta { return { @@ -103,6 +105,31 @@ describe('moneyActivityFilters', () => { ), ).toBe(false); }); + + it('returns true for a Perps/Predict withdraw landing in the Money account (inflow)', () => { + expect( + isMoneyActivityDeposit( + tx({ + type: TransactionType.batch, + nestedTransactions: [ + { type: TransactionType.predictWithdraw } as TransactionMeta, + ], + metamaskPay: MUSD_ON_MONAD, + }), + ), + ).toBe(true); + }); + + it('returns false for a Perps/Predict deposit (outflow, not a deposit into Money)', () => { + expect( + isMoneyActivityDeposit( + tx({ + type: TransactionType.perpsDeposit, + metamaskPay: MUSD_ON_MONAD, + }), + ), + ).toBe(false); + }); }); describe('isMoneyActivityTransfer', () => { @@ -148,6 +175,31 @@ describe('moneyActivityFilters', () => { ), ).toBe(false); }); + + it('returns true for a Perps/Predict deposit funded from the Money account (outflow)', () => { + expect( + isMoneyActivityTransfer( + tx({ + type: TransactionType.perpsDeposit, + metamaskPay: MUSD_ON_MONAD, + }), + ), + ).toBe(true); + }); + + it('returns false for a Perps/Predict withdraw (inflow, bucketed as a deposit)', () => { + expect( + isMoneyActivityTransfer( + tx({ + type: TransactionType.batch, + nestedTransactions: [ + { type: TransactionType.predictWithdraw } as TransactionMeta, + ], + metamaskPay: MUSD_ON_MONAD, + }), + ), + ).toBe(false); + }); }); describe('isMusdErc20Transfer', () => { diff --git a/app/components/UI/Money/constants/moneyActivityFilters.ts b/app/components/UI/Money/constants/moneyActivityFilters.ts index cc84a7c93036..9730d66448cd 100644 --- a/app/components/UI/Money/constants/moneyActivityFilters.ts +++ b/app/components/UI/Money/constants/moneyActivityFilters.ts @@ -3,6 +3,10 @@ import { TransactionType, } from '@metamask/transaction-controller'; import { isMusdOnMoneyAccountChain } from '../../Earn/constants/musd'; +import { + isPerpsPredictMoneyDeposit, + isPerpsPredictMoneyWithdraw, +} from '../utils/moneyTransactionGuards'; const ERC20_TRANSFER_TYPES: TransactionType[] = [ TransactionType.tokenMethodTransfer, @@ -32,10 +36,12 @@ export function isMoneyActivityDeposit(tx: TransactionMeta): boolean { if ( t === TransactionType.incoming || t === TransactionType.moneyAccountDeposit || - isMusdErc20Transfer(tx) + isMusdErc20Transfer(tx) || + isPerpsPredictMoneyWithdraw(tx) ) { return true; } + // EIP-7702 batch deposits: moneyAccountDeposit sits in nestedTransactions return ( tx.nestedTransactions?.some( @@ -48,7 +54,8 @@ export function isMoneyActivityTransfer(tx: TransactionMeta): boolean { const t = tx.type; if ( t === TransactionType.moneyAccountWithdraw || - t === TransactionType.simpleSend + t === TransactionType.simpleSend || + isPerpsPredictMoneyDeposit(tx) ) { return true; } diff --git a/app/components/UI/Money/hooks/useMoneyAccountTransactions.test.tsx b/app/components/UI/Money/hooks/useMoneyAccountTransactions.test.tsx index a961599b9248..ed0a1d500c23 100644 --- a/app/components/UI/Money/hooks/useMoneyAccountTransactions.test.tsx +++ b/app/components/UI/Money/hooks/useMoneyAccountTransactions.test.tsx @@ -365,6 +365,54 @@ describe('useMoneyAccountTransactions', () => { expect(result.current.allTransactions).toHaveLength(0); }); + const MUSD_ON_MONAD = { + tokenAddress: MUSD_TOKEN_ADDRESS, + chainId: CHAIN_IDS.MONAD, + }; + + it('includes a Perps deposit funded from the Money account as a transfer (outflow)', () => { + const tx = makeTx(TransactionType.perpsDeposit, { + metamaskPay: MUSD_ON_MONAD as TransactionMeta['metamaskPay'], + }); + 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 a Predict withdraw landing in the Money account as a deposit (inflow)', () => { + const tx = makeTx(TransactionType.batch, { + nestedTransactions: [ + { type: TransactionType.predictWithdraw } as TransactionMeta, + ], + metamaskPay: MUSD_ON_MONAD as TransactionMeta['metamaskPay'], + }); + 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('excludes a Perps deposit not funded from the Money account', () => { + const tx = makeTx(TransactionType.perpsDeposit, { + metamaskPay: { + tokenAddress: OTHER_ERC20, + chainId: CHAIN_IDS.ARBITRUM, + } as TransactionMeta['metamaskPay'], + }); + const { result } = renderHookWithProvider( + () => useMoneyAccountTransactions(), + { state: engineState({ moneyActivityMockDataEnabled: false }, [tx]) }, + ); + 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, diff --git a/app/components/UI/Money/hooks/useMoneyAccountTransactions.ts b/app/components/UI/Money/hooks/useMoneyAccountTransactions.ts index 20cbf1114705..d1186b955966 100644 --- a/app/components/UI/Money/hooks/useMoneyAccountTransactions.ts +++ b/app/components/UI/Money/hooks/useMoneyAccountTransactions.ts @@ -23,6 +23,7 @@ import { ERC20_TRANSFER_FROM_CALLDATA_LENGTH, } from '../constants/activityStyles'; import { getMoneyActivityStatus } from '../utils/classifyMoneyActivity'; +import { isPerpsPredictMoneyActivity } from '../utils/moneyTransactionGuards'; // Statuses that should surface in money activity. `unapproved`/`approved`/ // `signed` are mid-compose and shouldn't appear yet; `rejected`/`dropped`/ @@ -137,6 +138,7 @@ export function useMoneyAccountTransactions(): UseMoneyAccountTransactionsResult ) { return isMoneyAccountTxVisible(tx); } + // EIP-7702 batch where a Money account call is a nested call. if ( tx.nestedTransactions?.some( @@ -147,6 +149,12 @@ export function useMoneyAccountTransactions(): UseMoneyAccountTransactionsResult ) { return isMoneyAccountTxVisible(tx); } + + // Perps/Predict ↔ Money account transfers + if (isPerpsPredictMoneyActivity(tx)) { + return isMoneyAccountTxVisible(tx); + } + 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 diff --git a/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.ts b/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.ts index 1e7def7851b1..5575e53d0018 100644 --- a/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.ts +++ b/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.test.ts @@ -976,3 +976,98 @@ describe('useMoneyTransactionDisplayInfo — per-kind subtitles', () => { expect(result.current.description).toBe('mUSD'); }); }); + +// --------------------------------------------------------------------------- +// Perps/Predict ↔ Money transfers (matched via the mUSD pay token) +// --------------------------------------------------------------------------- + +describe('useMoneyTransactionDisplayInfo — Perps/Predict ↔ Money', () => { + const MONAD: Hex = '0x8f'; + const payOnMonad = (extra: Record) => ({ + tokenAddress: MUSD_TOKEN_ADDRESS, + chainId: MONAD, + ...extra, + }); + + it('renders a money-funded Perps deposit as an outflow ("Sent")', () => { + // Outflow → the debit from the account, including the bridge fee = totalFiat. + const tx = makeTx(TransactionType.perpsDeposit, { + metamaskPay: payOnMonad({ totalFiat: '0.67157', targetFiat: '0.64' }), + }); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState({ currentCurrency: 'usd' }) }, + ); + + expect(result.current.label).toBe('money.transaction.sent'); + expect(result.current.icon).toBe(IconName.Arrow2UpRight); + expect(result.current.description).toBe( + 'transaction_details.label.perps_account', + ); + expect(result.current.isIncoming).toBe(false); + expect(result.current.primaryAmount).toBe('-0.67 mUSD'); + expect(result.current.fiatAmount).toBe('-$0.67'); + }); + + it('renders a Predict withdraw into the Money account as an inflow ("Deposited")', () => { + // Inflow → the net amount that lands = targetFiat. Withdraw sits in the + // EIP-7702 batch's nested calls. + const tx = makeTx(TransactionType.batch, { + nestedTransactions: [{ type: TransactionType.predictWithdraw }], + metamaskPay: payOnMonad({ + totalFiat: '0.158879', + targetFiat: '0.099965', + }), + }); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState({ currentCurrency: 'usd' }) }, + ); + + expect(result.current.label).toBe('money.transaction.deposited'); + expect(result.current.icon).toBe(IconName.Add); + expect(result.current.description).toBe( + 'transaction_details.label.predictions_account', + ); + expect(result.current.isIncoming).toBe(true); + expect(result.current.primaryAmount).toBe('+0.10 mUSD'); + expect(result.current.fiatAmount).toBe('+$0.10'); + }); + + it('shows signed-zero (not the real amount) for a failed money-funded Perps deposit', () => { + // Failed rows must show signed-zero like every other kind; the perps amount + // block must not clobber it. + const tx = makeTx(TransactionType.perpsDeposit, { + status: TransactionStatus.failed, + metamaskPay: payOnMonad({ totalFiat: '0.67157', targetFiat: '0.64' }), + }); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState({ currentCurrency: 'usd' }) }, + ); + + expect(result.current.label).toBe('money.transaction.send_failed'); + expect(result.current.isIncoming).toBe(false); + expect(result.current.primaryAmount).toBe('-0.00 mUSD'); + expect(result.current.fiatAmount).toBe('-$0.00'); + }); + + it('shows signed-zero for a failed Predict withdraw into the Money account', () => { + const tx = makeTx(TransactionType.batch, { + status: TransactionStatus.failed, + nestedTransactions: [{ type: TransactionType.predictWithdraw }], + metamaskPay: payOnMonad({ + totalFiat: '0.158879', + targetFiat: '0.099965', + }), + }); + const { result } = renderHookWithProvider( + () => useMoneyTransactionDisplayInfo(tx, undefined), + { state: makeState({ currentCurrency: 'usd' }) }, + ); + + expect(result.current.isIncoming).toBe(true); + expect(result.current.primaryAmount).toBe('+0.00 mUSD'); + expect(result.current.fiatAmount).toBe('+$0.00'); + }); +}); diff --git a/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.ts b/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.ts index 58ff716e3e8b..1deb89ac39e3 100644 --- a/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.ts +++ b/app/components/UI/Money/hooks/useMoneyTransactionDisplayInfo.ts @@ -33,7 +33,12 @@ import { MUSD_TOKEN, } from '../../Earn/constants/musd'; import { MONEY_WITHDRAW_TOKEN_SYMBOL } from '../constants/moneyTokens'; -import { isMoneyWithdrawTx } from '../utils/moneyTransactionGuards'; +import { + isMoneyWithdrawTx, + isPerpsPredictMoneyActivity, + isPerpsPredictMoneyWithdraw, + perpsPredictServiceFamily, +} from '../utils/moneyTransactionGuards'; import type { MoneyActivityTransactionMeta } from '../constants/mockActivityData'; import { classifyMoneyActivity, @@ -111,6 +116,17 @@ function deriveSubtitle( if (explicitSubtitle) { return explicitSubtitle; } + + // Perps/Predict ↔ Money transfers (either direction) name the service account + // instead of a token pair / sender. + const serviceFamily = perpsPredictServiceFamily(tx); + if (serviceFamily === 'perps') { + return strings('transaction_details.label.perps_account'); + } + if (serviceFamily === 'predict') { + return strings('transaction_details.label.predictions_account'); + } + switch (kind) { case 'converted': return sourceTokenSymbol @@ -244,6 +260,26 @@ export function useMoneyTransactionDisplayInfo( } } + // Perps/Predict ↔ Money transfers carry no `requiredAssets` and aren't token + // transfers, so neither amount path above resolves. Skip when failed so the + // signed-zero amount set above is preserved (as for every other failed row). + if (status !== 'failed' && isPerpsPredictMoneyActivity(tx)) { + const fiatStr = isPerpsPredictMoneyWithdraw(tx) + ? tx.metamaskPay?.targetFiat + : tx.metamaskPay?.totalFiat; + const fiat = Number(fiatStr); + if (!isNaN(fiat) && fiat > 0) { + const amount = new BigNumber(fiat); + primaryAmount = formatMusdAmount(amount, isIncoming); + if (currentCurrency) { + fiatAmount = `${isIncoming ? '+' : '-'}${moneyFormatFiat( + amount, + currentCurrency, + )}`; + } + } + } + return { label: moneyActivityLabel(kind, status), description: deriveSubtitle( diff --git a/app/components/UI/Money/hooks/useRefreshMoneyBalanceOnTxConfirm.test.ts b/app/components/UI/Money/hooks/useRefreshMoneyBalanceOnTxConfirm.test.ts index 06912acadf65..93af8e2b4ee0 100644 --- a/app/components/UI/Money/hooks/useRefreshMoneyBalanceOnTxConfirm.test.ts +++ b/app/components/UI/Money/hooks/useRefreshMoneyBalanceOnTxConfirm.test.ts @@ -1,8 +1,10 @@ import { + CHAIN_IDS, TransactionMeta, TransactionStatus, TransactionType, } from '@metamask/transaction-controller'; +import { MUSD_TOKEN_ADDRESS } from '../../Earn/constants/musd'; import { renderHook } from '@testing-library/react-hooks'; import { waitFor } from '@testing-library/react-native'; import Engine from '../../../../core/Engine'; @@ -179,6 +181,58 @@ describe('useRefreshMoneyBalanceOnTxConfirm', () => { expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); }); + const MUSD_ON_MONAD = { + tokenAddress: MUSD_TOKEN_ADDRESS, + chainId: CHAIN_IDS.MONAD, + }; + + it('invalidates on a confirmed Perps deposit funded from the Money account', async () => { + renderHook(() => useRefreshMoneyBalanceOnTxConfirm()); + const handler = getConfirmedHandler(); + + handler({ + ...makeTx(TransactionType.perpsDeposit), + metamaskPay: MUSD_ON_MONAD, + } as unknown as TransactionMeta); + await waitFor(() => { + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); + }); + + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); + }); + + it('invalidates on a confirmed Predict withdraw landing in the Money account', async () => { + renderHook(() => useRefreshMoneyBalanceOnTxConfirm()); + const handler = getConfirmedHandler(); + + handler({ + ...makeTx(TransactionType.batch, TransactionStatus.confirmed, [ + { type: TransactionType.predictWithdraw }, + ]), + metamaskPay: MUSD_ON_MONAD, + } as unknown as TransactionMeta); + await waitFor(() => { + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); + }); + + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); + }); + + it('does not invalidate for a Perps deposit NOT funded from the Money account', () => { + renderHook(() => useRefreshMoneyBalanceOnTxConfirm()); + const handler = getConfirmedHandler(); + + handler({ + ...makeTx(TransactionType.perpsDeposit), + metamaskPay: { + tokenAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: CHAIN_IDS.ARBITRUM, + }, + } as unknown as TransactionMeta); + + expect(mockInvalidateQueries).not.toHaveBeenCalled(); + }); + it('does not invalidate for non-confirmed status', () => { renderHook(() => useRefreshMoneyBalanceOnTxConfirm()); const handler = getConfirmedHandler(); diff --git a/app/components/UI/Money/hooks/useRefreshMoneyBalanceOnTxConfirm.ts b/app/components/UI/Money/hooks/useRefreshMoneyBalanceOnTxConfirm.ts index bc9052165736..991a97d8cbc7 100644 --- a/app/components/UI/Money/hooks/useRefreshMoneyBalanceOnTxConfirm.ts +++ b/app/components/UI/Money/hooks/useRefreshMoneyBalanceOnTxConfirm.ts @@ -9,7 +9,10 @@ import ReactQueryService from '../../../../core/ReactQueryService'; import { store } from '../../../../store'; import { selectPrimaryMoneyAccount } from '../../../../selectors/moneyAccountController'; import { MoneyAccountBalanceServiceQueryKeys } from '../queryKeys'; -import { isMoneyAccountTx } from '../utils/moneyTransactionGuards'; +import { + isMoneyAccountTx, + isPerpsPredictMoneyActivity, +} from '../utils/moneyTransactionGuards'; import Logger from '../../../../util/Logger'; import { calculateExponentialRetryDelay } from '../../../../util/exponential-retry'; @@ -87,11 +90,18 @@ export const useRefreshMoneyBalanceOnTxConfirm = () => { useEffect(() => { const handleTransactionConfirmed = (transactionMeta: TransactionMeta) => { if (transactionMeta.status !== TransactionStatus.confirmed) return; - if (!isMoneyAccountTx(transactionMeta)) return; const address = selectPrimaryMoneyAccount(store.getState())?.address; if (!address) return; + // Direct Money txs (deposit/withdraw) plus Perps/Predict transfers to or + // from the Money account (paid with mUSD via MetaMask Pay), which also + // move mUSD and so must refresh the balance. + const affectsMoneyBalance = + isMoneyAccountTx(transactionMeta) || + isPerpsPredictMoneyActivity(transactionMeta); + if (!affectsMoneyBalance) return; + refreshMoneyBalanceQueries(address).catch((error) => { Logger.error(error, `${LOG_PREFIX} Balance refresh failed`); }); diff --git a/app/components/UI/Money/utils/classifyMoneyActivity.test.ts b/app/components/UI/Money/utils/classifyMoneyActivity.test.ts index dd9f6e44c7c0..ad26550b84da 100644 --- a/app/components/UI/Money/utils/classifyMoneyActivity.test.ts +++ b/app/components/UI/Money/utils/classifyMoneyActivity.test.ts @@ -1,4 +1,5 @@ import { + CHAIN_IDS, type TransactionMeta, TransactionStatus, TransactionType, @@ -82,6 +83,46 @@ describe('classifyMoneyActivity', () => { }, ); + const MUSD_ON_MONAD = { + tokenAddress: MUSD_TOKEN_ADDRESS, + chainId: CHAIN_IDS.MONAD, + }; + + it.each([ + TransactionType.perpsDeposit, + TransactionType.perpsDepositAndOrder, + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, + ])( + 'classifies a money-funded %s as sent (outflow to the service)', + (type) => { + expect( + classifyMoneyActivity(makeTx({ type, metamaskPay: MUSD_ON_MONAD })), + ).toBe('sent'); + }, + ); + + it.each([TransactionType.perpsWithdraw, TransactionType.predictWithdraw])( + 'classifies an mUSD %s as deposited (inflow into the Money account)', + (type) => { + const tx = makeTx({ + type: TransactionType.batch, + nestedTransactions: [{ type }], + metamaskPay: MUSD_ON_MONAD, + }); + expect(classifyMoneyActivity(tx)).toBe('deposited'); + }, + ); + + it('does not treat a non-mUSD perps deposit as a Money outflow', () => { + const tx = makeTx({ + type: TransactionType.perpsDeposit, + metamaskPay: { tokenAddress: USDC_ADDRESS, chainId: CHAIN_ID }, + }); + // Falls through to the default branch rather than the 'sent' early return. + expect(classifyMoneyActivity(tx)).toBe('received'); + }); + it('resolves the nested type for an EIP-7702 batch crypto deposit', () => { const tx = makeTx({ type: TransactionType.batch, diff --git a/app/components/UI/Money/utils/classifyMoneyActivity.ts b/app/components/UI/Money/utils/classifyMoneyActivity.ts index 57c5523a3a23..5a4fbed9f412 100644 --- a/app/components/UI/Money/utils/classifyMoneyActivity.ts +++ b/app/components/UI/Money/utils/classifyMoneyActivity.ts @@ -6,6 +6,10 @@ import { import { IconName } from '@metamask/design-system-react-native'; import { strings } from '../../../../../locales/i18n'; import { isMusdToken } from '../../Earn/constants/musd'; +import { + isPerpsPredictMoneyDeposit, + isPerpsPredictMoneyWithdraw, +} from './moneyTransactionGuards'; import type { MoneyActivityTitleKey, MoneyActivityTransactionMeta, @@ -78,6 +82,15 @@ export function classifyMoneyActivity(tx: TransactionMeta): MoneyActivityKind { return TITLE_KEY_TO_KIND[moneyActivityTitleKey] ?? 'received'; } + // Perps/Predict ↔ Money transfers (matched via the mUSD pay token). Withdraw + // into the Money account reads as a deposit; deposit out of it reads as sent. + if (isPerpsPredictMoneyWithdraw(tx)) { + return 'deposited'; + } + if (isPerpsPredictMoneyDeposit(tx)) { + return 'sent'; + } + const type = resolveMoneyTransactionType(tx); if (!type) { return 'deposited'; diff --git a/app/components/UI/Money/utils/moneyTransactionGuards.test.ts b/app/components/UI/Money/utils/moneyTransactionGuards.test.ts index 5ec493191220..f114e8196b23 100644 --- a/app/components/UI/Money/utils/moneyTransactionGuards.test.ts +++ b/app/components/UI/Money/utils/moneyTransactionGuards.test.ts @@ -1,13 +1,20 @@ import { + CHAIN_IDS, TransactionMeta, TransactionStatus, TransactionType, } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { MUSD_TOKEN_ADDRESS } from '../../Earn/constants/musd'; import { isMoneyAccountTx, isMoneyDepositTx, isMoneyWithdrawTx, + isPerpsPredictMoneyActivity, + isPerpsPredictMoneyDeposit, + isPerpsPredictMoneyWithdraw, nestedTxWithType, + perpsPredictServiceFamily, getMMPayChainIds, } from './moneyTransactionGuards'; @@ -142,6 +149,160 @@ describe('isMoneyAccountTx', () => { }); }); +// mUSD on Monad as a MetaMask Pay token = funded by / landing in the Money +// account; any other token/chain is unrelated to it. +const MUSD_ON_MONAD = { + tokenAddress: MUSD_TOKEN_ADDRESS, + chainId: CHAIN_IDS.MONAD as Hex, +}; +const USDC_ON_ARBITRUM = { + tokenAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as Hex, + chainId: CHAIN_IDS.ARBITRUM as Hex, +}; + +const makeServiceTx = ( + type: TransactionType | undefined, + metamaskPay: Record | undefined, + nested?: { type: TransactionType }[], +): TransactionMeta => + ({ + ...baseTx, + type, + metamaskPay, + nestedTransactions: nested, + }) as unknown as TransactionMeta; + +describe('isPerpsPredictMoneyDeposit', () => { + it.each([ + TransactionType.perpsDeposit, + TransactionType.perpsDepositAndOrder, + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, + ])('returns true for %s paid with mUSD on Monad', (type) => { + expect(isPerpsPredictMoneyDeposit(makeServiceTx(type, MUSD_ON_MONAD))).toBe( + true, + ); + }); + + it('returns false when the pay token is not mUSD on the Money chain', () => { + expect( + isPerpsPredictMoneyDeposit( + makeServiceTx(TransactionType.perpsDeposit, USDC_ON_ARBITRUM), + ), + ).toBe(false); + }); + + it('returns false when there is no metamaskPay (not money-funded)', () => { + expect( + isPerpsPredictMoneyDeposit( + makeServiceTx(TransactionType.perpsDeposit, undefined), + ), + ).toBe(false); + }); + + it('returns false for a withdraw type even with an mUSD pay token', () => { + expect( + isPerpsPredictMoneyDeposit( + makeServiceTx(TransactionType.perpsWithdraw, MUSD_ON_MONAD), + ), + ).toBe(false); + }); +}); + +describe('isPerpsPredictMoneyWithdraw', () => { + it('returns true for a top-level withdraw landing as mUSD on Monad', () => { + expect( + isPerpsPredictMoneyWithdraw( + makeServiceTx(TransactionType.perpsWithdraw, MUSD_ON_MONAD), + ), + ).toBe(true); + }); + + it('returns true for an EIP-7702 batch with a nested predictWithdraw', () => { + expect( + isPerpsPredictMoneyWithdraw( + makeServiceTx(TransactionType.batch, MUSD_ON_MONAD, [ + { type: TransactionType.predictWithdraw }, + ]), + ), + ).toBe(true); + }); + + it('returns false when the destination is not mUSD on the Money chain', () => { + expect( + isPerpsPredictMoneyWithdraw( + makeServiceTx(TransactionType.predictWithdraw, USDC_ON_ARBITRUM), + ), + ).toBe(false); + }); + + it('returns false for a deposit type', () => { + expect( + isPerpsPredictMoneyWithdraw( + makeServiceTx(TransactionType.perpsDeposit, MUSD_ON_MONAD), + ), + ).toBe(false); + }); +}); + +describe('isPerpsPredictMoneyActivity', () => { + it('returns true for both a money-funded deposit and an mUSD withdraw', () => { + expect( + isPerpsPredictMoneyActivity( + makeServiceTx(TransactionType.perpsDeposit, MUSD_ON_MONAD), + ), + ).toBe(true); + expect( + isPerpsPredictMoneyActivity( + makeServiceTx(TransactionType.batch, MUSD_ON_MONAD, [ + { type: TransactionType.predictWithdraw }, + ]), + ), + ).toBe(true); + }); + + it('returns false for an unrelated tx', () => { + expect( + isPerpsPredictMoneyActivity( + makeServiceTx(TransactionType.contractInteraction, MUSD_ON_MONAD), + ), + ).toBe(false); + }); +}); + +describe('perpsPredictServiceFamily', () => { + it.each([ + [TransactionType.perpsDeposit, 'perps'], + [TransactionType.perpsDepositAndOrder, 'perps'], + [TransactionType.perpsWithdraw, 'perps'], + [TransactionType.predictDeposit, 'predict'], + [TransactionType.predictDepositAndOrder, 'predict'], + [TransactionType.predictWithdraw, 'predict'], + ])('maps %s to %s', (type, family) => { + expect(perpsPredictServiceFamily(makeServiceTx(type, MUSD_ON_MONAD))).toBe( + family, + ); + }); + + it('resolves the family from a nested batch call', () => { + expect( + perpsPredictServiceFamily( + makeServiceTx(TransactionType.batch, MUSD_ON_MONAD, [ + { type: TransactionType.predictWithdraw }, + ]), + ), + ).toBe('predict'); + }); + + it('returns undefined for a non-service tx', () => { + expect( + perpsPredictServiceFamily( + makeServiceTx(TransactionType.moneyAccountWithdraw, undefined), + ), + ).toBeUndefined(); + }); +}); + describe('getMMPayChainIds', () => { const makePayTx = ( chainId: `0x${string}`, diff --git a/app/components/UI/Money/utils/moneyTransactionGuards.ts b/app/components/UI/Money/utils/moneyTransactionGuards.ts index 879f97f1f56f..798adecff6c4 100644 --- a/app/components/UI/Money/utils/moneyTransactionGuards.ts +++ b/app/components/UI/Money/utils/moneyTransactionGuards.ts @@ -3,6 +3,7 @@ import { TransactionType, } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; +import { isMusdOnMoneyAccountChain } from '../../Earn/constants/musd'; /** * Returns the first nested transaction matching a given TransactionType, @@ -31,6 +32,124 @@ export const isMoneyWithdrawTx = (transactionMeta: TransactionMeta) => export const isMoneyAccountTx = (transactionMeta: TransactionMeta) => isMoneyDepositTx(transactionMeta) || isMoneyWithdrawTx(transactionMeta); +/** + * Perps/Predict deposit parent types (money → service). When funded from the + * Money account these are paid with mUSD via MetaMask Pay; the on-chain deposit + * is signed from the user's EOA on the service chain (Arbitrum/Polygon) + **/ +export const PERPS_PREDICT_DEPOSIT_TYPES: TransactionType[] = [ + TransactionType.perpsDeposit, + TransactionType.perpsDepositAndOrder, + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, +]; + +/** + * Perps/Predict withdraw types (service → money). When the destination is the + * Money account these arrive as mUSD on Monad. The withdraw is wrapped in an + * EIP-7702 `batch`, so the type sits in `nestedTransactions`. + */ +export const PERPS_PREDICT_WITHDRAW_TYPES: TransactionType[] = [ + TransactionType.perpsWithdraw, + TransactionType.predictWithdraw, +]; + +/** + * The Perps/Predict deposit or withdraw type for a tx, unwrapping an EIP-7702 + * `batch` whose money-moving call sits in `nestedTransactions`. + */ +const effectiveServiceType = ( + transactionMeta: TransactionMeta, +): TransactionType | undefined => { + const serviceTypes = [ + ...PERPS_PREDICT_DEPOSIT_TYPES, + ...PERPS_PREDICT_WITHDRAW_TYPES, + ]; + if ( + transactionMeta.type && + serviceTypes.includes(transactionMeta.type as TransactionType) + ) { + return transactionMeta.type as TransactionType; + } + return transactionMeta.nestedTransactions?.find( + (nested) => nested.type && serviceTypes.includes(nested.type), + )?.type; +}; + +/** + * True when the `metamaskPay` token is mUSD on the Money account chain (Monad). + * For a deposit this is the source the Money account paid; for a withdraw + * (`isPostQuote`) it's the destination — either way it links the tx to the + * Money account. + */ +const isMusdMoneyPayToken = (transactionMeta: TransactionMeta): boolean => + isMusdOnMoneyAccountChain( + transactionMeta.metamaskPay?.tokenAddress, + transactionMeta.metamaskPay?.chainId as Hex | undefined, + ); + +/** + * Perps/Predict deposit funded from the Money account — + * from the perspective of the money account this a 'Send'. + * The tx `from` is the user's EOA, not the Money account, so it's matched via + * the mUSD pay token rather than the address. + */ +export const isPerpsPredictMoneyDeposit = ( + transactionMeta: TransactionMeta, +): boolean => { + const type = effectiveServiceType(transactionMeta); + return ( + Boolean(type) && + PERPS_PREDICT_DEPOSIT_TYPES.includes(type as TransactionType) && + isMusdMoneyPayToken(transactionMeta) + ); +}; + +/** + * Perps/Predict withdraw landing in the Money account — an inflow ("Deposited"). + */ +export const isPerpsPredictMoneyWithdraw = ( + transactionMeta: TransactionMeta, +): boolean => { + const type = effectiveServiceType(transactionMeta); + return ( + Boolean(type) && + PERPS_PREDICT_WITHDRAW_TYPES.includes(type as TransactionType) && + isMusdMoneyPayToken(transactionMeta) + ); +}; + +export const isPerpsPredictMoneyActivity = ( + transactionMeta: TransactionMeta, +): boolean => + isPerpsPredictMoneyDeposit(transactionMeta) || + isPerpsPredictMoneyWithdraw(transactionMeta); + +/** + * The service family ('perps' | 'predict') for a Perps/Predict ↔ Money tx, used + * to label the activity row's subtitle. Returns undefined for other txs. + */ +export const perpsPredictServiceFamily = ( + transactionMeta: TransactionMeta, +): 'perps' | 'predict' | undefined => { + const type = effectiveServiceType(transactionMeta); + if ( + type === TransactionType.perpsDeposit || + type === TransactionType.perpsDepositAndOrder || + type === TransactionType.perpsWithdraw + ) { + return 'perps'; + } + if ( + type === TransactionType.predictDeposit || + type === TransactionType.predictDepositAndOrder || + type === TransactionType.predictWithdraw + ) { + return 'predict'; + } + return undefined; +}; + /** * Resolves source and destination chain IDs for MM Pay transaction. * diff --git a/app/core/OAuthService/OAuthLoginHandlers/constants.ts b/app/core/OAuthService/OAuthLoginHandlers/constants.ts index ed20a4e74e8a..97ab6c6673af 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/constants.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/constants.ts @@ -97,9 +97,7 @@ export const AppleWebClientId = CURRENT_OAUTH_CONFIG.ANDROID_APPLE_CLIENT_ID; // Use universal link for OAuth redirect export const GoogleRedirectUri = `${PROTOCOLS.HTTPS}://${AppConstants.MM_IO_UNIVERSAL_LINK_HOST}/${ACTIONS.OAUTH_REDIRECT}`; export const AppRedirectUri = GoogleRedirectUri; -export const TelegramRedirectUri = Device.isAndroid() - ? `${PROTOCOLS.METAMASK}://${ACTIONS.OAUTH_REDIRECT}` - : AppRedirectUri; +export const TelegramRedirectUri = AppRedirectUri; export const AppleServerRedirectUri = `${CURRENT_OAUTH_CONFIG.AUTH_SERVER_URL}/api/v1/oauth/callback`; export const shouldUseLegacyIosGoogleConfig = () => { diff --git a/app/core/OAuthService/OAuthLoginHandlers/shared/TelegramLoginHandler.ts b/app/core/OAuthService/OAuthLoginHandlers/shared/TelegramLoginHandler.ts index a22ffcb54982..4c630817d2fe 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/shared/TelegramLoginHandler.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/shared/TelegramLoginHandler.ts @@ -21,6 +21,7 @@ import { } from '../baseHandler'; import { isRetryableError, retryWithDelay } from '../utils'; import { OAuthError, OAuthErrorType } from '../../error'; +import Device from '../../../../util/device'; const TELEGRAM_AUTH_SERVER_INITIATE_PATH = '/api/v2/telegram/login/initiate'; const TELEGRAM_AUTH_SERVER_VERIFY_PATH = '/api/v2/telegram/login/verify'; @@ -234,6 +235,12 @@ export class TelegramLoginHandler extends BaseLoginHandler { return initialUrl; } + // Temporary workaround for Android 16 Safer Intents which prevents Chrome from handling custom scheme URLs directly + // Currently Telegram login flow is not using params in the redirect url + if (Device.isAndroid()) { + return this.redirectUri; + } + if (result.type === 'cancel') { throw new OAuthError( 'TelegramLoginHandler: User cancelled the login process',