diff --git a/.js.env.example b/.js.env.example index a6a3c96bef1e..e0e4990f340d 100644 --- a/.js.env.example +++ b/.js.env.example @@ -137,6 +137,10 @@ export MM_MUSD_CONVERSION_REWARDS_UI_ENABLED="false" # Geo-blocked countries for mUSD conversion (comma-separated ISO 3166-1 alpha-2 codes) # LaunchDarkly takes precedence; this is a fallback. Default blocks UK. export MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES="GB" + +# Geo-blocked countries for Money Account +export MM_MONEY_ACCOUNT_GEO_BLOCKED_COUNTRIES="GB" + export MM_MUSD_CONVERSION_MIN_ASSET_BALANCE_REQUIRED="0.01" # Money Hub diff --git a/app/animations/money_account_onboarding_animation.riv b/app/animations/money_account_onboarding_animation_v4.riv similarity index 77% rename from app/animations/money_account_onboarding_animation.riv rename to app/animations/money_account_onboarding_animation_v4.riv index e2f3a2e1e74d..528edb63e983 100644 Binary files a/app/animations/money_account_onboarding_animation.riv and b/app/animations/money_account_onboarding_animation_v4.riv differ diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index b9ea67025a7b..29ad825fdb83 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -126,7 +126,9 @@ import { MoneyTabScreenStack, } from '../../UI/Money/routes'; import MoneyOnboardingView from '../../UI/Money/Views/MoneyOnboardingView'; +import MoneyPotentialEarningsView from '../../UI/Money/Views/MoneyPotentialEarningsView'; import { selectMoneyEnableMoneyAccountFlag } from '../../UI/Money/selectors/featureFlags'; +import { selectIsMoneyAccountGeoEligible } from '../../UI/Money/selectors/eligibility'; import { BridgeTransactionDetails } from '../../UI/Bridge/components/TransactionDetails/TransactionDetails'; import { BridgeModalStack, BridgeScreenStack } from '../../UI/Bridge/routes'; import { @@ -606,6 +608,11 @@ const HomeTabs = () => { const [isKeyboardHidden, setIsKeyboardHidden] = useState(true); const isMoneyAccountEnabled = useSelector(selectMoneyEnableMoneyAccountFlag); + const isMoneyAccountGeoEligible = useSelector( + selectIsMoneyAccountGeoEligible, + ); + const isMoneyAccountVisible = + isMoneyAccountEnabled && isMoneyAccountGeoEligible; const trackMoneyTabPressRef = useRef(null); @@ -849,8 +856,8 @@ const HomeTabs = () => { component={WalletTabStackFlow} /> - {/* Activity Tab (replaced by Money when feature flag is on) */} - {isMoneyAccountEnabled ? ( + {/* Activity Tab (replaced by Money when feature flag is on and user is geo-eligible) */} + {isMoneyAccountVisible ? ( { component={MoneyOnboardingView} options={{ headerShown: false, ...fadeNativeOptions }} /> + ({ mockSelectMoneyEnableMoneyAccountFlag(state), })); +const mockSelectIsMoneyAccountGeoEligible = jest.fn().mockReturnValue(true); +jest.mock('../../UI/Money/selectors/eligibility', () => ({ + selectIsMoneyAccountGeoEligible: (state: unknown) => + mockSelectIsMoneyAccountGeoEligible(state), +})); + describe('MainNavigator', () => { const originalEnv = process.env.METAMASK_ENVIRONMENT; diff --git a/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx b/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx new file mode 100644 index 000000000000..708adb624749 --- /dev/null +++ b/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx @@ -0,0 +1,462 @@ +/** + * Tests for ActivityListItemRow — exhaustive status and type mapping. + */ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import type { ActivityListItem, Status } from '../../../util/activity-adapters'; +import { ActivityListItemRow } from './ActivityListItemRow'; +import { strings } from '../../../../locales/i18n'; +import { getNetworkImageSource } from '../../../util/networks'; + +const LINEA_MUSD_ADDRESS = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; +const LINEA_MUSD_CHECKSUM_ADDRESS = + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA'; + +const mockState = { + user: { + appTheme: 'light', + }, + settings: { + showFiatOnTestnets: true, + }, + engine: { + backgroundState: { + CurrencyRateController: { + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionRate: 2500, + usdConversionRate: 2500, + }, + }, + }, + NetworkController: { + networkConfigurationsByChainId: { + '0x1': { + nativeCurrency: 'ETH', + }, + }, + }, + TokenRatesController: { + marketData: { + '0x1': { + [LINEA_MUSD_CHECKSUM_ADDRESS]: { + price: 0.0004, + }, + }, + }, + }, + }, + }, +}; + +// Minimal required mocks +jest.mock('../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn((selector) => { + try { + return selector(mockState); + } catch { + return undefined; + } + }), +})); + +jest.mock('../../../selectors/currencyRateController', () => ({ + selectCurrentCurrency: jest.fn( + (state) => + state.engine.backgroundState.CurrencyRateController.currentCurrency, + ), + selectCurrencyRateForChainId: jest.fn(() => 2500), +})); + +jest.mock('../../../selectors/tokenRatesController', () => ({ + selectTokenMarketData: jest.fn( + (state) => state.engine.backgroundState.TokenRatesController.marketData, + ), +})); + +jest.mock('react-native', () => { + const rn = jest.requireActual('react-native'); + return { + ...rn, + useColorScheme: jest.fn(() => 'light'), + }; +}); + +jest.mock('../../../util/transaction-icons', () => ({ + getTransactionIcon: jest.fn(() => 1), +})); + +jest.mock('../../../util/date', () => ({ + toDateFormat: jest.fn(() => 'Jan 1'), +})); + +jest.mock('../../../util/networks', () => ({ + getNetworkImageSource: jest.fn(() => ({ uri: 'mock-network-image' })), +})); + +jest.mock('../../../component-library/components/Badges/BadgeWrapper', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(View, null, children); +}); + +jest.mock('../../../component-library/components/Badges/Badge', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const Badge = () => ReactActual.createElement(View, null); + Badge.displayName = 'Badge'; + return { + __esModule: true, + default: Badge, + BadgeVariant: { Network: 'Network' }, + }; +}); + +jest.mock('../../../component-library/components/Avatars/Avatar', () => ({ + AvatarSize: { Xs: 'xs' }, +})); + +jest.mock('../../../component-library/components/Texts/Text', () => ({ + getFontFamily: jest.fn(() => 'Inter'), + TextVariant: { + BodyLGMedium: 'bodyLGMedium', + BodyMDBold: 'bodyMDBold', + BodyMD: 'bodyMD', + }, +})); + +jest.mock('../../Base/ListItem', () => { + const ReactActual = jest.requireActual('react'); + const { Text: TextActual, View } = jest.requireActual('react-native'); + + const ListItem = ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(View, null, children); + + ListItem.Date = ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(TextActual, null, children); + ListItem.Content = ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(View, null, children); + ListItem.Icon = ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(View, null, children); + ListItem.Body = ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(View, null, children); + ListItem.Title = ({ + children, + ...rest + }: { + children: React.ReactNode; + numberOfLines?: number; + style?: object; + }) => ReactActual.createElement(TextActual, rest, children); + ListItem.Amount = ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(TextActual, null, children); + ListItem.Amounts = ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(View, null, children); + ListItem.FiatAmount = ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(TextActual, null, children); + + return ListItem; +}); + +// --------------------------------------------------------------------------- +// Helper to build minimal ActivityListItem +// --------------------------------------------------------------------------- + +const makeItem = ( + overrides: Partial<{ + type: ActivityListItem['type']; + status: Status; + isEarliestNonce: boolean; + hash: string; + from: string; + to: string; + token?: object; + }>, +): ActivityListItem => { + const type = overrides.type ?? 'send'; + const status = overrides.status ?? 'success'; + const base = { + type, + chainId: 'eip155:1' as const, + status, + timestamp: 1_700_000_000_000, + isEarliestNonce: overrides.isEarliestNonce, + }; + + if (type === 'send' || type === 'receive') { + return { + ...base, + type, + data: { + hash: overrides.hash ?? '0xabc', + from: overrides.from ?? '0xfrom', + to: overrides.to ?? '0xto', + token: overrides.token as never, + }, + } as unknown as ActivityListItem; + } + + return { + ...base, + type, + data: { + hash: overrides.hash ?? '0xabc', + from: overrides.from ?? '0xfrom', + to: overrides.to ?? '0xto', + }, + } as unknown as ActivityListItem; +}; + +// --------------------------------------------------------------------------- +// Status mapping tests — prove all Status values map to explicit display labels +// --------------------------------------------------------------------------- + +describe('ActivityListItemRow — status display', () => { + const allStatuses: Status[] = ['pending', 'success', 'failed', 'cancelled']; + + it.each(allStatuses)( + 'renders an explicit non-empty status label for status=%s', + (status) => { + const item = makeItem({ status }); + const { getByTestId } = render( + , + ); + const el = getByTestId('transaction-status-0'); + expect(el.props.children).toBeTruthy(); + }, + ); + + it('shows "Confirmed" for success status', () => { + const item = makeItem({ status: 'success' }); + const { getByTestId } = render( + , + ); + const el = getByTestId('transaction-status-0'); + expect(el.props.children).toBe(strings('transaction.confirmed')); + }); + + it('shows "Failed" for failed status', () => { + const item = makeItem({ status: 'failed' }); + const { getByTestId } = render( + , + ); + const el = getByTestId('transaction-status-0'); + expect(el.props.children).toBe(strings('transaction.failed')); + }); + + it('shows "Cancelled" for cancelled status', () => { + const item = makeItem({ status: 'cancelled' }); + const { getByTestId } = render( + , + ); + const el = getByTestId('transaction-status-0'); + expect(el.props.children).toBe(strings('transaction.cancelled')); + }); + + it('shows "Submitted" for pending status (earliest nonce)', () => { + const item = makeItem({ status: 'pending', isEarliestNonce: true }); + const { getByTestId } = render( + , + ); + const el = getByTestId('transaction-status-0'); + expect(el.props.children).toBe(strings('transaction.submitted')); + }); +}); + +describe('ActivityListItemRow — network badge', () => { + it('uses the row item chainId for the network badge', () => { + const item = makeItem({ status: 'success' }); + render(); + + expect(getNetworkImageSource).toHaveBeenCalledWith({ + chainId: item.chainId, + }); + }); +}); + +describe('ActivityListItemRow — amount display', () => { + it('formats raw token base units and renders fiat when rates are available', () => { + const item = makeItem({ + status: 'success', + token: { + amount: '1000000', + decimals: 6, + symbol: 'mUSD', + assetId: `eip155:1/erc20:${LINEA_MUSD_ADDRESS}`, + direction: 'in', + }, + }); + + const { getByText } = render(); + + expect(getByText('+1 mUSD')).toBeOnTheScreen(); + expect(getByText('+$1')).toBeOnTheScreen(); + }); + + it('does not render fiat when token market data is unavailable', () => { + const item = makeItem({ + status: 'success', + token: { + amount: '1000000', + decimals: 6, + symbol: 'UNKNOWN', + assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000001', + direction: 'in', + }, + }); + + const { getByText, queryByText } = render( + , + ); + + expect(getByText('+1 UNKNOWN')).toBeOnTheScreen(); + expect(queryByText('+$1')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Type mapping tests — prove all ActivityKind values produce a non-empty title +// --------------------------------------------------------------------------- + +const ALL_KINDS: ActivityListItem['type'][] = [ + 'send', + 'receive', + 'swap', + 'swapIncomplete', + 'bridge', + 'buy', + 'sell', + 'claim', + 'claimMusdBonus', + 'deposit', + 'convert', + 'wrap', + 'unwrap', + 'approveSpendingCap', + 'revokeSpendingCap', + 'increaseSpendingCap', + 'lendingDeposit', + 'lendingWithdrawal', + 'nftMint', + 'contractInteraction', + 'contractDeployment', + 'smartAccountUpgrade', + 'predictionsAddFunds', + 'predictionsWithdrawFunds', + 'predictionClaimWinnings', + 'predictionCashedOut', + 'predictionPlaced', + 'perpsAddFunds', + 'perpsWithdrawFunds', + 'perpsOpenLong', + 'perpsCloseLong', + 'perpsCloseLongLiquidated', + 'perpsCloseLongStopLoss', + 'perpsOpenShort', + 'perpsCloseShort', + 'perpsCloseShortLiquidated', + 'perpsCloseShortStopLoss', + 'perpsPaidFundingFees', + 'perpsReceivedFundingFees', + 'perpsCloseShortTakeProfit', + 'perpsCloseLongTakeProfit', + 'marketShort', + 'stopMarketCloseShort', + 'marketCloseShort', +]; + +const EXPECTED_TITLES = { + send: strings('transactions.sent'), + receive: strings('transactions.received'), + swap: strings('transactions.swaps_transaction'), + swapIncomplete: strings('transactions.swaps_transaction'), + bridge: strings('transactions.bridge_transaction'), + buy: strings('transactions.activity_buy'), + sell: strings('transactions.activity_sell'), + claim: strings('transactions.claim'), + claimMusdBonus: strings('transactions.activity_claim_musd_bonus'), + deposit: strings('transactions.tx_review_staking_deposit'), + convert: strings('transactions.tx_review_musd_conversion'), + wrap: strings('transactions.activity_wrap'), + unwrap: strings('transactions.activity_unwrap'), + approveSpendingCap: strings('transactions.tx_review_approve'), + revokeSpendingCap: strings('transactions.activity_revoke_spending_cap'), + increaseSpendingCap: strings('transactions.tx_review_increase_allowance'), + lendingDeposit: strings('transactions.tx_review_lending_deposit'), + lendingWithdrawal: strings('transactions.tx_review_lending_withdraw'), + nftMint: strings('transactions.activity_nft_mint'), + contractInteraction: strings('transactions.smart_contract_interaction'), + contractDeployment: strings('transactions.tx_review_contract_deployment'), + smartAccountUpgrade: strings('transactions.activity_smart_account_upgrade'), + predictionsAddFunds: strings('transactions.tx_review_predict_deposit'), + predictionsWithdrawFunds: strings('transactions.tx_review_predict_withdraw'), + predictionClaimWinnings: strings('transactions.tx_review_predict_claim'), + predictionCashedOut: strings('transactions.activity_prediction_cashed_out'), + predictionPlaced: strings('transactions.activity_prediction_placed'), + perpsAddFunds: strings('transactions.tx_review_perps_deposit'), + perpsWithdrawFunds: strings('transactions.tx_review_perps_withdraw'), + perpsOpenLong: strings('transactions.activity_perps_open_long'), + perpsCloseLong: strings('transactions.activity_perps_close_long'), + perpsCloseLongLiquidated: strings( + 'transactions.activity_perps_close_long_liquidated', + ), + perpsCloseLongStopLoss: strings( + 'transactions.activity_perps_close_long_stop_loss', + ), + perpsOpenShort: strings('transactions.activity_perps_open_short'), + perpsCloseShort: strings('transactions.activity_perps_close_short'), + perpsCloseShortLiquidated: strings( + 'transactions.activity_perps_close_short_liquidated', + ), + perpsCloseShortStopLoss: strings( + 'transactions.activity_perps_close_short_stop_loss', + ), + perpsPaidFundingFees: strings( + 'transactions.activity_perps_paid_funding_fees', + ), + perpsReceivedFundingFees: strings( + 'transactions.activity_perps_received_funding_fees', + ), + perpsCloseShortTakeProfit: strings( + 'transactions.activity_perps_close_short_take_profit', + ), + perpsCloseLongTakeProfit: strings( + 'transactions.activity_perps_close_long_take_profit', + ), + marketShort: strings('transactions.activity_market_short'), + stopMarketCloseShort: strings( + 'transactions.activity_stop_market_close_short', + ), + marketCloseShort: strings('transactions.activity_market_close_short'), +} satisfies Record; + +describe('ActivityListItemRow — title display for all ActivityKind values', () => { + it.each(ALL_KINDS)('renders the explicit title for type=%s', (type) => { + const item = makeItem({ type, status: 'success' }); + const { getByText, queryByText } = render( + , + ); + + expect(getByText(EXPECTED_TITLES[type])).toBeOnTheScreen(); + expect(queryByText(strings('transactions.interaction'))).toBeNull(); + }); + + it('prefers the title override when provided (legacy swap/bridge contract)', () => { + const item = makeItem({ type: 'swap', status: 'success' }); + const { getByText, queryByText } = render( + , + ); + + expect(getByText('Swap ETH to USDC')).toBeOnTheScreen(); + expect(queryByText(strings('transactions.swaps_transaction'))).toBeNull(); + }); +}); diff --git a/app/components/UI/ActivityListItemRow/ActivityListItemRow.tsx b/app/components/UI/ActivityListItemRow/ActivityListItemRow.tsx new file mode 100644 index 000000000000..77dbb437ef6b --- /dev/null +++ b/app/components/UI/ActivityListItemRow/ActivityListItemRow.tsx @@ -0,0 +1,544 @@ +/** + * Row component that renders a single ActivityListItem from the shared adapter shape. + * All styling and localization are Mobile-specific; data comes from the shared adapters. + */ +import React, { useCallback } from 'react'; +import { + Image, + Text, + TextStyle, + TouchableHighlight, + useColorScheme, + StyleSheet, +} from 'react-native'; +import { useSelector } from 'react-redux'; +import type { Hex } from '@metamask/utils'; +import { useTheme } from '../../../util/theme'; +import { getTransactionIcon } from '../../../util/transaction-icons'; +import { toDateFormat } from '../../../util/date'; +import { strings } from '../../../../locales/i18n'; +import ListItem from '../../Base/ListItem'; +import BadgeWrapper from '../../../component-library/components/Badges/BadgeWrapper'; +import Badge, { + BadgeVariant, +} from '../../../component-library/components/Badges/Badge'; +import { AvatarSize } from '../../../component-library/components/Avatars/Avatar'; +import { getNetworkImageSource } from '../../../util/networks'; +import { RootState } from '../../../reducers'; +import { AppThemeKey } from '../../../util/theme/models'; +import { + getFontFamily, + TextVariant, +} from '../../../component-library/components/Texts/Text'; +import { + applyDisplaySign, + getDisplaySignPrefix, + getHumanReadableTokenAmount, + toMarketRateLookupToken, + type ActivityListItem, + type ActivityKind, + type Status, + type TokenAmount, +} from '../../../util/activity-adapters'; +import { + selectCurrencyRateForChainId, + selectCurrentCurrency, +} from '../../../selectors/currencyRateController'; +import { selectTokenMarketData } from '../../../selectors/tokenRatesController'; +import { NATIVE_TOKEN_ADDRESS } from '../../../util/activity-adapters/adapters/shims'; +import { balanceToFiatNumber, renderFiat } from '../../../util/number/bigint'; + +type TokenMarketData = ReturnType; + +type FiatCurrencyCode = Parameters[1]; + +// --------------------------------------------------------------------------- +// Chain/token helpers +// --------------------------------------------------------------------------- + +function resolveDisplayToken(item: ActivityListItem): TokenAmount | undefined { + const { data } = item; + + if ('destinationToken' in data && data.destinationToken?.symbol) { + return data.destinationToken; + } + + if ('sourceToken' in data && data.sourceToken?.symbol) { + return data.sourceToken; + } + + if ('token' in data && data.token?.symbol) { + return data.token; + } + + return undefined; +} + +const getHexChainId = (chainId: string): Hex | undefined => { + const decimalChainId = chainId.split(':')[1]; + if (!decimalChainId) { + return undefined; + } + + return `0x${Number.parseInt(decimalChainId, 10).toString(16)}` as Hex; +}; + +function getTokenPrice({ + token, + hexChainId, + marketData, +}: { + token: TokenAmount; + hexChainId: Hex; + marketData: TokenMarketData; +}): number | undefined { + const marketRateToken = toMarketRateLookupToken(token, hexChainId); + + if (!marketRateToken) { + return undefined; + } + + const tokenAddress = marketRateToken.address as Hex; + + if (tokenAddress === NATIVE_TOKEN_ADDRESS) { + return 1; + } + + const chainMarketData = marketData?.[hexChainId]; + if (!chainMarketData) { + return undefined; + } + + const exactPrice = chainMarketData[tokenAddress]?.price; + if (exactPrice !== undefined) { + return exactPrice; + } + + const normalizedTokenAddress = tokenAddress.toLowerCase(); + const marketDataAddress = Object.keys(chainMarketData).find( + (address) => address.toLowerCase() === normalizedTokenAddress, + ); + + return marketDataAddress + ? chainMarketData[marketDataAddress as Hex]?.price + : undefined; +} + +function resolveFiatAmount({ + token, + hexChainId, + marketData, + nativeConversionRate, + currentCurrency, +}: { + token: TokenAmount | undefined; + hexChainId: Hex | undefined; + marketData: TokenMarketData; + nativeConversionRate: number | undefined; + currentCurrency: string | undefined; +}): string | undefined { + if ( + !token || + !hexChainId || + !nativeConversionRate || + !currentCurrency || + token.amount === undefined + ) { + return undefined; + } + + const amount = getHumanReadableTokenAmount(token); + const tokenPrice = getTokenPrice({ token, hexChainId, marketData }); + + if (!amount || tokenPrice === undefined) { + return undefined; + } + + const fiatNumber = balanceToFiatNumber( + amount, + nativeConversionRate, + tokenPrice, + ); + const fiatAmount = renderFiat( + fiatNumber, + currentCurrency as FiatCurrencyCode, + 2, + ); + const signPrefix = getDisplaySignPrefix(token.direction, { showPlus: true }); + + return applyDisplaySign(fiatAmount, signPrefix); +} + +// --------------------------------------------------------------------------- +// Status → display +// --------------------------------------------------------------------------- + +interface StatusDisplay { + label: string; + colorKey: 'success' | 'warning' | 'error' | 'muted'; +} + +const STATUS_DISPLAY = { + success: { label: strings('transaction.confirmed'), colorKey: 'success' }, + failed: { label: strings('transaction.failed'), colorKey: 'error' }, + cancelled: { label: strings('transaction.cancelled'), colorKey: 'error' }, + pending: { + label: strings('transaction.submitted'), + colorKey: 'warning', + }, +} satisfies Record; + +/** Maps all adapter Status values to display labels and colors. */ +function resolveStatus(status: Status): StatusDisplay { + return STATUS_DISPLAY[status]; +} + +// --------------------------------------------------------------------------- +// ActivityKind → title +// --------------------------------------------------------------------------- + +type ActivityTitleResolver = (token?: TokenAmount) => string; + +const withSymbol = ( + token: TokenAmount | undefined, + symbolKey: string, + fallbackKey: string, +) => { + const symbol = token?.symbol ?? ''; + return symbol ? strings(symbolKey, { unit: symbol }) : strings(fallbackKey); +}; + +const ACTIVITY_TITLE_RESOLVERS = { + send: (token) => + withSymbol(token, 'transactions.sent_unit', 'transactions.sent'), + receive: (token) => + withSymbol(token, 'transactions.received_unit', 'transactions.received'), + swap: () => strings('transactions.swaps_transaction'), + swapIncomplete: () => strings('transactions.swaps_transaction'), + bridge: () => strings('transactions.bridge_transaction'), + buy: () => strings('transactions.activity_buy'), + sell: () => strings('transactions.activity_sell'), + claim: () => strings('transactions.claim'), + claimMusdBonus: () => strings('transactions.activity_claim_musd_bonus'), + deposit: () => strings('transactions.tx_review_staking_deposit'), + convert: () => strings('transactions.tx_review_musd_conversion'), + wrap: () => strings('transactions.activity_wrap'), + unwrap: () => strings('transactions.activity_unwrap'), + approveSpendingCap: () => strings('transactions.tx_review_approve'), + revokeSpendingCap: () => strings('transactions.activity_revoke_spending_cap'), + increaseSpendingCap: () => + strings('transactions.tx_review_increase_allowance'), + lendingDeposit: () => strings('transactions.tx_review_lending_deposit'), + lendingWithdrawal: () => strings('transactions.tx_review_lending_withdraw'), + nftMint: () => strings('transactions.activity_nft_mint'), + contractInteraction: () => strings('transactions.smart_contract_interaction'), + contractDeployment: () => + strings('transactions.tx_review_contract_deployment'), + smartAccountUpgrade: () => + strings('transactions.activity_smart_account_upgrade'), + predictionsAddFunds: () => strings('transactions.tx_review_predict_deposit'), + predictionsWithdrawFunds: () => + strings('transactions.tx_review_predict_withdraw'), + predictionClaimWinnings: () => + strings('transactions.tx_review_predict_claim'), + predictionCashedOut: () => + strings('transactions.activity_prediction_cashed_out'), + predictionPlaced: () => strings('transactions.activity_prediction_placed'), + perpsAddFunds: () => strings('transactions.tx_review_perps_deposit'), + perpsWithdrawFunds: () => strings('transactions.tx_review_perps_withdraw'), + perpsOpenLong: () => strings('transactions.activity_perps_open_long'), + perpsCloseLong: () => strings('transactions.activity_perps_close_long'), + perpsCloseLongLiquidated: () => + strings('transactions.activity_perps_close_long_liquidated'), + perpsCloseLongStopLoss: () => + strings('transactions.activity_perps_close_long_stop_loss'), + perpsOpenShort: () => strings('transactions.activity_perps_open_short'), + perpsCloseShort: () => strings('transactions.activity_perps_close_short'), + perpsCloseShortLiquidated: () => + strings('transactions.activity_perps_close_short_liquidated'), + perpsCloseShortStopLoss: () => + strings('transactions.activity_perps_close_short_stop_loss'), + perpsPaidFundingFees: () => + strings('transactions.activity_perps_paid_funding_fees'), + perpsReceivedFundingFees: () => + strings('transactions.activity_perps_received_funding_fees'), + perpsCloseShortTakeProfit: () => + strings('transactions.activity_perps_close_short_take_profit'), + perpsCloseLongTakeProfit: () => + strings('transactions.activity_perps_close_long_take_profit'), + marketShort: () => strings('transactions.activity_market_short'), + stopMarketCloseShort: () => + strings('transactions.activity_stop_market_close_short'), + marketCloseShort: () => strings('transactions.activity_market_close_short'), +} satisfies Record; + +/** Returns a display title for each ActivityKind. */ +function resolveTitle(type: ActivityKind, token?: TokenAmount): string { + return ACTIVITY_TITLE_RESOLVERS[type](token); +} + +export function resolveActivityListItemTitle( + item: ActivityListItem, + titleOverride?: string, +): string { + if (titleOverride) { + return titleOverride; + } + + const primaryToken = + 'token' in item.data + ? (item.data as { token?: TokenAmount }).token + : 'sourceToken' in item.data + ? (item.data as { sourceToken?: TokenAmount }).sourceToken + : undefined; + + return resolveTitle(item.type, primaryToken); +} + +// --------------------------------------------------------------------------- +// ActivityKind → icon type string (matches getTransactionIcon switch cases) +// --------------------------------------------------------------------------- + +function resolveIconType(type: ActivityKind): string { + switch (type) { + case 'send': + case 'sell': + case 'lendingDeposit': + case 'deposit': + case 'wrap': + case 'perpsAddFunds': + case 'predictionsAddFunds': + return 'send'; + case 'receive': + case 'buy': + case 'claim': + case 'claimMusdBonus': + case 'lendingWithdrawal': + case 'unwrap': + case 'nftMint': + case 'perpsWithdrawFunds': + case 'predictionsWithdrawFunds': + case 'predictionClaimWinnings': + case 'predictionCashedOut': + case 'predictionPlaced': + case 'perpsReceivedFundingFees': + return 'receive'; + case 'swap': + case 'swapIncomplete': + case 'bridge': + case 'convert': + return 'swap'; + case 'approveSpendingCap': + case 'revokeSpendingCap': + case 'increaseSpendingCap': + case 'contractInteraction': + case 'contractDeployment': + case 'smartAccountUpgrade': + case 'perpsOpenLong': + case 'perpsCloseLong': + case 'perpsCloseLongLiquidated': + case 'perpsCloseLongStopLoss': + case 'perpsOpenShort': + case 'perpsCloseShort': + case 'perpsCloseShortLiquidated': + case 'perpsCloseShortStopLoss': + case 'perpsPaidFundingFees': + case 'perpsCloseShortTakeProfit': + case 'perpsCloseLongTakeProfit': + case 'marketShort': + case 'stopMarketCloseShort': + case 'marketCloseShort': + return 'interaction'; + } +} + +// --------------------------------------------------------------------------- +// Amount display +// --------------------------------------------------------------------------- + +function resolveAmount(token: TokenAmount | undefined): string { + if (!token?.symbol) { + return ''; + } + + const amount = getHumanReadableTokenAmount(token); + const signPrefix = getDisplaySignPrefix(token.direction, { showPlus: true }); + const value = amount ? `${amount} ${token.symbol}` : token.symbol; + + return applyDisplaySign(value, signPrefix); +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const createStyles = ( + colors: ReturnType['colors'], + typography: ReturnType['typography'], +) => + StyleSheet.create({ + row: { + backgroundColor: colors.background.default, + flex: 1, + borderBottomWidth: 1, + borderBottomColor: colors.border.muted, + }, + icon: { + width: 32, + height: 32, + }, + listItemDate: { + marginBottom: 10, + paddingBottom: 0, + }, + listItemContent: { + alignItems: 'flex-start', + marginTop: 0, + paddingTop: 0, + }, + listItemTitle: { + ...typography.sBodyLGMedium, + fontFamily: getFontFamily(TextVariant.BodyLGMedium), + marginTop: 0, + color: colors.text.default, + } as TextStyle, + statusText: { + ...typography.sBodyMDBold, + fontFamily: getFontFamily(TextVariant.BodyMDBold), + marginTop: 4, + fontSize: 12, + letterSpacing: 0.5, + } as TextStyle, + listItemAmount: { + ...typography.sBodyMD, + fontFamily: getFontFamily(TextVariant.BodyMD), + color: colors.text.alternative, + } as TextStyle, + }); + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface ActivityListItemRowProps { + item: ActivityListItem; + index?: number; + onPress?: (item: ActivityListItem) => void; + /** + * Optional pre-resolved title. Used to preserve the legacy Activity contract + * for swap/bridge rows (e.g. "Swap ETH to USDC", "Bridge to Optimism"), which + * the parent derives from bridge history. Falls back to the kind-based title. + */ + title?: string; +} + +export function ActivityListItemRow({ + item, + index, + onPress, + title: titleOverride, +}: ActivityListItemRowProps) { + const { colors, typography } = useTheme(); + const osColorScheme = useColorScheme(); + const appTheme = useSelector( + (state: RootState) => state.user.appTheme as AppThemeKey, + ); + + const styles = createStyles(colors, typography); + const isFailed = item.status === 'failed' || item.status === 'cancelled'; + const iconType = resolveIconType(item.type); + const icon = getTransactionIcon(iconType, isFailed, appTheme, osColorScheme); + + const statusDisplay = resolveStatus(item.status); + const statusColor = + statusDisplay.colorKey === 'success' + ? colors.success.default + : statusDisplay.colorKey === 'error' + ? colors.error.default + : statusDisplay.colorKey === 'warning' + ? colors.warning.default + : colors.text.muted; + + const title = resolveActivityListItemTitle(item, titleOverride); + const displayToken = resolveDisplayToken(item); + const amount = resolveAmount(displayToken); + const marketData = useSelector(selectTokenMarketData); + const currentCurrency = useSelector(selectCurrentCurrency); + const hexChainId = getHexChainId(item.chainId); + const nativeConversionRate = useSelector((state: RootState) => + hexChainId ? selectCurrencyRateForChainId(state, hexChainId) : undefined, + ); + const fiatAmount = resolveFiatAmount({ + token: displayToken, + hexChainId, + marketData, + nativeConversionRate, + currentCurrency, + }); + + const networkImageSource = getNetworkImageSource({ + chainId: item.chainId, + }); + + const handlePress = useCallback(() => { + onPress?.(item); + }, [onPress, item]); + + return ( + + + + {toDateFormat(new Date(item.timestamp))} + + + + + } + > + + + + + + {title} + + + {statusDisplay.label} + + + {Boolean(amount) && ( + + {Boolean(fiatAmount) && ( + + {fiatAmount} + + )} + + {amount} + + + )} + + + + ); +} + +export default ActivityListItemRow; diff --git a/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx b/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx index c622c906f536..81ed4511ad8c 100644 --- a/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx +++ b/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx @@ -372,7 +372,7 @@ describe('PriceAdvanced', () => { }); it('calculates percentage from OHLCV close price of the reference candle', () => { - // Reference candle close = 100, current price = 105 + // prevBar close = 100, current price = 105 // Expected: (105 - 100) / 100 * 100 = 5.00% const now = Date.now(); const oneDayAgo = now - 24 * 60 * 60 * 1000; @@ -398,7 +398,7 @@ describe('PriceAdvanced', () => { open: 1, high: 1, low: 1, - close: 1, + close: 100, volume: 1, }, ]; @@ -455,7 +455,7 @@ describe('PriceAdvanced', () => { }); it('updates percentage when time range changes and new OHLCV data loads', () => { - // Initial: reference candle close = 100, current price = 105 + // Initial: prevBar close = 100, current price = 105 // Expected: (105 - 100) / 100 * 100 = 5.00% const now = Date.now(); const oneDayAgo = now - 24 * 60 * 60 * 1000; @@ -481,7 +481,7 @@ describe('PriceAdvanced', () => { open: 1, high: 1, low: 1, - close: 1, + close: 100, volume: 1, }, ]; @@ -519,11 +519,12 @@ describe('PriceAdvanced', () => { expect(getByText(/5\.00%/)).toBeOnTheScreen(); - // After time range change: reference candle close = 103, current = 105 + // After time range change: prevBar close = 103, current = 105 // Expected: (105 - 103) / 103 * 100 = 1.94% mockUseOHLCVChart.mockReturnValueOnce({ ohlcvData: [ - ...ohlcvPadBefore, + ...ohlcvPadBefore.slice(0, -1), + { ...ohlcvPadBefore[2], close: 103 }, { time: oneDayAgo, open: 102, @@ -554,7 +555,7 @@ describe('PriceAdvanced', () => { }); it('displays price diff when dynamicComparePrice is 0', () => { - // Edge case: reference candle close is 0 — should still render, not hide + // Edge case: prevBar close is 0 — should still render, not hide const now = Date.now(); const oneDayAgo = now - 24 * 60 * 60 * 1000; const ohlcvPadBefore = [ @@ -579,7 +580,7 @@ describe('PriceAdvanced', () => { open: 1, high: 1, low: 1, - close: 1, + close: 0, volume: 1, }, ]; @@ -648,13 +649,13 @@ describe('PriceAdvanced', () => { close: 200, volume: 1, }, - // 2 days ago — still before visible range, should be skipped + // 2 days ago — prevBar (last candle before visible range) { time: lastBarTime - 2 * oneDayMs, open: 190, high: 195, low: 185, - close: 190, + close: 100, volume: 1, }, // ~24h ago — first candle in visible range (close = 100) @@ -1047,12 +1048,19 @@ describe('PriceAdvanced', () => { // lastBarTime = 100000000, visibleFromMs = 13600000 // First visible candle at time 20000000 has close=100 // Last candle has close=95 - // displayDiff = 95 - 100 = -5 (negative) + // prevBar.close = 100, displayPrice = 95, diff = -5 (negative) mockUseOHLCVChart.mockReturnValueOnce({ ohlcvData: [ { time: 1000000, open: 90, high: 91, low: 89, close: 90, volume: 1 }, { time: 2000000, open: 90, high: 91, low: 89, close: 91, volume: 1 }, - { time: 3000000, open: 91, high: 92, low: 90, close: 92, volume: 1 }, + { + time: 3000000, + open: 91, + high: 101, + low: 90, + close: 100, + volume: 1, + }, { time: 20000000, open: 100, @@ -1108,11 +1116,19 @@ describe('PriceAdvanced', () => { const mockOnPriceDirectionChange = jest.fn(); // Mock OHLCV data with negative price movement + // prevBar.close = 100, lastBar.close = 95 → negative diff mockUseOHLCVChart.mockReturnValueOnce({ ohlcvData: [ { time: 1000000, open: 90, high: 91, low: 89, close: 90, volume: 1 }, { time: 2000000, open: 90, high: 91, low: 89, close: 91, volume: 1 }, - { time: 3000000, open: 91, high: 92, low: 90, close: 92, volume: 1 }, + { + time: 3000000, + open: 91, + high: 101, + low: 90, + close: 100, + volume: 1, + }, { time: 20000000, open: 100, diff --git a/app/components/UI/AssetOverview/Price/Price.advanced.tsx b/app/components/UI/AssetOverview/Price/Price.advanced.tsx index 75482775c592..7bb681c43d04 100644 --- a/app/components/UI/AssetOverview/Price/Price.advanced.tsx +++ b/app/components/UI/AssetOverview/Price/Price.advanced.tsx @@ -135,6 +135,7 @@ export interface PriceAdvancedProps { setTimePeriod?: (period: TimePeriod) => void; onPriceDirectionChange?: (isPositive: boolean) => void; useAmbientColor?: boolean; + hasInsufficientCoverage?: boolean; } const PriceAdvanced = ({ @@ -150,6 +151,7 @@ const PriceAdvanced = ({ setTimePeriod, onPriceDirectionChange, useAmbientColor = false, + hasInsufficientCoverage = false, }: PriceAdvancedProps) => { const dispatch = useDispatch(); const { trackEvent, createEventBuilder } = useAnalytics(); @@ -559,6 +561,7 @@ const PriceAdvanced = ({ isLoading={isLoading} onPriceDirectionChange={onPriceDirectionChange} useAmbientColor={useAmbientColor} + hasInsufficientCoverage={hasInsufficientCoverage} /> ); } diff --git a/app/components/UI/AssetOverview/Price/Price.legacy.tsx b/app/components/UI/AssetOverview/Price/Price.legacy.tsx index 0797416f29ea..7f66a3c5b005 100644 --- a/app/components/UI/AssetOverview/Price/Price.legacy.tsx +++ b/app/components/UI/AssetOverview/Price/Price.legacy.tsx @@ -39,6 +39,7 @@ export interface PriceLegacyProps { onTimePeriodChange?: (period: TimePeriod) => void; onPriceDirectionChange?: (isPositive: boolean) => void; useAmbientColor?: boolean; + hasInsufficientCoverage?: boolean; } const PriceLegacy = ({ @@ -53,6 +54,7 @@ const PriceLegacy = ({ onTimePeriodChange, onPriceDirectionChange, useAmbientColor = false, + hasInsufficientCoverage = false, }: PriceLegacyProps) => { const [activeChartIndex, setActiveChartIndex] = useState(-1); @@ -223,6 +225,7 @@ const PriceLegacy = ({ isLoading={isLoading} onChartIndexChange={handleChartInteraction} chartColorOverride={initialAmbientColor} + hasInsufficientCoverage={hasInsufficientCoverage} /> {chartNavigationButtons.length > 0 && onTimePeriodChange && ( diff --git a/app/components/UI/AssetOverview/Price/Price.tsx b/app/components/UI/AssetOverview/Price/Price.tsx index 1f6524ccd972..6da2fa2553f7 100644 --- a/app/components/UI/AssetOverview/Price/Price.tsx +++ b/app/components/UI/AssetOverview/Price/Price.tsx @@ -15,6 +15,7 @@ interface PriceSharedProps { currentCurrency: string; comparePrice: number; isLoading: boolean; + hasInsufficientCoverage?: boolean; } /** diff --git a/app/components/UI/AssetOverview/PriceChart/PriceChart.test.tsx b/app/components/UI/AssetOverview/PriceChart/PriceChart.test.tsx index a78890941503..8fbd2d1db88e 100644 --- a/app/components/UI/AssetOverview/PriceChart/PriceChart.test.tsx +++ b/app/components/UI/AssetOverview/PriceChart/PriceChart.test.tsx @@ -118,6 +118,33 @@ describe('PriceChart', () => { queryByText('Data is not available for this time period'), ).not.toBeOnTheScreen(); }); + + it('shows overlay when hasInsufficientCoverage is true despite having enough points', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('price-chart-insufficient-data')).toBeOnTheScreen(); + }); + + it('does not show overlay when hasInsufficientCoverage is false', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId('price-chart-insufficient-data'), + ).not.toBeOnTheScreen(); + expect(queryByTestId('price-chart-no-data')).not.toBeOnTheScreen(); + }); }); describe('Loading state', () => { diff --git a/app/components/UI/AssetOverview/PriceChart/PriceChart.tsx b/app/components/UI/AssetOverview/PriceChart/PriceChart.tsx index 07d4553cf5d6..f7c5610984f7 100644 --- a/app/components/UI/AssetOverview/PriceChart/PriceChart.tsx +++ b/app/components/UI/AssetOverview/PriceChart/PriceChart.tsx @@ -50,6 +50,12 @@ interface PriceChartProps { chartHeight?: number; /** Override line color (A/B test). */ chartColorOverride?: string; + /** + * When true, the historical-prices API returned data covering less than + * 75% of the requested time period. The chart shows a "no data" overlay + * instead of rendering a misleading partial chart. + */ + hasInsufficientCoverage?: boolean; } const PriceChart = ({ @@ -59,6 +65,7 @@ const PriceChart = ({ onChartIndexChange, chartHeight = TOKEN_OVERVIEW_CHART_HEIGHT, chartColorOverride, + hasInsufficientCoverage = false, }: PriceChartProps) => { const { trackEvent, createEventBuilder } = useAnalytics(); const emptyDisplayTrackedRef = useRef(false); @@ -128,9 +135,11 @@ const PriceChart = ({ }; }, [priceList]); - const chartHasData = priceList.length >= CHART_DATA_THRESHOLD; + const chartHasData = + priceList.length >= CHART_DATA_THRESHOLD && !hasInsufficientCoverage; const hasInsufficientData = - priceList.length > 0 && priceList.length < CHART_DATA_THRESHOLD; + (priceList.length > 0 && priceList.length < CHART_DATA_THRESHOLD) || + hasInsufficientCoverage; useEffect(() => { if (chartHasData || isLoading) { diff --git a/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.test.tsx b/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.test.tsx index e88bcfd80842..14a739c144c4 100644 --- a/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.test.tsx +++ b/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.test.tsx @@ -55,6 +55,10 @@ jest.mock('react-redux', () => ({ useSelector: (selector: (state: unknown) => unknown) => selector({}), })); +jest.mock('../../../../hooks/useRefreshSmartTransactionsLiveness', () => ({ + useRefreshSmartTransactionsLiveness: jest.fn(), +})); + jest.mock('@metamask/design-system-react-native', () => { const DesignSystem = jest.requireActual( '@metamask/design-system-react-native', diff --git a/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.tsx b/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.tsx index f3f78b45d204..da2f13a3b137 100644 --- a/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.tsx +++ b/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.tsx @@ -61,6 +61,7 @@ import { BatchSellEmptyState } from './BatchSellEmptyState'; import { DEFAULT_BATCH_SELL_SLIPPAGE } from '../../components/SlippageModal/utils'; import { normalizeTokenAddress } from '../../utils/tokenUtils'; import { useBatchSellTokens } from './useBatchSellTokens'; +import { useRefreshSmartTransactionsLiveness } from '../../../../hooks/useRefreshSmartTransactionsLiveness'; const getTokenKey = (token: BridgeToken) => `${formatChainIdToCaip(token.chainId)}:${normalizeTokenAddress( @@ -175,6 +176,10 @@ export function BatchSellTokenSelect() { }, [selectedChainId, sortedEligibleChains]); const activeChainId = selectedChainId ?? sortedEligibleChains[0]?.chainId; + + // Fetch STX liveness for the active batch sell source chain + useRefreshSmartTransactionsLiveness(activeChainId); + const destinationStablecoins = useSelector((state: RootState) => selectBatchSellDestStablecoins(state, activeChainId), ); diff --git a/app/components/UI/Bridge/utils/exchange-rates.test.tsx b/app/components/UI/Bridge/utils/exchange-rates.test.tsx index fedb9fb257b9..5a5fe712f1e0 100644 --- a/app/components/UI/Bridge/utils/exchange-rates.test.tsx +++ b/app/components/UI/Bridge/utils/exchange-rates.test.tsx @@ -753,6 +753,7 @@ describe('exchange-rates', () => { const result = await fetchTokenExchangeRates( solanaChainId, currency, + undefined, ...tokenAddresses, ); @@ -771,6 +772,7 @@ describe('exchange-rates', () => { const result = await fetchTokenExchangeRates( solanaChainId, currency, + undefined, ...tokenAddresses, ); @@ -795,6 +797,7 @@ describe('exchange-rates', () => { const result = await fetchTokenExchangeRates( evmChainId, currency, + undefined, ...tokenAddresses, ); @@ -810,6 +813,7 @@ describe('exchange-rates', () => { const result = await fetchTokenExchangeRates( evmChainId, currency, + undefined, ...tokenAddresses, ); @@ -823,7 +827,12 @@ describe('exchange-rates', () => { new Error('API error'), ); - const result = await fetchTokenExchangeRates('0x1', 'USD', '0x123'); + const result = await fetchTokenExchangeRates( + '0x1', + 'USD', + undefined, + '0x123', + ); expect(result).toEqual({}); }); diff --git a/app/components/UI/Bridge/utils/exchange-rates.ts b/app/components/UI/Bridge/utils/exchange-rates.ts index 35ce83e66182..4ae5693a8d5a 100644 --- a/app/components/UI/Bridge/utils/exchange-rates.ts +++ b/app/components/UI/Bridge/utils/exchange-rates.ts @@ -19,6 +19,7 @@ import { CodefiTokenPricesServiceV2, ContractMarketData, fetchTokenContractExchangeRates, + type MarketDataDetails, } from '@metamask/assets-controllers'; import { safeToChecksumAddress } from '../../../../util/address'; import { toAssetId } from '../hooks/useAssetMetadata/utils'; @@ -259,20 +260,24 @@ export const getDisplayCurrencyValue = ({ }; /** - * Fetches the exchange rates for the tokens against the current currency + * Fetches the exchange rates for the tokens against the current currency. * @param chainId - The chainId of the tokens * @param currency - The currency to fetch the exchange rates in + * @param options - Optional settings + * @param options.includeMarketData - When true, returns full MarketDataDetails + * per token instead of just the price number. * @param tokenAddresses - The addresses of the tokens to fetch the exchange rates for - * @returns Exchange rate for the tokens against the current currency + * @returns Exchange rates (or full market data) for the tokens */ export const fetchTokenExchangeRates = async ( chainId: Hex | CaipChainId, currency: string, + options?: { includeMarketData?: boolean }, ...tokenAddresses: string[] ) => { - try { - let exchangeRates: Record = {}; + const withMarketData = options?.includeMarketData === true; + try { // Non-EVM if (isNonEvmChainId(chainId) && isCaipChainId(chainId)) { const queryParams = new URLSearchParams({ @@ -285,17 +290,20 @@ export const fetchTokenExchangeRates = async ( const url = `https://price.api.cx.metamask.io/v3/spot-prices?${queryParams}`; const tokenV3PriceResponse = (await handleFetch(url)) as Record< string, - { price: number } + MarketDataDetails >; - exchangeRates = Object.entries(tokenV3PriceResponse).reduce( + if (withMarketData) { + return tokenV3PriceResponse; + } + + return Object.entries(tokenV3PriceResponse).reduce( (acc, [k, curr]) => { acc[k] = curr.price; return acc; }, {} as Record, ); - return exchangeRates; } // EVM chains @@ -306,12 +314,23 @@ export const fetchTokenExchangeRates = async ( return {}; } - exchangeRates = await fetchTokenContractExchangeRates({ + if (withMarketData) { + const marketData = await fetchTokenContractExchangeRates({ + tokenPricesService: new CodefiTokenPricesServiceV2(), + nativeCurrency: currency, + tokenAddresses: checksumAddresses as Hex[], + chainId: formatChainIdToHex(chainId), + includeMarketData: true, + }); + return marketData; + } + + const exchangeRates = (await fetchTokenContractExchangeRates({ tokenPricesService: new CodefiTokenPricesServiceV2(), nativeCurrency: currency, tokenAddresses: checksumAddresses as Hex[], chainId: formatChainIdToHex(chainId), - }); + })) as Record; return Object.keys(exchangeRates).reduce( (acc: Record, address) => { @@ -325,29 +344,35 @@ export const fetchTokenExchangeRates = async ( } }; -// This fetches the exchange rate for a token in a given currency. This is only called when the exchange -// rate is not available in the TokenRatesController, which happens when the selected token has not been -// imported into the wallet +/** + * Fetches the exchange rate for a single token. Only called when the rate is + * not available in the TokenRatesController (i.e. token is not imported). + * When includeMarketData is true, returns full MarketDataDetails instead of + * just the price number. + */ export const getTokenExchangeRate = async (request: { chainId: Hex | CaipChainId; tokenAddress: string; currency: string; + includeMarketData?: boolean; }) => { - const { chainId, tokenAddress, currency } = request; - const exchangeRates = await fetchTokenExchangeRates( + const { chainId, tokenAddress, currency, includeMarketData } = request; + + const result = (await fetchTokenExchangeRates( chainId, currency, + { includeMarketData }, tokenAddress, - ); + )) as Record; + const assetId = toAssetId(tokenAddress, formatChainIdToCaip(chainId)); if (isNonEvmChainId(chainId) && assetId) { - return exchangeRates?.[assetId]; + return result?.[assetId]; } - // The exchange rate can be checksummed or not, so we need to check both - const exchangeRate = - exchangeRates?.[toChecksumHexAddress(tokenAddress)] ?? - exchangeRates?.[tokenAddress.toLowerCase()]; - return exchangeRate; + return ( + result?.[toChecksumHexAddress(tokenAddress)] ?? + result?.[tokenAddress.toLowerCase()] + ); }; /** diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx index 5aab04e3b2b2..8a8b96553432 100644 --- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx +++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx @@ -15,6 +15,7 @@ import { selectIsCardholder, selectIsMoneyAccountCardLinkInProgress, selectIsMoneyAccountDelegatedForCard, + selectIsCardResidencyBlocked, selectMoneyAccountVedaTokenConfig, } from '../../../../selectors/cardController'; import { @@ -169,6 +170,7 @@ const buildSelectors = ( moneyAccountCardLinkInProgress?: boolean; cardFeatureFlag?: unknown; vedaConfig?: unknown; + isResidencyBlocked?: boolean; } = {}, ) => ({ primaryMoneyAccount: { address: MONEY_ACCOUNT_ADDRESS }, @@ -205,6 +207,7 @@ const buildSelectors = ( decimals: 6, delegationContract: '0xdelegation', }, + isResidencyBlocked: false, ...overrides, }); @@ -232,6 +235,8 @@ const applySelectorMocks = (state: ReturnType) => { return (_chainId: string) => state.isMonadSponsorshipEnabled; if (selector === selectCardFeatureFlag) return state.cardFeatureFlag; if (selector === selectMoneyAccountVedaTokenConfig) return state.vedaConfig; + if (selector === selectIsCardResidencyBlocked) + return state.isResidencyBlocked; return undefined; }); }; @@ -423,6 +428,19 @@ describe('useMoneyAccountCardLinkage', () => { expect(result.current.canLink).toBe(false); }); + it('reports canLink=false when card residency is blocked', () => { + applySelectorMocks(buildSelectors({ isResidencyBlocked: true })); + const { result } = renderLinkageHook(); + expect(result.current.canLink).toBe(false); + expect(result.current.isResidencyBlocked).toBe(true); + }); + + it('reports isResidencyBlocked=false when residency is not blocked', () => { + applySelectorMocks(buildSelectors({ isResidencyBlocked: false })); + const { result } = renderLinkageHook(); + expect(result.current.isResidencyBlocked).toBe(false); + }); + it('reports isLinking=true when controller linkage is in progress', () => { applySelectorMocks( buildSelectors({ moneyAccountCardLinkInProgress: true }), @@ -537,6 +555,28 @@ describe('useMoneyAccountCardLinkage', () => { expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled(); expect(mockShowToast).toHaveBeenCalledTimes(1); }); + + it('tracks RESIDENCY_BLOCKED and fails closed when residency is blocked', () => { + applySelectorMocks(buildSelectors({ isResidencyBlocked: true })); + const { result } = renderLinkageHook(); + + act(() => { + result.current.openLinkCardSheet(CardEntryPoint.MONEY_LINK_CARD_SHEET); + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledTimes(1); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ name: 'built-event' }), + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + reason: CardLinkingFailureReason.RESIDENCY_BLOCKED, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + }), + ); + }); }); describe('startLinkFlow (entrypoint branching)', () => { @@ -599,6 +639,29 @@ describe('useMoneyAccountCardLinkage', () => { expect(mockShowToast).toHaveBeenCalledTimes(1); }); + it('tracks RESIDENCY_BLOCKED and fails closed when residency is blocked', () => { + applySelectorMocks(buildSelectors({ isResidencyBlocked: true })); + const { result } = renderLinkageHook(); + + act(() => { + result.current.startLinkFlow({ + ...ORIGIN, + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + }); + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledTimes(1); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + reason: CardLinkingFailureReason.RESIDENCY_BLOCKED, + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + }), + ); + }); + it('shows the error toast when Monad USDC cannot be resolved AND the user is authenticated', () => { mockResolveMoneyAccountCardToken.mockReturnValueOnce(null); const { result } = renderLinkageHook(); @@ -811,6 +874,29 @@ describe('useMoneyAccountCardLinkage', () => { expect(mockShowToast).not.toHaveBeenCalled(); }); + it('tracks RESIDENCY_BLOCKED, shows error toast, and clears the flag when residency is blocked after auth', () => { + applySelectorMocks( + buildSelectors({ + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, + isResidencyBlocked: true, + }), + ); + renderLinkageHook(); + + expect(mockDispatch).toHaveBeenCalledWith( + setPendingMoneyAccountCardLink(null), + ); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledTimes(1); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + reason: CardLinkingFailureReason.RESIDENCY_BLOCKED, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + }), + ); + }); + it('clears the flag silently when authenticated but requirements are missing', () => { applySelectorMocks( buildSelectors({ @@ -1139,6 +1225,25 @@ describe('useMoneyAccountCardLinkage', () => { }); }); + it('tracks RESIDENCY_BLOCKED when residency is blocked and user is not already delegated', async () => { + applySelectorMocks(buildSelectors({ isResidencyBlocked: true })); + const { result } = renderLinkageHook(); + + let returned: boolean | undefined; + await act(async () => { + returned = await result.current.confirmLinkInBackground(); + }); + + expect(returned).toBe(false); + expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled(); + expect(mockAddProperties).toHaveBeenCalledWith({ + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + reason: CardLinkingFailureReason.RESIDENCY_BLOCKED, + is_revoke: false, + }); + }); + it('still submits the delegation when already delegated (Manage Limit update / revoke path)', async () => { applySelectorMocks(buildSelectors({ isAlreadyDelegated: true })); const { result } = renderLinkageHook(); diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx index b806bd4f316f..579b21614d07 100644 --- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx +++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx @@ -37,6 +37,7 @@ import { selectIsCardholder, selectIsMoneyAccountCardLinkInProgress, selectIsMoneyAccountDelegatedForCard, + selectIsCardResidencyBlocked, selectMoneyAccountVedaTokenConfig, } from '../../../../selectors/cardController'; import { @@ -85,6 +86,7 @@ export interface UseMoneyAccountCardLinkageReturn { primaryMoneyAccount: MoneyAccount | undefined; moneyAccountCardToken: CardFundingToken | null; canLink: boolean; + isResidencyBlocked: boolean; status: LinkageStatus; isLinking: boolean; @@ -134,6 +136,7 @@ export const useMoneyAccountCardLinkage = selectPendingMoneyAccountCardLink, ); const linkInProgress = useSelector(selectIsMoneyAccountCardLinkInProgress); + const isResidencyBlocked = useSelector(selectIsCardResidencyBlocked); const isMonadSponsorshipEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, )(vaultConfig?.chainId ?? ''); @@ -170,7 +173,9 @@ export const useMoneyAccountCardLinkage = moneyAccountCardToken && isMonadSponsorshipEnabled, ); - const canLink = Boolean(canSubmitDelegation && !isAlreadyDelegated); + const canLink = Boolean( + canSubmitDelegation && !isAlreadyDelegated && !isResidencyBlocked, + ); const showPendingToast = useCallback( (isRevoke: boolean = false) => { @@ -283,6 +288,15 @@ export const useMoneyAccountCardLinkage = return; } if (!canLink || !primaryMoneyAccount?.address) { + if (isResidencyBlocked) { + trackMoneyAccountLinkingEvent( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED, + { + entrypoint: entrypoint ?? CardEntryPoint.MONEY_LINK_CARD_SHEET, + reason: CardLinkingFailureReason.RESIDENCY_BLOCKED, + }, + ); + } showErrorToast(); return; } @@ -299,6 +313,8 @@ export const useMoneyAccountCardLinkage = primaryMoneyAccount?.address, navigation, showErrorToast, + isResidencyBlocked, + trackMoneyAccountLinkingEvent, ], ); @@ -312,6 +328,18 @@ export const useMoneyAccountCardLinkage = return; } + if (isResidencyBlocked) { + trackMoneyAccountLinkingEvent( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED, + { + entrypoint: origin.entrypoint, + reason: CardLinkingFailureReason.RESIDENCY_BLOCKED, + }, + ); + showErrorToast(); + return; + } + if (isCardAuthenticated) { if (!isCardVerified) { return; @@ -363,10 +391,12 @@ export const useMoneyAccountCardLinkage = isCardVerified, isAlreadyDelegated, isCardholder, + isResidencyBlocked, openLinkCardSheet, showErrorToast, navigation, dispatch, + trackMoneyAccountLinkingEvent, ], ); @@ -389,6 +419,19 @@ export const useMoneyAccountCardLinkage = return; } + if (isResidencyBlocked) { + trackMoneyAccountLinkingEvent( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED, + { + entrypoint: pendingMoneyAccountCardLinkEntryPoint, + reason: CardLinkingFailureReason.RESIDENCY_BLOCKED, + }, + ); + dispatch(setPendingMoneyAccountCardLink(null)); + showErrorToast(); + return; + } + if (isAlreadyDelegated) { dispatch(setPendingMoneyAccountCardLink(null)); return; @@ -415,9 +458,12 @@ export const useMoneyAccountCardLinkage = moneyAccountCardToken, primaryMoneyAccount?.address, isAlreadyDelegated, + isResidencyBlocked, cardHomeDataStatus, openLinkCardSheet, dispatch, + showErrorToast, + trackMoneyAccountLinkingEvent, ]); const confirmLinkInBackground = useCallback( @@ -430,13 +476,20 @@ export const useMoneyAccountCardLinkage = const isRevoke = options?.delegationAmountHuman !== undefined && parseFloat(options.delegationAmountHuman) === 0; + const isBlockedByResidency = isResidencyBlocked && !isAlreadyDelegated; - if (!canSubmitDelegation || !primaryMoneyAccount?.address) { + if ( + !canSubmitDelegation || + !primaryMoneyAccount?.address || + isBlockedByResidency + ) { trackMoneyAccountLinkingEvent( MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED, { entrypoint, - reason: CardLinkingFailureReason.PRECONDITION_FAILED, + reason: isBlockedByResidency + ? CardLinkingFailureReason.RESIDENCY_BLOCKED + : CardLinkingFailureReason.PRECONDITION_FAILED, is_revoke: isRevoke, }, ); @@ -533,6 +586,8 @@ export const useMoneyAccountCardLinkage = showPendingToast, showSuccessToast, trackMoneyAccountLinkingEvent, + isResidencyBlocked, + isAlreadyDelegated, ], ); @@ -549,6 +604,7 @@ export const useMoneyAccountCardLinkage = primaryMoneyAccount, moneyAccountCardToken, canLink, + isResidencyBlocked, status, isLinking: linkInProgress, diff --git a/app/components/UI/Card/util/metrics.ts b/app/components/UI/Card/util/metrics.ts index be47f0305fb6..c411fe0f5aea 100644 --- a/app/components/UI/Card/util/metrics.ts +++ b/app/components/UI/Card/util/metrics.ts @@ -93,6 +93,7 @@ enum CardFlow { enum CardLinkingFailureReason { PRECONDITION_FAILED = 'PRECONDITION_FAILED', + RESIDENCY_BLOCKED = 'RESIDENCY_BLOCKED', USER_CANCELLED = 'USER_CANCELLED', CONTROLLER_FAILED = 'CONTROLLER_FAILED', UNKNOWN = 'UNKNOWN', diff --git a/app/components/UI/Card/util/residency.test.ts b/app/components/UI/Card/util/residency.test.ts new file mode 100644 index 000000000000..ae24d5e88f85 --- /dev/null +++ b/app/components/UI/Card/util/residency.test.ts @@ -0,0 +1,52 @@ +import { + buildCardResidencyRegion, + isCardResidencyInBlockedRegions, +} from './residency'; + +describe('buildCardResidencyRegion', () => { + it('returns null when countryOfResidence is null', () => { + expect(buildCardResidencyRegion(null, 'CA')).toBeNull(); + }); + + it('returns the country code for non-US residents', () => { + expect(buildCardResidencyRegion('gb', null)).toBe('GB'); + }); + + it('returns US-{STATE} for US residents with a known state', () => { + expect(buildCardResidencyRegion('US', 'ca')).toBe('US-CA'); + }); + + it('returns US when state is unknown for a US resident', () => { + expect(buildCardResidencyRegion('US', null)).toBe('US'); + }); +}); + +describe('isCardResidencyInBlockedRegions', () => { + it('fail-opens when residency is unknown', () => { + expect(isCardResidencyInBlockedRegions(null, ['GB'])).toBe(false); + }); + + it('returns false when blocked regions is empty', () => { + expect(isCardResidencyInBlockedRegions('GB', [])).toBe(false); + }); + + it('blocks GB residents when GB is blocked', () => { + expect(isCardResidencyInBlockedRegions('GB', ['GB'])).toBe(true); + }); + + it('blocks all US residents when US is blocked', () => { + expect(isCardResidencyInBlockedRegions('US-CA', ['US'])).toBe(true); + }); + + it('blocks a specific US state when US-CA is blocked', () => { + expect(isCardResidencyInBlockedRegions('US-CA', ['US-CA'])).toBe(true); + }); + + it('does not block other US states when only US-CA is blocked', () => { + expect(isCardResidencyInBlockedRegions('US-NY', ['US-CA'])).toBe(false); + }); + + it('does not block US residents without state when only US-CA is blocked', () => { + expect(isCardResidencyInBlockedRegions('US', ['US-CA'])).toBe(false); + }); +}); diff --git a/app/components/UI/Card/util/residency.ts b/app/components/UI/Card/util/residency.ts new file mode 100644 index 000000000000..2077566dbaff --- /dev/null +++ b/app/components/UI/Card/util/residency.ts @@ -0,0 +1,41 @@ +/** + * Builds a residency region code from authenticated card KYC data. + * US cardholders with a known state use the `US-{STATE}` pattern (e.g. US-CA) + * so blocked-region lists can target specific states via + * `selectMusdConversionBlockedCountries`. + */ +export const buildCardResidencyRegion = ( + countryOfResidence: string | null, + usState: string | null, +): string | null => { + if (!countryOfResidence) { + return null; + } + + const country = countryOfResidence.toUpperCase(); + + if (country === 'US' && usState) { + return `US-${usState.toUpperCase()}`; + } + + return country; +}; + +/** + * Returns true when the residency region matches any blocked region entry. + * Fail-open when residency is unknown (null). + */ +export const isCardResidencyInBlockedRegions = ( + residencyRegion: string | null, + blockedRegions: string[], +): boolean => { + if (!residencyRegion || blockedRegions.length === 0) { + return false; + } + + const normalized = residencyRegion.toUpperCase(); + + return blockedRegions.some((region) => + normalized.startsWith(region.toUpperCase()), + ); +}; diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx index 7f80e8d6eb87..fa2502accb59 100644 --- a/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx @@ -97,6 +97,7 @@ const AdvancedChart = forwardRef( lineColorOverride, successColorOverride, errorColorOverride, + currentPriceLineColorOverride, }, ref, ) => { @@ -130,29 +131,50 @@ const AdvancedChart = forwardRef( const tradingViewOpenInterceptRef = useRef(0); const skeletonHiddenReportedRef = useRef(false); - // Capture the color overrides baked into the HTML template so the + // Track the color overrides baked into the current HTML template so the // SET_THEME_COLORS effect can skip sending when colors haven't diverged. + // Refs are updated synchronously inside the useMemo below (not in a post-render + // effect) so the snapshot is always in sync with what was actually baked, even + // when multiple deps change in the same render cycle. const initialLineColorRef = useRef(lineColorOverride); const initialSuccessColorRef = useRef(successColorOverride); const initialErrorColorRef = useRef(errorColorOverride); const themeColorsSentRef = useRef(false); - const htmlContent = useMemo( - () => - createAdvancedChartTemplate(theme, { - enableDrawingTools, - disabledFeatures, - lineChrome, - lineColorOverride: initialLineColorRef.current, - successColorOverride: initialSuccessColorRef.current, - errorColorOverride: initialErrorColorRef.current, - }), - // Color overrides intentionally excluded — hot-swapped via SET_THEME_COLORS. + const htmlContent = useMemo(() => { + // Snapshot current prop values at the moment the template is created. + // This must happen inside useMemo (not in a separate effect) so the refs + // reflect what is actually baked, preventing a stale-color race where a + // simultaneous non-color dep change causes the SET_THEME_COLORS effect to + // see "colors already match" and skip a needed hot-swap. + initialLineColorRef.current = lineColorOverride; + initialSuccessColorRef.current = successColorOverride; + initialErrorColorRef.current = errorColorOverride; + return createAdvancedChartTemplate(theme, { + enableDrawingTools, + disabledFeatures, + lineChrome, + lineColorOverride, + successColorOverride, + errorColorOverride, + currentPriceLineColorOverride, + }); + // lineColorOverride/successColorOverride/errorColorOverride intentionally excluded — + // color changes hot-swap via SET_THEME_COLORS without rebuilding the WebView. + // currentPriceLineColorOverride is a direct dep: it is theme-derived and changes + // only with theme, so the initial-ref indirection would introduce a stale-color race. // eslint-disable-next-line react-hooks/exhaustive-deps - [theme, enableDrawingTools, disabledFeatures, lineChrome], - ); + }, [ + theme, + enableDrawingTools, + disabledFeatures, + lineChrome, + currentPriceLineColorOverride, + ]); - // Reset all chart state when the WebView reloads due to htmlContent changes + // Reset all chart state when the WebView reloads due to htmlContent changes. + // Color refs are intentionally omitted here — they are snapshotted synchronously + // inside the useMemo above. useEffect(() => { skeletonHiddenReportedRef.current = false; setChartReadyCount(0); @@ -166,9 +188,6 @@ const AdvancedChart = forwardRef( prevOhlcvSeriesKeyRef.current = undefined; ohlcvSeriesStaleSnapshotRef.current = null; themeColorsSentRef.current = false; - initialLineColorRef.current = lineColorOverride; - initialSuccessColorRef.current = successColorOverride; - initialErrorColorRef.current = errorColorOverride; }, [htmlContent]); // eslint-disable-line react-hooks/exhaustive-deps // ---- Helpers ---- diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts b/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts index ad1e526f1fa6..d7facfd5e09f 100644 --- a/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts @@ -507,6 +507,8 @@ export interface AdvancedChartProps { successColorOverride?: string; /** Override the candlestick down/error color baked into the HTML template (A/B test). */ errorColorOverride?: string; + /** Override the current-price horizontal line color. Does not affect candle or volume colors. */ + currentPriceLineColorOverride?: string; } /** diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts b/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts index 963fd7fbfb85..54933dbfc1a5 100644 --- a/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts @@ -56,6 +56,7 @@ interface ChartFeatures { lineColorOverride?: string; successColorOverride?: string; errorColorOverride?: string; + currentPriceLineColorOverride?: string; } const createConfigScript = ( @@ -78,7 +79,8 @@ window.CONFIG = { successColor: '${successColor}', lineColor: '${lineColor}', errorColor: '${errorColor}', - primaryColor: '${theme.colors.primary.default}' + primaryColor: '${theme.colors.primary.default}', + currentPriceColor: '${features.currentPriceLineColorOverride ?? ''}' }, features: { enableDrawingTools: ${features.enableDrawingTools ? 'true' : 'false'}, diff --git a/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js b/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js index 1354556b4d9a..412254fc00f3 100644 --- a/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js +++ b/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js @@ -766,6 +766,7 @@ function handleSetThemeColors(payload) { var chart = window.chartWidget.activeChart(); var lineColor = theme.lineColor || theme.successColor; + var currentPriceColor = theme.currentPriceColor || lineColor; // Update volume study colors if present if (window.volumeStudyId) { @@ -796,7 +797,7 @@ function handleSetThemeColors(payload) { if (window.lastPriceShapeId) { try { chart.getShapeById(window.lastPriceShapeId).setProperties({ - linecolor: theme.successColor, + linecolor: theme.currentPriceColor || theme.successColor, }); } catch (e) {} } @@ -804,7 +805,7 @@ function handleSetThemeColors(payload) { if (window.lineLastPriceShapeId) { try { chart.getShapeById(window.lineLastPriceShapeId).setProperties({ - linecolor: lineColor, + linecolor: currentPriceColor, }); } catch (e) {} } @@ -2243,7 +2244,8 @@ function createLastPriceLine() { var lastBar = window.ohlcvData[window.ohlcvData.length - 1]; var chart = window.chartWidget.activeChart(); - var color = window.CONFIG.theme.successColor; + var color = + window.CONFIG.theme.currentPriceColor || window.CONFIG.theme.successColor; var candlePt = getLineEndDotTimeAndPriceFromSeries(chart); var candlePrice = candlePt && isFinite(candlePt.price) ? candlePt.price : lastBar.close; @@ -2329,7 +2331,9 @@ function createLineLastPriceLine() { var lastBar = window.ohlcvData[window.ohlcvData.length - 1]; var chart = window.chartWidget.activeChart(); const color = - window.CONFIG.theme.lineColor || window.CONFIG.theme.successColor; + window.CONFIG.theme.currentPriceColor || + window.CONFIG.theme.lineColor || + window.CONFIG.theme.successColor; var seriesPt = resolveLineEndOverlayPoint(chart); var linePrice = seriesPt && isFinite(seriesPt.price) ? seriesPt.price : lastBar.close; diff --git a/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts index 9572154e9935..4421fca3ba24 100644 --- a/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts +++ b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts @@ -775,6 +775,7 @@ function handleSetThemeColors(payload) { var chart = window.chartWidget.activeChart(); var lineColor = theme.lineColor || theme.successColor; + var currentPriceColor = theme.currentPriceColor || lineColor; // Update volume study colors if present if (window.volumeStudyId) { @@ -805,7 +806,7 @@ function handleSetThemeColors(payload) { if (window.lastPriceShapeId) { try { chart.getShapeById(window.lastPriceShapeId).setProperties({ - linecolor: theme.successColor, + linecolor: theme.currentPriceColor || theme.successColor, }); } catch (e) {} } @@ -813,7 +814,7 @@ function handleSetThemeColors(payload) { if (window.lineLastPriceShapeId) { try { chart.getShapeById(window.lineLastPriceShapeId).setProperties({ - linecolor: lineColor, + linecolor: currentPriceColor, }); } catch (e) {} } @@ -2252,7 +2253,8 @@ function createLastPriceLine() { var lastBar = window.ohlcvData[window.ohlcvData.length - 1]; var chart = window.chartWidget.activeChart(); - var color = window.CONFIG.theme.successColor; + var color = + window.CONFIG.theme.currentPriceColor || window.CONFIG.theme.successColor; var candlePt = getLineEndDotTimeAndPriceFromSeries(chart); var candlePrice = candlePt && isFinite(candlePt.price) ? candlePt.price : lastBar.close; @@ -2338,7 +2340,9 @@ function createLineLastPriceLine() { var lastBar = window.ohlcvData[window.ohlcvData.length - 1]; var chart = window.chartWidget.activeChart(); const color = - window.CONFIG.theme.lineColor || window.CONFIG.theme.successColor; + window.CONFIG.theme.currentPriceColor || + window.CONFIG.theme.lineColor || + window.CONFIG.theme.successColor; var seriesPt = resolveLineEndOverlayPoint(chart); var linePrice = seriesPt && isFinite(seriesPt.price) ? seriesPt.price : lastBar.close; diff --git a/app/components/UI/Earn/selectors/featureFlags/index.test.ts b/app/components/UI/Earn/selectors/featureFlags/index.test.ts index b399cec9b4c6..c32a759d8905 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.test.ts @@ -12,7 +12,6 @@ import { selectMusdConversionPaymentTokensBlocklist, selectIsMusdConversionRewardsUiEnabledFlag, selectMusdConversionBlockedCountries, - parseBlockedCountriesEnv, selectMusdConversionMinAssetBalanceRequired, selectMusdTokenRegistrationChainIds, MUSD_TOKEN_REGISTRATION_CHAIN_IDS_FALLBACK, @@ -1770,62 +1769,6 @@ describe('Earn Feature Flag Selectors', () => { }); }); - describe('parseBlockedCountriesEnv', () => { - it('returns empty array for undefined input', () => { - const result = parseBlockedCountriesEnv(undefined); - - expect(result).toEqual([]); - }); - - it('returns empty array for empty string', () => { - const result = parseBlockedCountriesEnv(''); - - expect(result).toEqual([]); - }); - - it('returns empty array for whitespace-only string', () => { - const result = parseBlockedCountriesEnv(' '); - - expect(result).toEqual([]); - }); - - it('parses single country code', () => { - const result = parseBlockedCountriesEnv('GB'); - - expect(result).toEqual(['GB']); - }); - - it('parses comma-separated country codes', () => { - const result = parseBlockedCountriesEnv('GB,US,FR'); - - expect(result).toEqual(['GB', 'US', 'FR']); - }); - - it('trims whitespace around country codes', () => { - const result = parseBlockedCountriesEnv('GB , US , FR'); - - expect(result).toEqual(['GB', 'US', 'FR']); - }); - - it('converts country codes to uppercase', () => { - const result = parseBlockedCountriesEnv('gb,us,fr'); - - expect(result).toEqual(['GB', 'US', 'FR']); - }); - - it('filters out empty entries', () => { - const result = parseBlockedCountriesEnv('GB,,US,'); - - expect(result).toEqual(['GB', 'US']); - }); - - it('handles mixed case and whitespace', () => { - const result = parseBlockedCountriesEnv(' gb , Us, FR '); - - expect(result).toEqual(['GB', 'US', 'FR']); - }); - }); - describe('selectMusdConversionBlockedCountries', () => { it('returns blocked countries array when remote flag is valid', () => { const blockedRegions = ['GB', 'US']; diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts index 8b92250bb420..3a44a4a786b7 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.ts @@ -2,6 +2,7 @@ import { createSelector } from 'reselect'; import { selectRemoteFeatureFlags } from '../../../../../selectors/featureFlagController'; import { validatedVersionGatedFeatureFlag, + parseBlockedCountriesEnv, VersionGatedFeatureFlag, } from '../../../../../util/remoteFeatureFlag'; import { @@ -244,23 +245,6 @@ export const selectIsMusdConversionRewardsUiEnabledFlag = createSelector( }, ); -/** - * Parses a comma-separated string of country codes into an array. - * Returns empty array if input is undefined/empty. - * - * @param envValue - Comma-separated country codes (e.g., "GB,US,FR") - * @returns Array of country codes - */ -export const parseBlockedCountriesEnv = (envValue?: string): string[] => { - if (!envValue || envValue.trim() === '') { - return []; - } - return envValue - .split(',') - .map((code) => code.trim().toUpperCase()) - .filter((code) => code.length > 0); -}; - /** * Selects the geo-blocked countries for mUSD conversion from remote config or local fallback. * Returns an array of ISO 3166-1 alpha-2 country codes (e.g., ['GB', 'US']). diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index 78cf71d8cb7a..240fd847d497 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -1942,6 +1942,61 @@ describe('MoneyHomeView', () => { ).not.toBeOnTheScreen(); }); + it('hides the card section when residency is blocked', () => { + mockSelectIsCardholder.mockReturnValue(true); + mockUseMoneyAccountCardLinkage.mockReturnValue({ + hasMoneyAccountRequirements: true, + isCardAuthenticated: true, + isCardVerified: true, + isCardLinkedToMoneyAccount: false, + isResidencyBlocked: true, + primaryMoneyAccount: { address: '0xabc' }, + moneyAccountCardToken: { symbol: 'veda' }, + canLink: false, + status: 'idle', + isLinking: false, + error: null, + startLinkFlow: mockStartLinkFlow, + openLinkCardSheet: mockOpenLinkCardSheet, + reset: jest.fn(), + } as unknown as ReturnType); + + const { queryByTestId } = renderWithProvider(); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.LINK_CONTAINER), + ).not.toBeOnTheScreen(); + expect( + queryByTestId(MoneyMetaMaskCardTestIds.CONTAINER), + ).not.toBeOnTheScreen(); + }); + + it('still shows manage mode when residency is blocked but card is already linked', () => { + mockSelectIsCardholder.mockReturnValue(true); + mockUseMoneyAccountCardLinkage.mockReturnValue({ + hasMoneyAccountRequirements: true, + isCardAuthenticated: true, + isCardVerified: true, + isCardLinkedToMoneyAccount: true, + isResidencyBlocked: true, + primaryMoneyAccount: { address: '0xabc' }, + moneyAccountCardToken: { symbol: 'veda' }, + canLink: false, + status: 'idle', + isLinking: false, + error: null, + startLinkFlow: mockStartLinkFlow, + openLinkCardSheet: mockOpenLinkCardSheet, + reset: jest.fn(), + } as unknown as ReturnType); + + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyMetaMaskCardTestIds.MANAGE_CONTAINER), + ).toBeOnTheScreen(); + }); + it('selects mode="link" for cardholder even with zero transactions', () => { mockSelectIsCardholder.mockReturnValue(true); mockUseMoneyAccountCardLinkage.mockReturnValue({ diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index 4102ef46de4b..366f37823bc4 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -83,7 +83,7 @@ import { } from '../../constants/moneyEvents'; import { TransactionMeta } from '@metamask/transaction-controller'; -const Divider = () => ; +const Divider = () => ; const ACTION_BUTTON_ROW_BUTTON_COUNT = 3; @@ -166,6 +166,7 @@ const MoneyHomeView = () => { isCardLinkedToMoneyAccount, isLinking, hasMoneyAccountRequirements, + isResidencyBlocked, } = useMoneyAccountCardLinkage(); let displayState: MoneyBalanceDisplayState; @@ -581,6 +582,8 @@ const MoneyHomeView = () => { let metamaskCardMode: 'upsell' | 'link' | 'manage' | null; if (isCardLinkedToMoneyAccount) { metamaskCardMode = 'manage'; + } else if (isResidencyBlocked) { + metamaskCardMode = null; } else if (isCardholder || (isCardAuthenticated && isCardVerified)) { metamaskCardMode = hasMoneyAccountRequirements ? 'link' : null; } else if (isCardAuthenticated) { @@ -599,6 +602,147 @@ const MoneyHomeView = () => { const isCardAnalyticsReady = cardHomeDataStatus === 'success' || cardHomeDataStatus === 'error'; + const contentSections: { key: string; node: React.ReactNode }[] = []; + + if (hasBalanceValue && isFunded) { + contentSections.push({ + key: 'earnings', + node: ( + + ), + }); + } + + if (isEmptyState) { + contentSections.push({ + key: 'how-it-works', + node: ( + + handleHowItWorksPress({ + componentName: COMPONENT_NAMES.MONEY_HOW_IT_WORKS_SECTION_HEADER, + }) + } + isLoading={vaultApyQuery.isLoading} + /> + ), + }); + contentSections.push({ + key: 'musd-row', + node: ( + + handleMusdRowPress({ + componentName: COMPONENT_NAMES.MONEY_MUSD_TOKEN_SECTION, + }) + } + onAddPress={handleMusdRowAddPress} + balance={musdFiatFormatted} + /> + ), + }); + } + + if (showCardActivityLoading || activityItems.length >= 1) { + contentSections.push({ + key: 'activity', + node: showCardActivityLoading ? ( + + ) : ( + + ), + }); + } + + if (depositTokens.length > 0) { + contentSections.push({ + key: 'potential-earnings', + node: ( + + ), + }); + } + + if (metamaskCardMode) { + contentSections.push({ + key: 'metamask-card', + node: ( + + ), + }); + } + + if (isFunded) { + contentSections.push({ + key: 'condensed-info', + node: ( + + handleHowItWorksPress({ + componentName: + COMPONENT_NAMES.MONEY_CONDENSED_INFO_CARDS_HOW_IT_WORKS, + }) + } + onMusdPress={() => + handleMusdRowPress({ + componentName: COMPONENT_NAMES.MONEY_CONDENSED_INFO_CARDS_MUSD, + }) + } + onWhatYouGetPress={handleWhatYouGetPress} + /> + ), + }); + } + + if (isEmptyState) { + contentSections.push({ + key: 'what-you-get', + node: ( + + ), + }); + } + return ( { card={{ onPress: handleActionButtonCardPress }} /> - {hasBalanceValue && isFunded && ( - <> - - - - )} - {isEmptyState && ( - <> - - handleHowItWorksPress({ - componentName: - COMPONENT_NAMES.MONEY_HOW_IT_WORKS_SECTION_HEADER, - }) - } - isLoading={vaultApyQuery.isLoading} - /> - - handleMusdRowPress({ - componentName: COMPONENT_NAMES.MONEY_MUSD_TOKEN_SECTION, - }) - } - onAddPress={handleMusdRowAddPress} - balance={musdFiatFormatted} - /> - - - )} - {(showCardActivityLoading || activityItems.length >= 1) && ( - <> - {showCardActivityLoading ? ( - - ) : ( - - )} - - - )} - {depositTokens.length > 0 && ( - <> - - - - )} - {metamaskCardMode && ( - <> - - - - )} - {isFunded && ( - - handleHowItWorksPress({ - componentName: - COMPONENT_NAMES.MONEY_CONDENSED_INFO_CARDS_HOW_IT_WORKS, - }) - } - onMusdPress={() => - handleMusdRowPress({ - componentName: COMPONENT_NAMES.MONEY_CONDENSED_INFO_CARDS_MUSD, - }) - } - onWhatYouGetPress={handleWhatYouGetPress} - /> - )} - {isEmptyState && ( - - )} - {!isEmptyState && ( - - )} + {contentSections.map((section, index) => ( + + {index > 0 && } + {section.node} + + ))} + ); diff --git a/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.tsx b/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.tsx index 0c71e1b66f36..a1447fd167c1 100644 --- a/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.tsx +++ b/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.tsx @@ -24,9 +24,16 @@ import Animated, { withTiming, Easing, } from 'react-native-reanimated'; +import { useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; import { useTheme } from '../../../../../util/theme'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; +import { selectMoneyNoFeeTokens } from '../../selectors/featureFlags'; +import { + resolveNoFeeTokens, + formatNoFeeTokenBullets, + formatBaseStablecoins, +} from '../../utils/depositFaqTokens'; import { MoneyHowItWorksViewTestIds } from './MoneyHowItWorksView.testIds'; import useMountEffect from '../../hooks/useMountEffect'; import { COMPONENT_NAMES, SCREEN_NAMES } from '../../constants/moneyEvents'; @@ -126,6 +133,10 @@ const MoneyHowItWorksView = () => { const { apyPercent } = useMoneyAccountBalance(); const percentage = apyPercent ?? FALLBACK_APY; + const noFeeTokens = resolveNoFeeTokens(useSelector(selectMoneyNoFeeTokens)); + const tokenBullets = formatNoFeeTokenBullets(noFeeTokens); + const stablecoins = formatBaseStablecoins(noFeeTokens); + const { trackScreenViewed } = useMoneyAnalytics({ screen_name: SCREEN_NAMES.MONEY_HOW_IT_WORKS, }); @@ -224,6 +235,8 @@ const MoneyHowItWorksView = () => { })} answer={strings(`money.how_it_works_page.faq_a${index + 1}`, { percentage, + tokenBullets, + stablecoins, })} testID={MoneyHowItWorksViewTestIds.FAQ_ITEM(index + 1)} /> diff --git a/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.test.tsx b/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.test.tsx index 22f13e869a2f..fc4ac680806e 100644 --- a/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.test.tsx +++ b/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.test.tsx @@ -1,36 +1,30 @@ import React from 'react'; -import { render, fireEvent, act } from '@testing-library/react-native'; -import MoneyOnboardingView, { - MONEY_ONBOARDING_STEP_DURATION_MS, -} from './MoneyOnboardingView'; -import { RiveOnboardingStepperTestIds } from '../../../RiveOnboardingStepper/RiveOnboardingStepper.testIds'; -import { __clearLastMockedMethods } from '../../../../../__mocks__/rive-react-native'; +import { render } from '@testing-library/react-native'; +import MoneyOnboardingView from './MoneyOnboardingView'; import Routes from '../../../../../constants/navigation/Routes'; -import { selectMoneyOnboardingStepperAnimationEnabled } from '../../../../../selectors/featureFlagController/moneyAccount'; +import { strings } from '../../../../../../locales/i18n'; import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; import { COMPONENT_NAMES, MONEY_ONBOARDING_STEP_ACTIONS, SCREEN_NAMES, } from '../../constants/moneyEvents'; +import { MoneyOnboardingViewTestIds } from './MoneyOnboardingView.testIds'; const mockTrackOnboardingEvent = jest.fn(); +const mockNavigate = jest.fn(); +const mockDispatch = jest.fn(); jest.mock('../../hooks/useMoneyAnalytics', () => ({ useMoneyAnalytics: jest.fn(), })); -const mockNavigate = jest.fn(); -const mockDispatch = jest.fn(); -const mockUseSelector = jest.fn(); - jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate }), })); jest.mock('react-redux', () => ({ useDispatch: () => mockDispatch, - useSelector: (selector: unknown) => mockUseSelector(selector), })); jest.mock('../../hooks/useMoneyAccountBalance', () => ({ @@ -38,17 +32,35 @@ jest.mock('../../hooks/useMoneyAccountBalance', () => ({ default: () => ({ apyPercent: 4 }), })); -jest.mock('react-native-linear-gradient', () => 'LinearGradient'); - -jest.mock('react-native-reanimated', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const Reanimated = require('react-native-reanimated/mock'); - Reanimated.default.call = jest.fn(); - return Reanimated; +let mockOnStateChanged: (stateMachineName: string, stateName: string) => void; +let mockTriggerCallbacks: Record void> = {}; +const mockSetString = jest.fn(); +const mockSetNumber = jest.fn(); + +jest.mock('rive-react-native', () => { + const mockRiveRef = {}; + + return { + __esModule: true, + default: jest.fn(({ onStateChanged, ...props }) => { + mockOnStateChanged = onStateChanged; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native'); + return ; + }), + useRive: () => [jest.fn(), mockRiveRef], + useRiveString: () => [undefined, mockSetString], + useRiveNumber: () => [undefined, mockSetNumber], + useRiveTrigger: (_riveRef: unknown, path: string, callback: () => void) => { + mockTriggerCallbacks[path] = callback; + }, + AutoBind: (value: boolean) => ({ type: 'autobind', value }), + Fit: { Layout: 'layout' }, + }; }); jest.mock( - '../../../../../animations/money_account_onboarding_animation.riv', + '../../../../../animations/money_account_onboarding_animation_v4.riv', () => 1, { virtual: true }, ); @@ -56,220 +68,219 @@ jest.mock( describe('MoneyOnboardingView', () => { beforeEach(() => { jest.clearAllMocks(); - __clearLastMockedMethods(); - // Stepper animation (Rive) enabled by default; every other selector is irrelevant here. - mockUseSelector.mockImplementation( - (selector: unknown) => - selector === selectMoneyOnboardingStepperAnimationEnabled, - ); + mockTriggerCallbacks = {}; (useMoneyAnalytics as jest.Mock).mockReturnValue({ trackOnboardingEvent: mockTrackOnboardingEvent, }); }); describe('Rendering', () => { - it('renders the onboarding stepper container', () => { + it('renders the Rive animation component', () => { const { getByTestId } = render(); - expect( - getByTestId(RiveOnboardingStepperTestIds.CONTAINER), - ).toBeOnTheScreen(); - }); - it('renders the progress bar', () => { - const { getByTestId } = render(); expect( - getByTestId(RiveOnboardingStepperTestIds.PROGRESS_BAR), + getByTestId(MoneyOnboardingViewTestIds.RIVE_ANIMATION), ).toBeOnTheScreen(); }); + }); - it('renders five progress segments', () => { - const { getByTestId } = render(); - [0, 1, 2, 3, 4].forEach((index) => { - expect( - getByTestId( - `${RiveOnboardingStepperTestIds.PROGRESS_SEGMENT}-${index}`, - ), - ).toBeOnTheScreen(); + describe('Analytics initialization', () => { + it('initializes useMoneyAnalytics with onboarding screen and stepper component', () => { + render(); + + expect(useMoneyAnalytics).toHaveBeenCalledWith({ + screen_name: SCREEN_NAMES.MONEY_ONBOARDING, + component_name: COMPONENT_NAMES.RIVE_ONBOARDING_STEPPER, }); }); + }); - it('renders the Rive animation', () => { - const { getByTestId } = render(); - expect( - getByTestId(RiveOnboardingStepperTestIds.RIVE_ANIMATION), - ).toBeOnTheScreen(); - }); + describe('State changes (onStateChanged)', () => { + it('tracks VIEWED event with step 1 when state changes to UI1', () => { + render(); - it('renders the footer button', () => { - const { getByTestId } = render(); - expect( - getByTestId(RiveOnboardingStepperTestIds.FOOTER_BUTTON), - ).toBeOnTheScreen(); - }); + mockOnStateChanged('State Machine 1', 'UI1'); - it('renders the close button', () => { - const { getByTestId } = render(); - expect( - getByTestId(RiveOnboardingStepperTestIds.CLOSE_BUTTON), - ).toBeOnTheScreen(); + expect(mockTrackOnboardingEvent).toHaveBeenCalledWith({ + step: 1, + step_title: expect.any(String), + total_steps: 5, + step_action: MONEY_ONBOARDING_STEP_ACTIONS.VIEWED, + redirect_target: SCREEN_NAMES.MONEY_ONBOARDING, + }); }); - it('renders the first step title', () => { - const { getByText } = render(); - expect(getByText('Money accounts are here')).toBeOnTheScreen(); - }); + it('tracks VIEWED event with step 2 when state changes to APY', () => { + render(); - it('renders the first step body text', () => { - const { getByText } = render(); - expect( - getByText( - 'Earn up to 4% APY on your balance, available across your entire wallet.', - ), - ).toBeOnTheScreen(); + mockOnStateChanged('State Machine 1', 'APY'); + + expect(mockTrackOnboardingEvent).toHaveBeenCalledWith({ + step: 2, + step_title: expect.any(String), + total_steps: 5, + step_action: MONEY_ONBOARDING_STEP_ACTIONS.VIEWED, + redirect_target: SCREEN_NAMES.MONEY_ONBOARDING, + }); }); - it('renders the first step footer text', () => { - const { getByText } = render(); - expect( - getByText('APY is variable and not guaranteed.'), - ).toBeOnTheScreen(); + it('tracks VIEWED event with step 3 when state changes to Card', () => { + render(); + + mockOnStateChanged('State Machine 1', 'Card'); + + expect(mockTrackOnboardingEvent).toHaveBeenCalledWith({ + step: 3, + step_title: expect.any(String), + total_steps: 5, + step_action: MONEY_ONBOARDING_STEP_ACTIONS.VIEWED, + redirect_target: SCREEN_NAMES.MONEY_ONBOARDING, + }); }); - }); - describe('when the onboarding stepper flag is disabled', () => { - beforeEach(() => { - mockUseSelector.mockImplementation(() => false); + it('tracks VIEWED event with step 4 when state changes to Coins', () => { + render(); + + mockOnStateChanged('State Machine 1', 'Coins'); + + expect(mockTrackOnboardingEvent).toHaveBeenCalledWith({ + step: 4, + step_title: expect.any(String), + total_steps: 5, + step_action: MONEY_ONBOARDING_STEP_ACTIONS.VIEWED, + redirect_target: SCREEN_NAMES.MONEY_ONBOARDING, + }); }); - it('does not render the Rive animation', () => { - const { queryByTestId } = render(); - expect( - queryByTestId(RiveOnboardingStepperTestIds.RIVE_ANIMATION), - ).toBeNull(); + it('does not track events for unknown state names', () => { + render(); + + mockOnStateChanged('State Machine 1', 'SomeTransitionState'); + + expect(mockTrackOnboardingEvent).not.toHaveBeenCalled(); }); - it('still renders the onboarding content (container, title, footer button)', () => { - const { getByTestId, getByText } = render(); - expect( - getByTestId(RiveOnboardingStepperTestIds.CONTAINER), - ).toBeOnTheScreen(); - expect(getByText('Money accounts are here')).toBeOnTheScreen(); - expect( - getByTestId(RiveOnboardingStepperTestIds.FOOTER_BUTTON), - ).toBeOnTheScreen(); + it('tracks VIEWED event when FinalState fires', () => { + render(); + + mockOnStateChanged('State Machine 1', 'FinalState'); + + expect(mockTrackOnboardingEvent).toHaveBeenCalledWith( + expect.objectContaining({ + step: 5, + step_action: MONEY_ONBOARDING_STEP_ACTIONS.VIEWED, + }), + ); }); - }); - describe('Navigation', () => { - it('dispatches setMoneyOnboardingSeen and navigates to Money home when close button is pressed', () => { - const { getByTestId } = render(); + it('tracks COMPLETED event when FinalState fires', () => { + render(); - fireEvent.press(getByTestId(RiveOnboardingStepperTestIds.CLOSE_BUTTON)); + mockOnStateChanged('State Machine 1', 'FinalState'); - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ type: 'SET_MONEY_ONBOARDING_SEEN' }), + expect(mockTrackOnboardingEvent).toHaveBeenCalledWith( + expect.objectContaining({ + step: 5, + step_action: MONEY_ONBOARDING_STEP_ACTIONS.COMPLETED, + redirect_target: SCREEN_NAMES.MONEY_HOME, + }), ); + }); + + it('navigates to Money home when FinalState fires', () => { + render(); + + mockOnStateChanged('State Machine 1', 'FinalState'); + expect(mockNavigate).toHaveBeenCalledWith(Routes.HOME_TABS, { screen: Routes.MONEY.ROOT, params: { screen: Routes.MONEY.HOME }, }); }); - it('dispatches setMoneyOnboardingSeen and navigates to Money home on completion', () => { - jest.useFakeTimers(); - const { getByTestId } = render(); - const footerButton = getByTestId( - RiveOnboardingStepperTestIds.FOOTER_BUTTON, + it('dispatches setMoneyOnboardingSeen when FinalState fires', () => { + render(); + + mockOnStateChanged('State Machine 1', 'FinalState'); + + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'SET_MONEY_ONBOARDING_SEEN', + payload: { seen: true }, + }), ); + }); + }); - // Step 0 -> 1 (button starts enabled at step 0) - fireEvent.press(footerButton); - act(() => jest.advanceTimersByTime(MONEY_ONBOARDING_STEP_DURATION_MS)); + describe('Close trigger', () => { + it('tracks EXITED event at current step when close trigger fires', () => { + render(); + mockOnStateChanged('State Machine 1', 'APY'); + jest.clearAllMocks(); - // Step 1 -> 2 - fireEvent.press(footerButton); - act(() => jest.advanceTimersByTime(MONEY_ONBOARDING_STEP_DURATION_MS)); + mockTriggerCallbacks.close(); - // Step 2 -> 3 - fireEvent.press(footerButton); - act(() => jest.advanceTimersByTime(MONEY_ONBOARDING_STEP_DURATION_MS)); + expect(mockTrackOnboardingEvent).toHaveBeenCalledWith( + expect.objectContaining({ + step: 2, + step_action: MONEY_ONBOARDING_STEP_ACTIONS.EXITED, + redirect_target: SCREEN_NAMES.MONEY_HOME, + }), + ); + }); - // Step 3 -> 4 (last step, autoComplete timer starts) - fireEvent.press(footerButton); + it('navigates to Money home when close trigger fires', () => { + render(); + mockOnStateChanged('State Machine 1', 'APY'); + jest.clearAllMocks(); - // Auto-complete timer fires onComplete after durationMs - act(() => jest.advanceTimersByTime(MONEY_ONBOARDING_STEP_DURATION_MS)); + mockTriggerCallbacks.close(); - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ type: 'SET_MONEY_ONBOARDING_SEEN' }), - ); expect(mockNavigate).toHaveBeenCalledWith(Routes.HOME_TABS, { screen: Routes.MONEY.ROOT, params: { screen: Routes.MONEY.HOME }, }); - - jest.useRealTimers(); - }); - - it('does not navigate to Money home when continuing between non-final steps', () => { - const { getByTestId } = render(); - fireEvent.press(getByTestId(RiveOnboardingStepperTestIds.FOOTER_BUTTON)); - expect(mockNavigate).not.toHaveBeenCalled(); }); - describe('analytics', () => { - it('initialises useMoneyAnalytics with MONEY_ONBOARDING screen_name and RIVE_ONBOARDING_STEPPER component_name', () => { - render(); + it('dispatches setMoneyOnboardingSeen when close trigger fires', () => { + render(); - expect(useMoneyAnalytics).toHaveBeenCalledWith({ - screen_name: SCREEN_NAMES.MONEY_ONBOARDING, - component_name: COMPONENT_NAMES.RIVE_ONBOARDING_STEPPER, - }); - }); - - it('calls trackOnboardingEvent with VIEWED action for step 1 on initial render', () => { - render(); - - expect(mockTrackOnboardingEvent).toHaveBeenCalledWith( - expect.objectContaining({ - step: 1, - step_action: MONEY_ONBOARDING_STEP_ACTIONS.VIEWED, - total_steps: 5, - }), - ); - }); - - it('calls trackOnboardingEvent with EXITED action for step 1 when close is pressed at step 0', () => { - const { getByTestId } = render(); + mockTriggerCallbacks.close(); - fireEvent.press(getByTestId(RiveOnboardingStepperTestIds.CLOSE_BUTTON)); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'SET_MONEY_ONBOARDING_SEEN', + payload: { seen: true }, + }), + ); + }); + }); - expect(mockTrackOnboardingEvent).toHaveBeenCalledWith( - expect.objectContaining({ - step: 1, - step_action: MONEY_ONBOARDING_STEP_ACTIONS.EXITED, - redirect_target: SCREEN_NAMES.MONEY_HOME, - total_steps: 5, - }), - ); + describe('Rive text run initialization', () => { + it('sets all expected step text and numeric config on mount', () => { + render(); + + const expectedStrings = [ + strings('money.rive_onboarding.step1_title'), + strings('money.rive_onboarding.step1_body', { percentage: 4 }), + strings('money.rive_onboarding.step1_footer_text'), + strings('money.rive_onboarding.step2_title'), + strings('money.rive_onboarding.step2_body'), + strings('money.rive_onboarding.step2_footer_text'), + strings('money.rive_onboarding.step3_title'), + strings('money.rive_onboarding.step3_body', { percentage: 3 }), + strings('money.rive_onboarding.step3_footer_text'), + strings('money.rive_onboarding.step4_title'), + strings('money.rive_onboarding.step4_body'), + strings('money.rive_onboarding.step4_footer_text'), + ]; + + expectedStrings.forEach((text) => { + expect(mockSetString).toHaveBeenCalledWith(text); }); - it('calls trackOnboardingEvent with VIEWED action for step 2 when footer advances to step 1', () => { - const { getByTestId } = render(); - jest.clearAllMocks(); - - fireEvent.press( - getByTestId(RiveOnboardingStepperTestIds.FOOTER_BUTTON), - ); - - expect(mockTrackOnboardingEvent).toHaveBeenCalledWith( - expect.objectContaining({ - step: 2, - step_action: MONEY_ONBOARDING_STEP_ACTIONS.VIEWED, - total_steps: 5, - }), - ); - }); + expect(mockSetNumber).toHaveBeenCalledWith(300); + expect(mockSetNumber).toHaveBeenCalledWith(0); }); }); }); diff --git a/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.testIds.ts b/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.testIds.ts new file mode 100644 index 000000000000..08adfeb6b9bc --- /dev/null +++ b/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.testIds.ts @@ -0,0 +1,3 @@ +export const MoneyOnboardingViewTestIds = { + RIVE_ANIMATION: 'money-onboarding-rive-animation', +} as const; diff --git a/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.tsx b/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.tsx index ed6cd4ef3dc7..728a9dcb03c1 100644 --- a/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.tsx +++ b/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.tsx @@ -1,58 +1,61 @@ -import React, { useCallback, useMemo } from 'react'; -import LinearGradient from 'react-native-linear-gradient'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useNavigation } from '@react-navigation/native'; import { type StackNavigationProp } from '@react-navigation/stack'; -import { useDispatch, useSelector } from 'react-redux'; -import { - ButtonVariant, - IconColor, - TextColor, -} from '@metamask/design-system-react-native'; +import { useDispatch } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; -import RiveOnboardingStepper, { - type OnboardingStep, - type RiveConfig, -} from '../../../RiveOnboardingStepper'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; -import { selectMoneyOnboardingStepperAnimationEnabled } from '../../../../../selectors/featureFlagController/moneyAccount'; -import { useTheme } from '../../../../../util/theme'; import { setMoneyOnboardingSeen } from '../../../../../actions/user'; -import { AppThemeKey } from '../../../../../util/theme/models'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; import { COMPONENT_NAMES, MONEY_ONBOARDING_STEP_ACTIONS, SCREEN_NAMES, } from '../../constants/moneyEvents'; +import Rive, { + AutoBind, + useRive, + useRiveString, + useRiveNumber, + Fit, + useRiveTrigger, +} from 'rive-react-native'; +import { PixelRatio } from 'react-native'; +import { MoneyOnboardingViewTestIds } from './MoneyOnboardingView.testIds'; // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs -const MoneyOnboardingAnimation = require('../../../../../animations/money_account_onboarding_animation.riv'); +const MoneyOnboardingAnimationV4 = require('../../../../../animations/money_account_onboarding_animation_v4.riv'); /** * State machine constants must match the Rive file authored for this animation. * Update these if the Rive file's state machine or trigger names change. */ const RIVE_STATE_MACHINE_NAME = 'State Machine 1'; -const RIVE_TRIGGER_NAME = 'Trig'; - -// eslint-disable-next-line @metamask/design-tokens/color-no-hex -const GRADIENT_COLORS = ['#190066', '#3F6FD0']; -const GRADIENT_START = { x: 0, y: 0 }; -const GRADIENT_END = { x: 0, y: 1 }; - +const RIVE_ARTBOARD_NAME = 'Money_Account'; const CARD_CASHBACK_PERCENTAGE = 3; +const CLOSE_TRIGGER = 'close'; -const RIVE_ANIMATION_STYLE = { flex: 1, top: 100 }; +/** + * The keys in this mapping refer to the step state names in the Rive file. + * Do not change the keys without updating the Rive file. + */ +const RIVE_STEP_NAMES = { + UI1: 'UI1', + APY: 'APY', + CARD: 'Card', + COINS: 'Coins', + FINAL_STATE: 'FinalState', +}; -const RIVE_CONFIG: RiveConfig = { - source: MoneyOnboardingAnimation, - stateMachineName: RIVE_STATE_MACHINE_NAME, - triggerName: RIVE_TRIGGER_NAME, +const RIVE_STATE_TO_STEP_INDEX: Record = { + [RIVE_STEP_NAMES.UI1]: 0, + [RIVE_STEP_NAMES.APY]: 1, + [RIVE_STEP_NAMES.CARD]: 2, + [RIVE_STEP_NAMES.COINS]: 3, + [RIVE_STEP_NAMES.FINAL_STATE]: 4, }; -export const MONEY_ONBOARDING_STEP_DURATION_MS = 4 * 1000; // 4 seconds +const TOTAL_ONBOARDING_STEPS = Object.keys(RIVE_STATE_TO_STEP_INDEX).length; const MoneyOnboardingView = () => { const navigation = @@ -65,80 +68,35 @@ const MoneyOnboardingView = () => { component_name: COMPONENT_NAMES.RIVE_ONBOARDING_STEPPER, }); - const { colors, themeAppearance } = useTheme(); - - const isStepperAnimationEnabled = useSelector( - selectMoneyOnboardingStepperAnimationEnabled, - ); - - const tw = useTailwind(); + const { apyPercent } = useMoneyAccountBalance(); - const isDarkTheme = themeAppearance === AppThemeKey.dark; + const [ref, riveRef] = useRive(); - const progressBarColor = isDarkTheme - ? colors.primary.default - : colors.primary.inverse; + const stepRef = useRef(0); - // Title and text color - const textColor = isDarkTheme - ? TextColor.TextDefault - : TextColor.PrimaryInverse; + // --- Text runs (stepText1–4: title, content, footer) --- + const [, setStep1Title] = useRiveString(riveRef, 'stepText1/title'); + const [, setStep1Content] = useRiveString(riveRef, 'stepText1/content'); + const [, setStep1Footer] = useRiveString(riveRef, 'stepText1/footer'); - const footerTextColor = isDarkTheme - ? TextColor.TextDefault - : TextColor.PrimaryInverse; + const [, setStep2Title] = useRiveString(riveRef, 'stepText2/title'); + const [, setStep2Content] = useRiveString(riveRef, 'stepText2/content'); + const [, setStep2Footer] = useRiveString(riveRef, 'stepText2/footer'); - const iconColor = isDarkTheme - ? IconColor.IconDefault - : IconColor.PrimaryInverse; + const [, setStep3Title] = useRiveString(riveRef, 'stepText3/title'); + const [, setStep3Content] = useRiveString(riveRef, 'stepText3/content'); + const [, setStep3Footer] = useRiveString(riveRef, 'stepText3/footer'); - const buttonIsInverse = !isDarkTheme; + const [, setStep4Title] = useRiveString(riveRef, 'stepText4/title'); + const [, setStep4Content] = useRiveString(riveRef, 'stepText4/content'); + const [, setStep4Footer] = useRiveString(riveRef, 'stepText4/footer'); - const { apyPercent } = useMoneyAccountBalance(); + // --- Number inputs --- + const [, setTransitionSpeed] = useRiveNumber(riveRef, 'transitionSpeed'); + const [, setCoinSeq] = useRiveNumber(riveRef, 'coinSeq'); + const [, setCardSeq] = useRiveNumber(riveRef, 'cardSeq'); - const steps: OnboardingStep[] = useMemo( - () => [ - { - title: strings('money.rive_onboarding.step1_title'), - body: strings('money.rive_onboarding.step1_body', { - percentage: apyPercent, - }), - footerText: strings('money.rive_onboarding.step1_footer_text'), - durationMs: MONEY_ONBOARDING_STEP_DURATION_MS, - buttonLabel: strings('money.rive_onboarding.continue'), - }, - { - title: strings('money.rive_onboarding.step2_title'), - body: strings('money.rive_onboarding.step2_body'), - footerText: strings('money.rive_onboarding.step2_footer_text'), - durationMs: MONEY_ONBOARDING_STEP_DURATION_MS, - buttonLabel: strings('money.rive_onboarding.continue'), - }, - { - title: strings('money.rive_onboarding.step3_title'), - body: strings('money.rive_onboarding.step3_body', { - percentage: CARD_CASHBACK_PERCENTAGE, - }), - footerText: strings('money.rive_onboarding.step3_footer_text'), - durationMs: MONEY_ONBOARDING_STEP_DURATION_MS, - buttonLabel: strings('money.rive_onboarding.continue'), - }, - { - title: strings('money.rive_onboarding.step4_title'), - body: strings('money.rive_onboarding.step4_body'), - footerText: strings('money.rive_onboarding.step4_footer_text'), - durationMs: MONEY_ONBOARDING_STEP_DURATION_MS, - buttonLabel: strings('money.rive_onboarding.continue'), - }, - { - durationMs: MONEY_ONBOARDING_STEP_DURATION_MS, - showCloseButton: false, - }, - ], - [apyPercent], - ); - - // Hardcoded to use English step titles to simplify event tracking. + // Hardcoded to English to simplify event tracking. const stepTitlesEnglish: string[] = useMemo( () => [ strings('money.rive_onboarding.step1_title', { locale: 'en' }), @@ -150,12 +108,65 @@ const MoneyOnboardingView = () => { [], ); + useEffect(() => { + if (!riveRef) return; + + // Step 1 + setStep1Title(strings('money.rive_onboarding.step1_title')); + setStep1Content( + strings('money.rive_onboarding.step1_body', { percentage: apyPercent }), + ); + setStep1Footer(strings('money.rive_onboarding.step1_footer_text')); + + // Step 2 + setStep2Title(strings('money.rive_onboarding.step2_title')); + setStep2Content(strings('money.rive_onboarding.step2_body')); + setStep2Footer(strings('money.rive_onboarding.step2_footer_text')); + + // Step 3 + setStep3Title(strings('money.rive_onboarding.step3_title')); + setStep3Content( + strings('money.rive_onboarding.step3_body', { + percentage: CARD_CASHBACK_PERCENTAGE, + }), + ); + setStep3Footer(strings('money.rive_onboarding.step3_footer_text')); + + // Step 4 + setStep4Title(strings('money.rive_onboarding.step4_title')); + setStep4Content(strings('money.rive_onboarding.step4_body')); + setStep4Footer(strings('money.rive_onboarding.step4_footer_text')); + + // Config + setTransitionSpeed(300); + setCoinSeq(0); + setCardSeq(0); + }, [ + riveRef, + apyPercent, + setStep1Title, + setStep1Content, + setStep1Footer, + setStep2Title, + setStep2Content, + setStep2Footer, + setStep3Title, + setStep3Content, + setStep3Footer, + setStep4Title, + setStep4Content, + setStep4Footer, + setTransitionSpeed, + setCoinSeq, + setCardSeq, + ]); + const handleClose = useCallback( (stepIndex: number) => { trackOnboardingEvent({ step: stepIndex + 1, // Use 1-based index for event tracking to match total_steps count. step_title: stepTitlesEnglish[stepIndex], - total_steps: steps.length, + total_steps: TOTAL_ONBOARDING_STEPS, step_action: MONEY_ONBOARDING_STEP_ACTIONS.EXITED, redirect_target: SCREEN_NAMES.MONEY_HOME, }); @@ -166,13 +177,7 @@ const MoneyOnboardingView = () => { params: { screen: Routes.MONEY.HOME }, }); }, - [ - dispatch, - navigation, - stepTitlesEnglish, - steps.length, - trackOnboardingEvent, - ], + [dispatch, navigation, stepTitlesEnglish, trackOnboardingEvent], ); const handleStepViewed = useCallback( @@ -180,12 +185,12 @@ const MoneyOnboardingView = () => { trackOnboardingEvent({ step: stepIndex + 1, // Use 1-based index for event tracking to match total_steps count. step_title: stepTitlesEnglish[stepIndex], - total_steps: steps.length, + total_steps: TOTAL_ONBOARDING_STEPS, step_action: MONEY_ONBOARDING_STEP_ACTIONS.VIEWED, redirect_target: SCREEN_NAMES.MONEY_ONBOARDING, }); }, - [stepTitlesEnglish, steps.length, trackOnboardingEvent], + [stepTitlesEnglish, trackOnboardingEvent], ); const handleComplete = useCallback( @@ -194,7 +199,7 @@ const MoneyOnboardingView = () => { trackOnboardingEvent({ step: stepIndex + 1, // Use 1-based index for event tracking to match total_steps count. step_title: stepTitlesEnglish[stepIndex], - total_steps: steps.length, + total_steps: TOTAL_ONBOARDING_STEPS, step_action: MONEY_ONBOARDING_STEP_ACTIONS.COMPLETED, redirect_target: SCREEN_NAMES.MONEY_HOME, }); @@ -204,45 +209,40 @@ const MoneyOnboardingView = () => { params: { screen: Routes.MONEY.HOME }, }); }, - [ - dispatch, - navigation, - stepTitlesEnglish, - steps.length, - trackOnboardingEvent, - ], + [dispatch, navigation, stepTitlesEnglish, trackOnboardingEvent], ); - const renderBackground = useCallback( - () => ( - - ), - [tw], + useRiveTrigger(riveRef, CLOSE_TRIGGER, () => { + handleClose(stepRef.current); + }); + + const handleStateChanged = useCallback( + (_stateMachineName: string, stateName: string) => { + const stepIndex = RIVE_STATE_TO_STEP_INDEX[stateName]; + + if (stepIndex !== undefined) { + stepRef.current = stepIndex; + handleStepViewed(stepIndex); + } + + if (stateName === RIVE_STEP_NAMES.FINAL_STATE) { + handleComplete(stepRef.current); + } + }, + [handleStepViewed, handleComplete], ); return ( - ); }; diff --git a/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx b/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx index 04a22bea22b0..7c7692a7a704 100644 --- a/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx +++ b/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx @@ -1,18 +1,10 @@ import React from 'react'; +import { StyleSheet } from 'react-native'; import { Box, - BoxAlignItems, BoxFlexDirection, - BoxJustifyContent, - ButtonBase, - FontWeight, - Icon, - IconColor, IconName, - IconSize, - Text, - TextColor, - TextVariant, + MainActionButton, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import { MoneyActionButtonRowTestIds } from './MoneyActionButtonRow.testIds'; @@ -28,49 +20,12 @@ interface MoneyActionButtonRowProps { card: ActionButtonConfig; } -const ActionButton = ({ - iconName, - label, - onPress, - testID, - disabled, -}: { - iconName: IconName; - label: string; - onPress: () => void; - testID: string; - disabled?: boolean; -}) => ( - - - - - {label} - - - -); +const styles = StyleSheet.create({ + buttonContainer: { + flex: 1, + overflow: 'hidden', + }, +}); const MoneyActionButtonRow = ({ add, @@ -79,30 +34,32 @@ const MoneyActionButtonRow = ({ }: MoneyActionButtonRowProps) => ( - - - ); diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.pendingTransaction.test.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.pendingTransaction.test.tsx index b20f277d2540..b859047ca136 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.pendingTransaction.test.tsx +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.pendingTransaction.test.tsx @@ -15,7 +15,6 @@ import { addTransactionBatch } from '../../../../../util/transaction-controller' import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; import { useMMPayFiatConfig } from '../../../../Views/confirmations/hooks/pay/useMMPayFiatConfig'; import { selectHasAnyNonZeroTokenBalance } from '../../../../../selectors/tokenBalancesController'; -import { useHasNativeFiatProvider } from '../../../Ramp/hooks/useHasNativeFiatProvider'; import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; const PENDING_TX_ID = 'pending-tx-from-elsewhere'; @@ -154,9 +153,6 @@ jest.mock('../../../../../selectors/tokenBalancesController', () => ({ ...jest.requireActual('../../../../../selectors/tokenBalancesController'), selectHasAnyNonZeroTokenBalance: jest.fn(), })); -jest.mock('../../../Ramp/hooks/useHasNativeFiatProvider', () => ({ - useHasNativeFiatProvider: jest.fn(), -})); jest.mock('../../hooks/useMoneyAnalytics', () => ({ useMoneyAnalytics: jest.fn(), })); @@ -214,7 +210,6 @@ describe('MoneyAddMoneySheet — Add funds with a pending transaction', () => { (selectHasAnyNonZeroTokenBalance as unknown as jest.Mock).mockReturnValue( true, ); - (useHasNativeFiatProvider as jest.Mock).mockReturnValue(true); (useMoneyAnalytics as jest.Mock).mockReturnValue({ trackBottomSheetViewed: jest.fn(), trackSurfaceClicked: jest.fn(), diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx index 60ea836f0bad..a9fcfe70cdd2 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx @@ -8,7 +8,6 @@ import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount'; import { useMMPayFiatConfig } from '../../../../Views/confirmations/hooks/pay/useMMPayFiatConfig'; import { selectHasAnyNonZeroTokenBalance } from '../../../../../selectors/tokenBalancesController'; -import { useHasNativeFiatProvider } from '../../../Ramp/hooks/useHasNativeFiatProvider'; import { MUSD_CONVERSION_DEFAULT_CHAIN_ID, MUSD_TOKEN_ADDRESS_BY_CHAIN, @@ -63,10 +62,6 @@ jest.mock('../../../../../selectors/tokenBalancesController', () => ({ selectHasAnyNonZeroTokenBalance: jest.fn(), })); -jest.mock('../../../Ramp/hooks/useHasNativeFiatProvider', () => ({ - useHasNativeFiatProvider: jest.fn(), -})); - jest.mock('../../../../../selectors/transactionController', () => ({ ...jest.requireActual('../../../../../selectors/transactionController'), selectTransactions: jest.fn(() => []), @@ -125,24 +120,35 @@ describe('MoneyAddMoneySheet', () => { (selectHasAnyNonZeroTokenBalance as unknown as jest.Mock).mockReturnValue( true, ); - (useHasNativeFiatProvider as jest.Mock).mockReturnValue(true); }); - it('renders all four options', () => { - const { getByText, getByTestId } = renderWithProvider( + it('renders all options', () => { + const { getByText, getAllByText, getByTestId } = renderWithProvider( , ); expect(getByText('Convert crypto')).toBeOnTheScreen(); - expect(getByText('Debit card or bank account')).toBeOnTheScreen(); + expect(getByText('Debit card or Apple Pay')).toBeOnTheScreen(); expect(getByText('$1,203.89 mUSD')).toBeOnTheScreen(); + expect(getByText('Bank account')).toBeOnTheScreen(); expect(getByText('External address')).toBeOnTheScreen(); - expect(getByText('Coming soon')).toBeOnTheScreen(); + // Bank account and External address are both coming soon. + expect(getAllByText('Coming soon')).toHaveLength(2); expect( getByTestId(MoneyAddMoneySheetTestIds.RECEIVE_EXTERNAL_ROW), ).toBeOnTheScreen(); }); + it('renders the Bank account row as a coming-soon, non-pressable option', () => { + const { getByTestId } = renderWithProvider(); + + const bankRow = getByTestId(MoneyAddMoneySheetTestIds.BANK_ACCOUNT_ROW); + expect(bankRow).toBeOnTheScreen(); + + fireEvent.press(bankRow); + expect(mockInitiateDeposit).not.toHaveBeenCalled(); + }); + it('renders the "Add funds" title', () => { const { getByText } = renderWithProvider(); @@ -345,9 +351,7 @@ describe('MoneyAddMoneySheet', () => { ).toBeNull(); }); - it('shows the Deposit funds option enabled when a native provider is available', () => { - (useHasNativeFiatProvider as jest.Mock).mockReturnValue(true); - + it('navigates to MM Pay with autoSelectFiatPayment when Debit card or Apple Pay is pressed', () => { const { getByTestId } = renderWithProvider(); fireEvent.press( @@ -359,21 +363,20 @@ describe('MoneyAddMoneySheet', () => { }); }); - it('shows the Deposit funds option disabled with "Coming soon" when no native provider serves the region', () => { - (useHasNativeFiatProvider as jest.Mock).mockReturnValue(false); - + it('keeps Debit card or Apple Pay active, with only Bank account and External address coming soon', () => { const { getByTestId, getAllByText } = renderWithProvider( , ); + expect(getAllByText('Coming soon')).toHaveLength(2); + fireEvent.press( getByTestId(MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_OPTION), ); - // Two "Coming soon" tags now: the disabled Deposit row + Receive external. - expect(getAllByText('Coming soon')).toHaveLength(2); - expect(mockOnCloseBottomSheet).not.toHaveBeenCalled(); - expect(mockInitiateDeposit).not.toHaveBeenCalled(); + expect(mockInitiateDeposit).toHaveBeenCalledWith({ + autoSelectFiatPayment: true, + }); }); // Returns the option-row testIDs in rendered order. Each row carries its @@ -401,8 +404,6 @@ describe('MoneyAddMoneySheet', () => { }; it('keeps the original order when all options are enabled', () => { - (useHasNativeFiatProvider as jest.Mock).mockReturnValue(true); - const { UNSAFE_root } = renderWithProvider(); const order = getOptionOrder(UNSAFE_root); @@ -413,32 +414,18 @@ describe('MoneyAddMoneySheet', () => { ]); }); - it('moves the disabled Deposit funds (Coming soon) row to the bottom of the options', () => { - (useHasNativeFiatProvider as jest.Mock).mockReturnValue(false); - - const { UNSAFE_root } = renderWithProvider(); - const order = getOptionOrder(UNSAFE_root); - - expect(order).toEqual([ - MoneyAddMoneySheetTestIds.CONVERT_CRYPTO_OPTION, - MoneyAddMoneySheetTestIds.MOVE_MUSD_OPTION, - MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_OPTION, - ]); - }); - - it('groups multiple disabled options at the bottom preserving their relative order', () => { + it('moves a disabled option (Convert crypto) to the bottom of the order', () => { (selectHasAnyNonZeroTokenBalance as unknown as jest.Mock).mockReturnValue( false, ); - (useHasNativeFiatProvider as jest.Mock).mockReturnValue(false); const { UNSAFE_root } = renderWithProvider(); const order = getOptionOrder(UNSAFE_root); expect(order).toEqual([ + MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_OPTION, MoneyAddMoneySheetTestIds.MOVE_MUSD_OPTION, MoneyAddMoneySheetTestIds.CONVERT_CRYPTO_OPTION, - MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_OPTION, ]); }); diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.testIds.ts b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.testIds.ts index cee9159c36a6..1bccd213abc0 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.testIds.ts +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.testIds.ts @@ -3,5 +3,6 @@ export const MoneyAddMoneySheetTestIds = { CONVERT_CRYPTO_OPTION: 'money-add-money-sheet-convert-crypto', DEPOSIT_FUNDS_OPTION: 'money-add-money-sheet-deposit-funds', MOVE_MUSD_OPTION: 'money-add-money-sheet-move-musd', + BANK_ACCOUNT_ROW: 'money-add-money-sheet-bank-account', RECEIVE_EXTERNAL_ROW: 'money-add-money-sheet-receive-external', }; diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx index f53ca7a6e81a..87641fb823ce 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx @@ -40,7 +40,6 @@ import { useMMPayFiatConfig } from '../../../../Views/confirmations/hooks/pay/us import { useElevatedSurface } from '../../../../../util/theme/themeUtils'; import { selectTransactions } from '../../../../../selectors/transactionController'; import { selectHasAnyNonZeroTokenBalance } from '../../../../../selectors/tokenBalancesController'; -import { useHasNativeFiatProvider } from '../../../Ramp/hooks/useHasNativeFiatProvider'; import MoneySheetOptionsList, { type MoneySheetOption, } from '../MoneySheetOptionsList'; @@ -72,7 +71,6 @@ const MoneyAddMoneySheet: React.FC = () => { const { initiateDeposit } = useMoneyAccountDeposit(); const { enabledTransactionTypes } = useMMPayFiatConfig(); const hasAnyCryptoBalance = useSelector(selectHasAnyNonZeroTokenBalance); - const hasNativeFiatProvider = useHasNativeFiatProvider(); const transactions = useSelector(selectTransactions); const isFiatDepositEnabled = useMemo( () => enabledTransactionTypes.includes(TransactionType.moneyAccountDeposit), @@ -214,11 +212,9 @@ const MoneyAddMoneySheet: React.FC = () => { ? [ { label: strings('money.add_money_sheet.deposit_funds'), - icon: IconName.Bank, + icon: IconName.Card, onPress: handleDepositFunds, testID: MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_OPTION, - disabled: !hasNativeFiatProvider, - comingSoon: !hasNativeFiatProvider, }, ] : []), @@ -233,6 +229,13 @@ const MoneyAddMoneySheet: React.FC = () => { testID: MoneyAddMoneySheetTestIds.MOVE_MUSD_OPTION, disabled: !hasMusdBalance, }, + { + label: strings('money.add_money_sheet.bank_account'), + icon: IconName.Bank, + testID: MoneyAddMoneySheetTestIds.BANK_ACCOUNT_ROW, + disabled: true, + comingSoon: true, + }, { label: strings('money.add_money_sheet.receive_external'), icon: IconName.QrCode, diff --git a/app/components/UI/Money/components/MoneyApyInfoSheet/MoneyApyInfoSheet.test.tsx b/app/components/UI/Money/components/MoneyApyInfoSheet/MoneyApyInfoSheet.test.tsx index f7360ac462ce..5aaf5dea1608 100644 --- a/app/components/UI/Money/components/MoneyApyInfoSheet/MoneyApyInfoSheet.test.tsx +++ b/app/components/UI/Money/components/MoneyApyInfoSheet/MoneyApyInfoSheet.test.tsx @@ -125,6 +125,14 @@ describe('MoneyApyInfoSheet', () => { ).toBeOnTheScreen(); }); + it('renders paragraph_4', () => { + const { getByText } = renderWithProvider(); + + expect( + getByText(strings('money.apy_tooltip.paragraph_4')), + ).toBeOnTheScreen(); + }); + it('does not render a Learn More footer button', () => { const { queryByTestId } = renderWithProvider(); @@ -174,6 +182,12 @@ describe('MoneyApyInfoSheet', () => { expect(queryByText(strings('money.apy_tooltip.paragraph_3'))).toBeNull(); }); + it('does not render paragraph_4', () => { + const { queryByText } = renderWithProvider(); + + expect(queryByText(strings('money.apy_tooltip.paragraph_4'))).toBeNull(); + }); + it('renders the sheet title', () => { const { getByText } = renderWithProvider(); diff --git a/app/components/UI/Money/components/MoneyApyInfoSheet/MoneyApyInfoSheet.tsx b/app/components/UI/Money/components/MoneyApyInfoSheet/MoneyApyInfoSheet.tsx index 1a38e305d990..09e86ece11c0 100644 --- a/app/components/UI/Money/components/MoneyApyInfoSheet/MoneyApyInfoSheet.tsx +++ b/app/components/UI/Money/components/MoneyApyInfoSheet/MoneyApyInfoSheet.tsx @@ -62,6 +62,9 @@ const MoneyApyInfoSheet = () => { {strings('money.apy_tooltip.paragraph_3')} + + {strings('money.apy_tooltip.paragraph_4')} + ); diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx index 14c0d53d3605..ee940525d8d7 100644 --- a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx @@ -189,17 +189,12 @@ describe('MoneyBalanceCard', () => { expect(queryByText('$0.00')).not.toBeOnTheScreen(); }); - it('renders the Add button (not the Earn upsell)', () => { - const { getByTestId, queryByTestId } = renderWithProvider( - , - ); + it('renders the Add button', () => { + const { getByTestId } = renderWithProvider(); expect(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)).toHaveTextContent( strings('money.balance_card.add'), ); - expect( - queryByTestId(MoneyBalanceCardTestIds.EARN_BUTTON), - ).not.toBeOnTheScreen(); }); it('does not render the empty or new-user container', () => { @@ -295,23 +290,18 @@ describe('MoneyBalanceCard', () => { ); }); - it('renders the Earn button (not the Add button)', () => { - const { getByTestId, queryByTestId } = renderWithProvider( - , - ); + it('renders the Add button', () => { + const { getByTestId } = renderWithProvider(); - expect( - getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON), - ).toHaveTextContent(strings('homepage.sections.money_empty_state.earn')); - expect( - queryByTestId(MoneyBalanceCardTestIds.ADD_BUTTON), - ).not.toBeOnTheScreen(); + expect(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)).toHaveTextContent( + strings('money.balance_card.add'), + ); }); - it('routes add money when Earn is pressed', () => { + it('routes add money when Add is pressed', () => { const { getByTestId } = renderWithProvider(); - fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); expect(mockInitiateDeposit).toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { @@ -319,15 +309,15 @@ describe('MoneyBalanceCard', () => { }); }); - it('tracks the Earn click with the empty-state label key', () => { + it('tracks the Add click with the deposit redirect target', () => { const { getByTestId } = renderWithProvider(); - fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); expect(mockTrackButtonClicked).toHaveBeenCalledWith({ button_type: MONEY_BUTTON_TYPES.TEXT, button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, - label_key: 'homepage.sections.money_empty_state.earn', + label_key: 'money.balance_card.add', redirect_target: SCREEN_NAMES.MONEY_DEPOSIT, }); }); @@ -352,12 +342,10 @@ describe('MoneyBalanceCard', () => { ).toBeOnTheScreen(); }); - it('does not render the Add button', () => { - const { queryByTestId } = renderWithProvider(); + it('renders the Add button', () => { + const { getByTestId } = renderWithProvider(); - expect( - queryByTestId(MoneyBalanceCardTestIds.ADD_BUTTON), - ).not.toBeOnTheScreen(); + expect(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)).toBeOnTheScreen(); }); describe('when the wallet-home onboarding stepper is not displayed', () => { @@ -365,16 +353,14 @@ describe('MoneyBalanceCard', () => { mockSelectWalletHomeOnboardingFlowVisible.mockReturnValue(false); }); - it('renders the Earn button with the earn label', () => { + it('renders the Add button with the add label', () => { const { getByTestId, queryByTestId } = renderWithProvider( , ); expect( - getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON), - ).toHaveTextContent( - strings('homepage.sections.money_empty_state.earn'), - ); + getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON), + ).toHaveTextContent(strings('money.balance_card.add')); expect( queryByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON), ).not.toBeOnTheScreen(); @@ -388,10 +374,10 @@ describe('MoneyBalanceCard', () => { ).toBeOnTheScreen(); }); - it('routes add money when Earn is pressed', () => { + it('routes add money when Add is pressed', () => { const { getByTestId } = renderWithProvider(); - fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); expect(mockInitiateDeposit).toHaveBeenCalled(); expect(mockNavigateToMoneyHome).not.toHaveBeenCalled(); @@ -403,16 +389,14 @@ describe('MoneyBalanceCard', () => { mockSelectWalletHomeOnboardingFlowVisible.mockReturnValue(true); }); - it('renders the Earn button (never Get started) when the stepper is visible', () => { + it('renders the Add button (never Get started) when the stepper is visible', () => { const { getByTestId, queryByTestId } = renderWithProvider( , ); expect( - getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON), - ).toHaveTextContent( - strings('homepage.sections.money_empty_state.earn'), - ); + getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON), + ).toHaveTextContent(strings('money.balance_card.add')); expect( queryByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON), ).not.toBeOnTheScreen(); @@ -426,10 +410,10 @@ describe('MoneyBalanceCard', () => { ).toBeOnTheScreen(); }); - it('routes add money when Earn is pressed', () => { + it('routes add money when Add is pressed', () => { const { getByTestId } = renderWithProvider(); - fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); expect(mockInitiateDeposit).toHaveBeenCalled(); }); @@ -453,17 +437,12 @@ describe('MoneyBalanceCard', () => { ); }); - it('renders the Add button with the add label and not the Earn button', () => { - const { getByTestId, queryByTestId } = renderWithProvider( - , - ); + it('renders the Add button with the add label', () => { + const { getByTestId } = renderWithProvider(); expect(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)).toHaveTextContent( strings('money.balance_card.add'), ); - expect( - queryByTestId(MoneyBalanceCardTestIds.EARN_BUTTON), - ).not.toBeOnTheScreen(); }); it('renders the APY tag', () => { @@ -510,7 +489,7 @@ describe('MoneyBalanceCard', () => { }); }); - it('routes add money (and not the Money home) when Earn is pressed in empty state', () => { + it('routes add money (and not the Money home) when Add is pressed in empty state', () => { mockUseMoneyAccountBalance.mockReturnValue( createBalanceMock({ totalFiatRaw: '0', @@ -520,7 +499,7 @@ describe('MoneyBalanceCard', () => { const { getByTestId } = renderWithProvider(); - fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); expect(mockInitiateDeposit).toHaveBeenCalled(); expect(mockNavigateToMoneyHome).not.toHaveBeenCalled(); @@ -633,23 +612,23 @@ describe('MoneyBalanceCard', () => { mockSelectMoneyOnboardingSeen.mockReturnValue(true); }); - it('renders Earn as Secondary when the onboarding stepper is visible', () => { + it('renders Add as Secondary when the onboarding stepper is visible', () => { mockSelectWalletHomeOnboardingFlowVisible.mockReturnValue(true); const { UNSAFE_getByProps } = renderWithProvider(); expect( - getVariant(UNSAFE_getByProps, MoneyBalanceCardTestIds.EARN_BUTTON), + getVariant(UNSAFE_getByProps, MoneyBalanceCardTestIds.ADD_BUTTON), ).toBe(ButtonVariant.Secondary); }); - it('renders Earn as Primary when no other primary CTA is on Home', () => { + it('renders Add as Primary when no other primary CTA is on Home', () => { mockSelectWalletHomeOnboardingFlowVisible.mockReturnValue(false); const { UNSAFE_getByProps } = renderWithProvider(); expect( - getVariant(UNSAFE_getByProps, MoneyBalanceCardTestIds.EARN_BUTTON), + getVariant(UNSAFE_getByProps, MoneyBalanceCardTestIds.ADD_BUTTON), ).toBe(ButtonVariant.Primary); }); }); @@ -687,23 +666,23 @@ describe('MoneyBalanceCard', () => { mockSelectMoneyOnboardingSeen.mockReturnValue(false); }); - it('renders Earn as Primary when no other primary CTA is on Home', () => { + it('renders Add as Primary when no other primary CTA is on Home', () => { mockSelectWalletHomeOnboardingFlowVisible.mockReturnValue(false); const { UNSAFE_getByProps } = renderWithProvider(); expect( - getVariant(UNSAFE_getByProps, MoneyBalanceCardTestIds.EARN_BUTTON), + getVariant(UNSAFE_getByProps, MoneyBalanceCardTestIds.ADD_BUTTON), ).toBe(ButtonVariant.Primary); }); - it('renders Earn as Secondary when the onboarding stepper is visible', () => { + it('renders Add as Secondary when the onboarding stepper is visible', () => { mockSelectWalletHomeOnboardingFlowVisible.mockReturnValue(true); const { UNSAFE_getByProps } = renderWithProvider(); expect( - getVariant(UNSAFE_getByProps, MoneyBalanceCardTestIds.EARN_BUTTON), + getVariant(UNSAFE_getByProps, MoneyBalanceCardTestIds.ADD_BUTTON), ).toBe(ButtonVariant.Secondary); }); }); diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts index 333c9aced670..3ee4f1242a54 100644 --- a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts @@ -16,5 +16,4 @@ export const MoneyBalanceCardTestIds = { APY_TAG_SKELETON: 'money-balance-card-apy-tag-skeleton', ADD_BUTTON: 'money-balance-card-add-button', GET_STARTED_BUTTON: 'money-balance-card-get-started-button', - EARN_BUTTON: 'money-balance-card-earn-button', } as const; diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx index e0b4a2fcb9e8..4bd7d01ed42e 100644 --- a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx @@ -100,27 +100,22 @@ const MoneyBalanceCard = () => { const balanceText = totalFiatFormatted ?? ''; + const buttonLabelKey = 'money.balance_card.add'; + const buttonTestId = MoneyBalanceCardTestIds.ADD_BUTTON; + let buttonVariant: ButtonVariant; - let buttonLabelKey: string; - let buttonTestId: string; let containerTestId: string; if (!hasMoneyAccount || isError || isRetrying) { buttonVariant = ButtonVariant.Secondary; - buttonLabelKey = 'money.balance_card.add'; - buttonTestId = MoneyBalanceCardTestIds.ADD_BUTTON; containerTestId = MoneyBalanceCardTestIds.ERROR_CONTAINER; } else if (isUnavailable) { buttonVariant = ButtonVariant.Secondary; - buttonLabelKey = 'money.balance_card.add'; - buttonTestId = MoneyBalanceCardTestIds.ADD_BUTTON; containerTestId = MoneyBalanceCardTestIds.UNAVAILABLE_CONTAINER; } else if (isEmpty) { buttonVariant = hasOtherPrimaryCtaOnHome ? ButtonVariant.Secondary : ButtonVariant.Primary; - buttonLabelKey = 'homepage.sections.money_empty_state.earn'; - buttonTestId = MoneyBalanceCardTestIds.EARN_BUTTON; containerTestId = isNewUser ? MoneyBalanceCardTestIds.NEW_USER_CONTAINER : MoneyBalanceCardTestIds.EMPTY_CONTAINER; @@ -128,8 +123,6 @@ const MoneyBalanceCard = () => { buttonVariant = hasOtherPrimaryCtaOnHome ? ButtonVariant.Secondary : ButtonVariant.Primary; - buttonLabelKey = 'money.balance_card.add'; - buttonTestId = MoneyBalanceCardTestIds.ADD_BUTTON; containerTestId = MoneyBalanceCardTestIds.FUNDED_CONTAINER; } diff --git a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx index 99d0d4baf583..2afe2ed0d983 100644 --- a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx +++ b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx @@ -83,7 +83,6 @@ const MoneyBalanceSummary = ({ variant={TextVariant.DisplayLg} fontWeight={FontWeight.Bold} testID={MoneyBalanceSummaryTestIds.BALANCE} - twClassName="mb-2" > {displayState.value} @@ -94,7 +93,6 @@ const MoneyBalanceSummary = ({ variant={TextVariant.BodyMd} color={TextColor.TextAlternative} testID={MoneyBalanceSummaryTestIds.BALANCE_NO_ACCOUNT} - twClassName="mb-2" > {strings('money.balance_no_account')} @@ -108,7 +106,6 @@ const MoneyBalanceSummary = ({ fontWeight={FontWeight.Bold} color={TextColor.TextAlternative} testID={MoneyBalanceSummaryTestIds.BALANCE_UNAVAILABLE} - twClassName="mb-2" > {displayState.lastKnownValue ?? strings('money.balance_unavailable_value')} @@ -120,16 +117,17 @@ const MoneyBalanceSummary = ({ }; return ( - - - {renderBalanceSlot()} - - {renderApySlot()} - + + {renderBalanceSlot()} + + {renderApySlot()} ); diff --git a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.tsx b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.tsx index 9ba67b680af6..0dc725f9f247 100644 --- a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.tsx +++ b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.tsx @@ -62,7 +62,7 @@ const MoneyEarnings = ({ isLoading = false, onInfoPress, }: MoneyEarningsProps) => ( - + { @@ -133,6 +137,7 @@ const setupDefaultMocks = ({ isCardAuthenticated, isCardVerified, isCardLinkedToMoneyAccount, + isResidencyBlocked, isLinking: false, }); mockIsCardholder.mockReturnValue(isCardholder); @@ -712,4 +717,35 @@ describe('MoneyOnboardingCard', () => { ); }); }); + + describe('residency blocking', () => { + it('auto-skips step 2 when residency is blocked and account is funded', () => { + setupDefaultMocks({ + currentStep: 0, + isCardholder: true, + isCardAuthenticated: true, + isCardVerified: true, + isResidencyBlocked: true, + tokenTotal: new BigNumber(100), + }); + + const { toJSON } = render(); + + expect(toJSON()).toBeNull(); + }); + + it('shows get-card step 2 when residency is not blocked', () => { + setupDefaultMocks({ + currentStep: 1, + isCardAuthenticated: false, + isResidencyBlocked: false, + }); + + const { getByText } = render(); + + expect( + getByText(strings('money.onboarding.step_2.no_card_account.title')), + ).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx index 69ad4fda0f49..34a3ae943e3b 100644 --- a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx +++ b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx @@ -60,6 +60,7 @@ const MoneyOnboardingCard = () => { isCardVerified, isCardLinkedToMoneyAccount, isLinking, + isResidencyBlocked, } = useMoneyAccountCardLinkage(); const isCardholder = useSelector(selectIsCardholder); const cardHomeDataStatus = useSelector(selectCardHomeDataStatus); @@ -76,9 +77,12 @@ const MoneyOnboardingCard = () => { isCardLinkedToMoneyAccount, }); + const isCardStepBlocked = isResidencyBlocked && !isCardLinkedToMoneyAccount; + const shouldShowLinkCardAction = - isCardholder || - (isCardAuthenticated && isCardVerified && !isCardLinkedToMoneyAccount); + !isCardStepBlocked && + (isCardholder || + (isCardAuthenticated && isCardVerified && !isCardLinkedToMoneyAccount)); const handleRedirectToCryptoDeposit = useCallback(async () => { await initiateDeposit().catch(() => undefined); @@ -173,7 +177,9 @@ const MoneyOnboardingCard = () => { // - step 1 is complete right now (immediately advance funded users). const canEvaluateStep2 = currentStep >= 1 || isStep1Complete; const isStep2Complete = - canEvaluateStep2 && isCardAuthenticated && isCardLinkedToMoneyAccount; + canEvaluateStep2 && + ((isCardAuthenticated && isCardLinkedToMoneyAccount) || + isCardStepBlocked); if (isStep2Complete) return 2; if (isStep1Complete) return 1; @@ -183,6 +189,7 @@ const MoneyOnboardingCard = () => { isMoneyAccountFunded, isCardAuthenticated, isCardLinkedToMoneyAccount, + isCardStepBlocked, ]); // Prevent a flash of earlier steps by rendering the computed step immediately, @@ -335,7 +342,7 @@ const MoneyOnboardingCard = () => { } return ( - + { expect(container).toHaveTextContent(/dollar-backed stablecoin/); expect(container).toHaveTextContent(/Get full liquidity/); expect(container).toHaveTextContent(/1-3% mUSD back/); - expect(container).toHaveTextContent(/Transfer to any of your wallets/); + expect(container).toHaveTextContent(/Send to any of your wallets/); expect(container).toHaveTextContent(/Send and receive funds globally/); }); diff --git a/app/components/UI/Money/hooks/useMoneyAccountCardTransactions.test.ts b/app/components/UI/Money/hooks/useMoneyAccountCardTransactions.test.ts index d2ffa01e39b4..beb5fc1eb3b2 100644 --- a/app/components/UI/Money/hooks/useMoneyAccountCardTransactions.test.ts +++ b/app/components/UI/Money/hooks/useMoneyAccountCardTransactions.test.ts @@ -3,7 +3,6 @@ import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { apiClient } from '../../../../core/apiClient'; import { selectPrimaryMoneyAccount } from '../../../../selectors/moneyAccountController'; -import { selectIsMoneyAccountDelegatedForCard } from '../../../../selectors/cardController'; import { parseCardTransactions } from '../utils/accountsApi'; import { useMoneyAccountCardTransactions } from './useMoneyAccountCardTransactions'; import { MUSD_MONEY_ACCOUNT_CHAIN_IDS } from '../../Earn/constants/musd'; @@ -29,9 +28,6 @@ jest.mock('../../../../core/apiClient', () => ({ jest.mock('../../../../selectors/moneyAccountController', () => ({ selectPrimaryMoneyAccount: jest.fn(), })); -jest.mock('../../../../selectors/cardController', () => ({ - selectIsMoneyAccountDelegatedForCard: jest.fn(), -})); jest.mock('../utils/accountsApi', () => ({ parseCardTransactions: jest.fn(), })); @@ -60,17 +56,12 @@ const CARD: CardTransaction = { function setupSelectors( opts: { - isLinked?: boolean; account?: { address: string } | undefined; } = {}, ) { - const isLinked = opts.isLinked ?? true; // Distinguish "not provided" (default account) from an explicit `undefined`. const account = 'account' in opts ? opts.account : { address: ADDR_A }; mockUseSelector.mockImplementation((selector) => { - if (selector === selectIsMoneyAccountDelegatedForCard) { - return isLinked; - } if (selector === selectPrimaryMoneyAccount) { return account; } @@ -122,16 +113,6 @@ describe('useMoneyAccountCardTransactions', () => { ); }); - it('disables the query for an unlinked account', () => { - setupSelectors({ isLinked: false }); - - renderHook(() => useMoneyAccountCardTransactions()); - - expect(mockUseQuery).toHaveBeenCalledWith( - expect.objectContaining({ enabled: false }), - ); - }); - it('disables the query and sends an empty address when there is no money account', () => { setupSelectors({ account: undefined }); diff --git a/app/components/UI/Money/hooks/useMoneyAccountCardTransactions.ts b/app/components/UI/Money/hooks/useMoneyAccountCardTransactions.ts index ba10a71399df..29c9f5028001 100644 --- a/app/components/UI/Money/hooks/useMoneyAccountCardTransactions.ts +++ b/app/components/UI/Money/hooks/useMoneyAccountCardTransactions.ts @@ -28,7 +28,6 @@ const EMPTY: CardTransaction[] = []; */ export function useMoneyAccountCardTransactions(): UseMoneyAccountCardTransactionsResult { const primaryMoneyAccount = useSelector(selectPrimaryMoneyAccount); - const isLinked = useSelector(selectIsMoneyAccountDelegatedForCard); const rawAddress = primaryMoneyAccount?.address; const moneyAddress = rawAddress ? toChecksumHexAddress(rawAddress) : ''; @@ -49,7 +48,7 @@ export function useMoneyAccountCardTransactions(): UseMoneyAccountCardTransactio const query = useQuery({ ...queryOptions, select, - enabled: isLinked && moneyAddress !== '', + enabled: moneyAddress !== '', staleTime: 5 * MINUTE, retry: false, } as unknown as UseQueryOptions< diff --git a/app/components/UI/Money/routes/index.test.tsx b/app/components/UI/Money/routes/index.test.tsx index ab5741c0fe9b..92edc528ea6a 100644 --- a/app/components/UI/Money/routes/index.test.tsx +++ b/app/components/UI/Money/routes/index.test.tsx @@ -112,7 +112,7 @@ describe('MoneyTabScreenStack', () => { jest.clearAllMocks(); }); - it('registers Money home, activity, how-it-works, and potential-earnings screens', () => { + it('registers Money home, activity, and how-it-works screens', () => { const { getByTestId } = renderWithProvider(, { theme: themeWithCustomBackground, }); @@ -120,9 +120,6 @@ describe('MoneyTabScreenStack', () => { expect(getByTestId('money-screen-MoneyHome')).toBeOnTheScreen(); expect(getByTestId('money-screen-MoneyActivity')).toBeOnTheScreen(); expect(getByTestId('money-screen-MoneyHowItWorks')).toBeOnTheScreen(); - expect( - getByTestId('money-screen-MoneyPotentialEarnings'), - ).toBeOnTheScreen(); }); it('sets stack content background from theme to avoid flash during inner navigation', () => { diff --git a/app/components/UI/Money/routes/index.tsx b/app/components/UI/Money/routes/index.tsx index 6bf630323424..2ce7438e9a1a 100644 --- a/app/components/UI/Money/routes/index.tsx +++ b/app/components/UI/Money/routes/index.tsx @@ -9,7 +9,6 @@ import { useTheme } from '../../../../util/theme'; import MoneyHomeView from '../Views/MoneyHomeView'; import MoneyActivityView from '../Views/MoneyActivityView'; import MoneyHowItWorksView from '../Views/MoneyHowItWorksView'; -import MoneyPotentialEarningsView from '../Views/MoneyPotentialEarningsView'; import MoneyAddMoneySheet from '../components/MoneyAddMoneySheet'; import MoneyMoreSheet from '../components/MoneyMoreSheet'; import MoneyTransferSheet from '../components/MoneyTransferSheet'; @@ -49,10 +48,6 @@ const MoneyTabScreenStack = () => { name={Routes.MONEY.HOW_IT_WORKS} component={MoneyHowItWorksView} /> - ); }; diff --git a/app/components/UI/Money/selectors/eligibility.test.ts b/app/components/UI/Money/selectors/eligibility.test.ts new file mode 100644 index 000000000000..3ce33c3fc333 --- /dev/null +++ b/app/components/UI/Money/selectors/eligibility.test.ts @@ -0,0 +1,155 @@ +import { selectIsMoneyAccountGeoEligible } from './eligibility'; +import type { RootState } from '../../../../reducers'; + +describe('selectIsMoneyAccountGeoEligible', () => { + const createStateWithGeolocation = ( + geolocation: string | null | undefined, + remoteFeatureFlags: Record = {}, + ) => + ({ + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags, + cacheTimestamp: 0, + }, + GeolocationController: { + location: geolocation, + }, + }, + }, + }) as unknown as RootState; + + const noBlockedCountriesFlags = { + moneyAccountGeoBlockedCountries: { blockedRegions: [] }, + }; + + const gbBlockedFlags = { + moneyAccountGeoBlockedCountries: { blockedRegions: ['GB'] }, + }; + + const gbUsBlockedFlags = { + moneyAccountGeoBlockedCountries: { blockedRegions: ['GB', 'US'] }, + }; + + it('returns false when geolocation is undefined (loading state)', () => { + const state = createStateWithGeolocation(undefined, gbBlockedFlags); + + const result = selectIsMoneyAccountGeoEligible(state); + + expect(result).toBe(false); + }); + + it('returns false when geolocation is null', () => { + const state = createStateWithGeolocation(null, gbBlockedFlags); + + const result = selectIsMoneyAccountGeoEligible(state); + + expect(result).toBe(false); + }); + + it('returns false when geolocation is UNKNOWN', () => { + const state = createStateWithGeolocation('UNKNOWN', gbBlockedFlags); + + const result = selectIsMoneyAccountGeoEligible(state); + + expect(result).toBe(false); + }); + + it('returns false when user country is in the blocked list', () => { + const state = createStateWithGeolocation('GB', gbBlockedFlags); + + const result = selectIsMoneyAccountGeoEligible(state); + + expect(result).toBe(false); + }); + + it('returns false when country-region code matches a blocked country', () => { + const state = createStateWithGeolocation('GB-ENG', gbBlockedFlags); + + const result = selectIsMoneyAccountGeoEligible(state); + + expect(result).toBe(false); + }); + + it('returns false when user is in one of multiple blocked countries', () => { + const state = createStateWithGeolocation('US', gbUsBlockedFlags); + + const result = selectIsMoneyAccountGeoEligible(state); + + expect(result).toBe(false); + }); + + it('returns true when user country is not in the blocked list', () => { + const state = createStateWithGeolocation('US', gbBlockedFlags); + + const result = selectIsMoneyAccountGeoEligible(state); + + expect(result).toBe(true); + }); + + it('returns true when the blocked countries list is empty', () => { + const state = createStateWithGeolocation('GB', noBlockedCountriesFlags); + + const result = selectIsMoneyAccountGeoEligible(state); + + expect(result).toBe(true); + }); + + it('comparison is case-insensitive for geolocation codes', () => { + const state = createStateWithGeolocation('gb', gbBlockedFlags); + + const result = selectIsMoneyAccountGeoEligible(state); + + expect(result).toBe(false); + }); + + it('comparison is case-insensitive for blocked country codes', () => { + const lowercaseBlockedFlags = { + moneyAccountGeoBlockedCountries: { blockedRegions: ['gb'] }, + }; + const state = createStateWithGeolocation('GB', lowercaseBlockedFlags); + + const result = selectIsMoneyAccountGeoEligible(state); + + expect(result).toBe(false); + }); + + it('returns true when user country-region does not match any blocked country', () => { + const state = createStateWithGeolocation('US-CA', gbBlockedFlags); + + const result = selectIsMoneyAccountGeoEligible(state); + + expect(result).toBe(true); + }); + + it('defaults to blocking GB when no remote flag and no env var is set', () => { + const originalEnv = process.env.MM_MONEY_ACCOUNT_GEO_BLOCKED_COUNTRIES; + delete process.env.MM_MONEY_ACCOUNT_GEO_BLOCKED_COUNTRIES; + + const state = createStateWithGeolocation('GB', {}); + + const result = selectIsMoneyAccountGeoEligible(state); + + expect(result).toBe(false); + + if (originalEnv !== undefined) { + process.env.MM_MONEY_ACCOUNT_GEO_BLOCKED_COUNTRIES = originalEnv; + } + }); + + it('returns true for non-blocked country when using default blocked list', () => { + const originalEnv = process.env.MM_MONEY_ACCOUNT_GEO_BLOCKED_COUNTRIES; + delete process.env.MM_MONEY_ACCOUNT_GEO_BLOCKED_COUNTRIES; + + const state = createStateWithGeolocation('US', {}); + + const result = selectIsMoneyAccountGeoEligible(state); + + expect(result).toBe(true); + + if (originalEnv !== undefined) { + process.env.MM_MONEY_ACCOUNT_GEO_BLOCKED_COUNTRIES = originalEnv; + } + }); +}); diff --git a/app/components/UI/Money/selectors/eligibility.ts b/app/components/UI/Money/selectors/eligibility.ts new file mode 100644 index 000000000000..2a14aad31cb9 --- /dev/null +++ b/app/components/UI/Money/selectors/eligibility.ts @@ -0,0 +1,26 @@ +/** + * Determines whether the current user is geo-eligible for the Money account. + * + * Defaults to BLOCKING when geolocation is unknown to ensure regulatory + * compliance. Users in blocked regions cannot bypass restrictions by having + * geolocation fail to load. + */ + +import { createSelector } from 'reselect'; +import { selectMoneyAccountGeoBlockedCountries } from './featureFlags'; +import { getDetectedGeolocation } from '../../../../reducers/fiatOrders'; + +export const selectIsMoneyAccountGeoEligible = createSelector( + selectMoneyAccountGeoBlockedCountries, + getDetectedGeolocation, + (blockedCountries, geolocation): boolean => { + const userCountry = geolocation?.toUpperCase().split('-')[0] ?? null; + + if (!userCountry) return false; + if (blockedCountries.length === 0) return true; + + return blockedCountries.every( + (blocked) => !userCountry.startsWith(blocked.toUpperCase()), + ); + }, +); diff --git a/app/components/UI/Money/selectors/featureFlags.test.ts b/app/components/UI/Money/selectors/featureFlags.test.ts index b7b3452ccf83..ee5e18ec4e62 100644 --- a/app/components/UI/Money/selectors/featureFlags.test.ts +++ b/app/components/UI/Money/selectors/featureFlags.test.ts @@ -8,6 +8,8 @@ import { selectMoneyNoFeeTokens, selectMoneyDepositMinBalance, selectMoneyTokensSortMode, + selectMoneyAccountGeoBlockedCountries, + DEFAULT_MONEY_ACCOUNT_BLOCKED_COUNTRIES, } from './featureFlags'; jest.mock('../../../../core/Engine', () => ({ @@ -413,3 +415,72 @@ describe('selectMoneyTokensSortMode', () => { expect(result).toBe('fiatBalanceDesc'); }); }); + +describe('selectMoneyAccountGeoBlockedCountries', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('returns blockedRegions array from remote flag when valid', () => { + const state = createState({ + moneyAccountGeoBlockedCountries: { blockedRegions: ['GB', 'US'] }, + }); + + const result = selectMoneyAccountGeoBlockedCountries(state as never); + + expect(result).toEqual(['GB', 'US']); + }); + + it('returns empty array when remote flag has empty blockedRegions', () => { + const state = createState({ + moneyAccountGeoBlockedCountries: { blockedRegions: [] }, + }); + + const result = selectMoneyAccountGeoBlockedCountries(state as never); + + expect(result).toEqual([]); + }); + + it('falls back to env var when remote flag is absent', () => { + process.env.MM_MONEY_ACCOUNT_GEO_BLOCKED_COUNTRIES = 'US,FR'; + + const state = createState({}); + + const result = selectMoneyAccountGeoBlockedCountries(state as never); + + expect(result).toEqual(['US', 'FR']); + }); + + it('falls back to default blocked countries when both remote and env are absent', () => { + delete process.env.MM_MONEY_ACCOUNT_GEO_BLOCKED_COUNTRIES; + + const state = createState({}); + + const result = selectMoneyAccountGeoBlockedCountries(state as never); + + expect(result).toEqual(DEFAULT_MONEY_ACCOUNT_BLOCKED_COUNTRIES); + }); + + it('ignores remote flag when blockedRegions is not an array', () => { + process.env.MM_MONEY_ACCOUNT_GEO_BLOCKED_COUNTRIES = 'IE'; + + const state = createState({ + moneyAccountGeoBlockedCountries: { blockedRegions: 'not-an-array' }, + }); + + const result = selectMoneyAccountGeoBlockedCountries(state as never); + + expect(result).toEqual(['IE']); + }); + + it('DEFAULT_MONEY_ACCOUNT_BLOCKED_COUNTRIES includes GB', () => { + expect(DEFAULT_MONEY_ACCOUNT_BLOCKED_COUNTRIES).toContain('GB'); + }); +}); diff --git a/app/components/UI/Money/selectors/featureFlags.ts b/app/components/UI/Money/selectors/featureFlags.ts index 7a53c2b495a2..200ba522b2c5 100644 --- a/app/components/UI/Money/selectors/featureFlags.ts +++ b/app/components/UI/Money/selectors/featureFlags.ts @@ -2,6 +2,7 @@ import { createSelector } from 'reselect'; import { selectRemoteFeatureFlags } from '../../../../selectors/featureFlagController'; import { validatedVersionGatedFeatureFlag, + parseBlockedCountriesEnv, VersionGatedFeatureFlag, } from '../../../../util/remoteFeatureFlag'; import { isMoneyAccountEnabled } from '../../../../lib/Money/feature-flags'; @@ -153,3 +154,46 @@ export const selectMoneyTokensSortMode = createSelector( return 'fiatBalanceDesc'; }, ); + +/** Default blocked countries for Money account when no remote flag is configured. */ +export const DEFAULT_MONEY_ACCOUNT_BLOCKED_COUNTRIES = ['GB']; + +/** + * Selects the geo-blocked countries for the Money account from remote config or local fallback. + * Returns an array of ISO 3166-1 alpha-2 country codes (e.g., ['GB', 'US']). + * + * The Ramps geolocation API returns country codes like "GB" or "US-CA" (country-region). + * Matching uses startsWith to handle both country-only and country-region formats. + * + * Remote flag takes precedence over local env var. + * + * Examples: + * - Remote: { "blockedRegions": ["GB"] } - Block users in Great Britain + * - Remote: { "blockedRegions": ["GB", "US"] } - Block users in GB and US + * - Local env: "GB,US,FR" - Block users in GB, US, and FR + * + * If both remote and local are unavailable or invalid, defaults to blocking Great Britain. + */ +export const selectMoneyAccountGeoBlockedCountries = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags): string[] => { + // Try remote flag first (takes precedence) + const remoteFlag = remoteFeatureFlags?.moneyAccountGeoBlockedCountries as + | { blockedRegions?: string[] } + | undefined; + + if (Array.isArray(remoteFlag?.blockedRegions)) { + return remoteFlag.blockedRegions; + } + + // Fallback to local env var + const envBlockedCountries = parseBlockedCountriesEnv( + process.env.MM_MONEY_ACCOUNT_GEO_BLOCKED_COUNTRIES, + ); + + // If env var is also empty, use default blocked countries + return envBlockedCountries.length > 0 + ? envBlockedCountries + : DEFAULT_MONEY_ACCOUNT_BLOCKED_COUNTRIES; + }, +); diff --git a/app/components/UI/Money/utils/cardTransactionDisplayInfo.test.ts b/app/components/UI/Money/utils/cardTransactionDisplayInfo.test.ts index 603918028319..e999a6b5332f 100644 --- a/app/components/UI/Money/utils/cardTransactionDisplayInfo.test.ts +++ b/app/components/UI/Money/utils/cardTransactionDisplayInfo.test.ts @@ -28,7 +28,7 @@ describe('cardTransactionDisplayInfo', () => { expect(info.isIncoming).toBe(false); expect(info.icon).toBe(IconName.Card); expect(info.description).toBeUndefined(); - expect(info.primaryAmount).toBe('-5.38 USDC'); + expect(info.primaryAmount).toBe('-5.38 mUSD'); expect(info.fiatAmount).toContain('5.38'); expect(info.fiatAmount.startsWith('-')).toBe(true); }); @@ -41,7 +41,7 @@ describe('cardTransactionDisplayInfo', () => { }); // Assert - expect(info.primaryAmount).toBe('-5.38 USDC'); + expect(info.primaryAmount).toBe('-5.38 mUSD'); expect(info.fiatAmount).toContain('10.76'); }); }); diff --git a/app/components/UI/Money/utils/cardTransactionDisplayInfo.ts b/app/components/UI/Money/utils/cardTransactionDisplayInfo.ts index 280c548560d0..ccd7eed42720 100644 --- a/app/components/UI/Money/utils/cardTransactionDisplayInfo.ts +++ b/app/components/UI/Money/utils/cardTransactionDisplayInfo.ts @@ -4,6 +4,7 @@ import { strings } from '../../../../../locales/i18n'; import type { CardTransaction } from '../types/moneyActivity'; import type { MoneyTransactionDisplayInfo } from '../hooks/useMoneyTransactionDisplayInfo'; import { moneyFormatFiat } from './moneyFormatFiat'; +import { MONEY_ACCOUNT_DISPLAY_SYMBOL } from '../../Card/util/vedaToken'; export function cardTransactionDisplayInfo( card: CardTransaction, @@ -15,7 +16,7 @@ export function cardTransactionDisplayInfo( new BigNumber(10).pow(card.token.decimals), ); - const primaryAmount = `-${usdValue.toFixed(2)} ${card.token.symbol}`; + const primaryAmount = `-${usdValue.toFixed(2)} ${MONEY_ACCOUNT_DISPLAY_SYMBOL}`; const fiatAmount = usdToCurrentCurrencyRate && usdToCurrentCurrencyRate > 0 diff --git a/app/components/UI/Money/utils/depositFaqTokens.test.ts b/app/components/UI/Money/utils/depositFaqTokens.test.ts new file mode 100644 index 000000000000..de1a230cf9a3 --- /dev/null +++ b/app/components/UI/Money/utils/depositFaqTokens.test.ts @@ -0,0 +1,72 @@ +import { + MONEY_NO_FEE_TOKENS_FALLBACK, + resolveNoFeeTokens, + formatNoFeeTokenBullets, + formatBaseStablecoins, +} from './depositFaqTokens'; + +describe('depositFaqTokens', () => { + describe('resolveNoFeeTokens', () => { + it('returns the remote list when it is populated', () => { + const remote = { '0x1': ['USDC'] }; + + expect(resolveNoFeeTokens(remote)).toBe(remote); + }); + + it('falls back to the bundled list when remote is empty', () => { + expect(resolveNoFeeTokens({})).toBe(MONEY_NO_FEE_TOKENS_FALLBACK); + }); + }); + + describe('formatNoFeeTokenBullets', () => { + it('formats each chain as a bullet line with a friendly network name', () => { + expect(formatNoFeeTokenBullets(MONEY_NO_FEE_TOKENS_FALLBACK)).toBe( + [ + '• Ethereum: USDC, USDT, DAI, aUSDC, aUSDT, aDAI', + '• Arbitrum: USDC', + '• Base: USDC', + '• BNB Chain: USDC, USDT', + ].join('\n'), + ); + }); + + it('omits chains without a known network name', () => { + expect( + formatNoFeeTokenBullets({ '0x1': ['USDC'], '0x999999': ['USDC'] }), + ).toBe('• Ethereum: USDC'); + }); + + it('includes mUSD in the per-chain deposit list', () => { + expect( + formatNoFeeTokenBullets({ + '0x1': ['USDC', 'mUSD'], + '0xe708': ['mUSD'], + }), + ).toBe('• Ethereum: USDC, mUSD\n• Linea: mUSD'); + }); + }); + + describe('formatBaseStablecoins', () => { + it('returns the unique base stablecoins excluding aTokens, with an "or" list', () => { + expect(formatBaseStablecoins(MONEY_NO_FEE_TOKENS_FALLBACK)).toBe( + 'USDC, USDT, or DAI', + ); + }); + + it('joins two stablecoins with "or"', () => { + expect(formatBaseStablecoins({ '0x1': ['USDC', 'DAI'] })).toBe( + 'USDC or DAI', + ); + }); + + it('returns a single stablecoin without a connector', () => { + expect(formatBaseStablecoins({ '0x1': ['USDC', 'aUSDC'] })).toBe('USDC'); + }); + + it('excludes mUSD from the inline stablecoin list', () => { + expect(formatBaseStablecoins({ '0x1': ['USDC', 'mUSD', 'DAI'] })).toBe( + 'USDC or DAI', + ); + }); + }); +}); diff --git a/app/components/UI/Money/utils/depositFaqTokens.ts b/app/components/UI/Money/utils/depositFaqTokens.ts new file mode 100644 index 000000000000..0d70a257f1d5 --- /dev/null +++ b/app/components/UI/Money/utils/depositFaqTokens.ts @@ -0,0 +1,84 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { NETWORK_TO_NAME_MAP } from '../../../../core/Engine/constants'; +import { WildcardTokenList } from '../../Earn/utils/wildcardTokenList'; + +const NETWORK_NAMES = NETWORK_TO_NAME_MAP as Record; + +/** + * Symbols excluded from the inline faq_a1 sentence only. That sentence describes + * depositing a stablecoin that "converts to mUSD", so listing mUSD itself reads + * as circular. mUSD is still shown in the per-chain deposit list (faq_a7) since + * it is a valid no-fee deposit. + */ +const INLINE_EXCLUDED_SYMBOLS = new Set(['MUSD']); + +const isInlineExcludedSymbol = (symbol: string): boolean => + INLINE_EXCLUDED_SYMBOLS.has(symbol.toUpperCase()); + +/** + * Default no-fee deposit tokens per chain, used when the remote + * `earnMoneyDepositNoFeeTokens` flag is not configured so the FAQ never renders + * an empty list. Mirrors the wildcard list shape: chain id -> token symbols. + */ +export const MONEY_NO_FEE_TOKENS_FALLBACK: WildcardTokenList = { + [CHAIN_IDS.MAINNET]: ['USDC', 'USDT', 'DAI', 'aUSDC', 'aUSDT', 'aDAI'], + [CHAIN_IDS.ARBITRUM]: ['USDC'], + [CHAIN_IDS.BASE]: ['USDC'], + [CHAIN_IDS.BSC]: ['USDC', 'USDT'], +}; + +/** + * Returns the remote no-fee token list when populated, otherwise the bundled + * fallback. + */ +export const resolveNoFeeTokens = ( + remote: WildcardTokenList, +): WildcardTokenList => + Object.keys(remote).length > 0 ? remote : MONEY_NO_FEE_TOKENS_FALLBACK; + +/** + * Formats the per-chain no-fee token list as bullet lines, e.g. + * "• Ethereum: USDC, USDT, DAI". Chains without a known network name are + * omitted so the user-facing FAQ never shows a raw chain id. + */ +export const formatNoFeeTokenBullets = (list: WildcardTokenList): string => + Object.entries(list) + .map(([chainId, symbols]) => { + const name = NETWORK_NAMES[chainId]; + return name ? `• ${name}: ${symbols.join(', ')}` : null; + }) + .filter((line): line is string => line !== null) + .join('\n'); + +/** + * Returns the unique base stablecoin symbols (excluding aave-wrapped aTokens) + * across all chains, formatted as an "or" list, e.g. "USDC, USDT, or DAI". + */ +export const formatBaseStablecoins = (list: WildcardTokenList): string => { + const seen = new Set(); + const stablecoins: string[] = []; + for (const symbols of Object.values(list)) { + for (const symbol of symbols) { + // aTokens (aUSDC, aUSDT, aDAI) are wrapped variants; the inline FAQ + // sentence lists only the base stablecoins, and excludes mUSD. + if ( + /^a[A-Z]/.test(symbol) || + isInlineExcludedSymbol(symbol) || + seen.has(symbol) + ) { + continue; + } + seen.add(symbol); + stablecoins.push(symbol); + } + } + + if (stablecoins.length <= 1) { + return stablecoins.join(''); + } + if (stablecoins.length === 2) { + return `${stablecoins[0]} or ${stablecoins[1]}`; + } + const last = stablecoins[stablecoins.length - 1]; + return `${stablecoins.slice(0, -1).join(', ')}, or ${last}`; +}; diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx index 566c9b8f785c..9aae867b2255 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx @@ -12,7 +12,7 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import React, { useCallback, useMemo } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { Pressable, RefreshControl, ScrollView } from 'react-native'; import { SafeAreaView, @@ -37,7 +37,9 @@ import { PREDICT_GAME_DETAILS_CONTENT_TEST_IDS } from './PredictGameDetailsConte const CHIPS_STICKY_INDEX = 2; -const PredictGameDetailsContent: React.FC = ({ +const PredictGameDetailsContentComponent: React.FC< + PredictGameDetailsContentProps +> = ({ market, onBack, onRefresh, @@ -231,4 +233,10 @@ const PredictGameDetailsContent: React.FC = ({ ); }; +// Memoized so a parent (PredictMarketDetails) re-render driven by its own live +// subscriptions does not re-render this entire subtree when our props are +// unchanged. The screen's live odds updates are driven by this component's own +// hooks instead. +const PredictGameDetailsContent = memo(PredictGameDetailsContentComponent); + export default PredictGameDetailsContent; diff --git a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx index a341bbc90243..8a2dc2b8546d 100644 --- a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx +++ b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx @@ -8,7 +8,7 @@ import { } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { NavigationProp, useNavigation } from '@react-navigation/native'; -import React from 'react'; +import React, { memo } from 'react'; import { Image, View } from 'react-native'; import { strings } from '../../../../../../locales/i18n'; import Button, { @@ -50,7 +50,7 @@ interface PredictMarketOutcomeProps { const MAX_LABEL_LENGTH = 6; -const PredictMarketOutcome: React.FC = ({ +const PredictMarketOutcomeComponent: React.FC = ({ market, outcome, entryPoint = PredictEventValues.ENTRY_POINT.PREDICT_FEED, @@ -221,4 +221,9 @@ const PredictMarketOutcome: React.FC = ({ ); }; +// Memoized so that in a live-updating outcomes list only the rows whose +// `outcome` reference actually changed re-render. Relies on useOpenOutcomes +// preserving identity for unchanged outcomes/tokens. +const PredictMarketOutcome = memo(PredictMarketOutcomeComponent); + export default PredictMarketOutcome; diff --git a/app/components/UI/Predict/hooks/useLiveMarketPrices.ts b/app/components/UI/Predict/hooks/useLiveMarketPrices.ts index f8d1dc28b989..83069342a372 100644 --- a/app/components/UI/Predict/hooks/useLiveMarketPrices.ts +++ b/app/components/UI/Predict/hooks/useLiveMarketPrices.ts @@ -74,6 +74,10 @@ export const useLiveMarketPrices = ( const isMountedRef = useRef(true); const tokenIdsRef = useRef(tokenIds); + // Mirrors the latest `prices` map so we can detect no-op ticks without + // adding `prices` to `handlePriceUpdates`' dependencies. Kept in sync inside + // every state updater below. + const pricesRef = useRef(prices); // Use JSON.stringify to avoid key collisions if token IDs contain commas const tokenIdsKey = useMemo( @@ -95,11 +99,30 @@ export const useLiveMarketPrices = ( liveMarketPriceCache.set(update.tokenId, { price: update, updatedAt }); }); + // Skip the state update entirely when none of the incoming values differ + // from what we already have. This prevents a re-render of the whole + // subscriber tree for redundant ticks that carry no visible change. + const current = pricesRef.current; + const hasChange = updates.some((update) => { + const existing = current.get(update.tokenId); + return ( + !existing || + existing.price !== update.price || + existing.bestBid !== update.bestBid || + existing.bestAsk !== update.bestAsk + ); + }); + + if (!hasChange) { + return; + } + setPrices((prevPrices) => { const newPrices = new Map(prevPrices); updates.forEach((update) => { newPrices.set(update.tokenId, update); }); + pricesRef.current = newPrices; return newPrices; }); @@ -125,6 +148,7 @@ export const useLiveMarketPrices = ( nextPrices.set(tokenId, cachedPrice); } }); + pricesRef.current = nextPrices; return nextPrices; }); diff --git a/app/components/UI/Predict/hooks/usePredictPrices.tsx b/app/components/UI/Predict/hooks/usePredictPrices.tsx index eb448292ee4d..1db1343abda7 100644 --- a/app/components/UI/Predict/hooks/usePredictPrices.tsx +++ b/app/components/UI/Predict/hooks/usePredictPrices.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Engine from '../../../../core/Engine'; import Logger from '../../../../util/Logger'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; @@ -62,7 +62,10 @@ export const usePredictPrices = ( } }, [enabled]); - const queriesKey = JSON.stringify(queries); + // Memoize so the (potentially large) serialization only runs when the + // queries array identity actually changes, not on every re-render driven + // by live price ticks. + const queriesKey = useMemo(() => JSON.stringify(queries), [queries]); const fetchPrices = useCallback(async () => { if (!enabled) { diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts index d2391b5037fd..f3de4a40c7a9 100644 --- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts +++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts @@ -2335,6 +2335,9 @@ describe('WebSocketManager', () => { ], timestamp: '2025-01-12T12:00:01Z', }); + // Price emission is throttled: the first message flushes immediately + // (leading edge) and the second is coalesced into the trailing flush. + jest.advanceTimersByTime(250); expect(throwingCallback).toHaveBeenCalledTimes(2); expect(healthyCallback).toHaveBeenCalledTimes(2); @@ -2378,7 +2381,7 @@ describe('WebSocketManager', () => { context: { name: 'WebSocketManager', data: { - method: 'handleMarketMessage', + method: 'flushMarketPriceUpdates', subscriptionKey: 'token1', }, }, diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts index 9697c5a33125..536e0b00ec4b 100644 --- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts +++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts @@ -40,6 +40,7 @@ const RTDS_CRYPTO_PRICES_CHAINLINK_TOPIC = 'crypto_prices_chainlink'; const RTDS_PING_INTERVAL_MS = 5000; const DEFAULT_THROTTLE_INTERVAL_MS = 16; const ORDERBOOK_EMIT_THROTTLE_MS = 250; +const MARKET_PRICE_EMIT_THROTTLE_MS = 250; const HEARTBEAT_CHECK_INTERVAL_MS = 5000; const MARKET_STALE_THRESHOLD_MS = 60000; @@ -107,7 +108,16 @@ export class WebSocketManager { private gameSubscriptions: Map> = new Map(); private priceSubscriptions: Map> = new Map(); + // Parsed token-id set per subscription key, precomputed once at subscribe + // time so the high-frequency message handler never re-runs + // `key.split(',')` + `new Set(...)` per message. + private priceSubscriptionTokenSets: Map> = new Map(); private marketPriceCache: Map = new Map(); + // Coalesced price emission: token ids that changed since the last flush, plus + // the leading/trailing throttle timer. Avoids re-rendering subscribers at the + // full WebSocket message rate. + private marketPricePendingTokenIds: Set = new Set(); + private marketPriceEmitTimer: ReturnType | null = null; private orderbookSubscriptions: Map> = new Map(); private orderbookState: Map< @@ -431,6 +441,10 @@ export class WebSocketManager { if (!callbacks) { callbacks = new Set(); this.priceSubscriptions.set(subscriptionKey, callbacks); + this.priceSubscriptionTokenSets.set( + subscriptionKey, + new Set(subscriptionKey.split(',').filter(Boolean)), + ); } callbacks.add(callback); @@ -471,6 +485,7 @@ export class WebSocketManager { subscriptionCallbacks.delete(callback); if (subscriptionCallbacks.size === 0) { this.priceSubscriptions.delete(subscriptionKey); + this.priceSubscriptionTokenSets.delete(subscriptionKey); const remainingPriceTokenIds = this.getSubscribedMarketTokenIds(); const tokenIdsToUnsubscribe = tokenIds.filter( (tokenId) => @@ -626,6 +641,74 @@ export class WebSocketManager { }; } + /** + * Throttle market price fan-out with a leading + trailing edge, mirroring + * {@link scheduleOrderbookEmit}. The first change in a window is delivered + * immediately; subsequent changes within the window are batched and flushed + * once when the window closes. + */ + private scheduleMarketPriceEmit(): void { + if (this.marketPriceEmitTimer === null) { + this.flushMarketPriceUpdates(); + this.marketPriceEmitTimer = setTimeout(() => { + this.marketPriceEmitTimer = null; + if (this.marketPricePendingTokenIds.size > 0) { + this.flushMarketPriceUpdates(); + } + }, MARKET_PRICE_EMIT_THROTTLE_MS); + } + } + + private flushMarketPriceUpdates(): void { + if (this.marketPricePendingTokenIds.size === 0) { + return; + } + + const changedTokenIds = this.marketPricePendingTokenIds; + this.marketPricePendingTokenIds = new Set(); + + this.priceSubscriptions.forEach((callbacks, key) => { + if (callbacks.size === 0) { + return; + } + const subscribedTokenIds = this.priceSubscriptionTokenSets.get(key); + if (!subscribedTokenIds) { + return; + } + + const relevantUpdates: PriceUpdate[] = []; + changedTokenIds.forEach((tokenId) => { + if (subscribedTokenIds.has(tokenId)) { + const cached = this.marketPriceCache.get(tokenId); + if (cached) { + relevantUpdates.push(cached); + } + } + }); + + if (relevantUpdates.length === 0) { + return; + } + + callbacks.forEach((callback) => { + try { + callback(relevantUpdates); + } catch (error) { + DevLogger.log('WebSocketManager: Market price subscriber failed', { + error, + subscriptionKey: key, + }); + Logger.error( + this.toError(error), + this.getErrorContext('flushMarketPriceUpdates', 'market', { + subscriptionKey: key, + }), + ); + } + }); + }); + } + private emitOrderbookSnapshot(tokenId: string): void { const cached = this.orderbookState.get(tokenId); const callbacks = this.orderbookSubscriptions.get(tokenId); @@ -821,36 +904,12 @@ export class WebSocketManager { updates.forEach((update) => { this.marketPriceCache.set(update.tokenId, update); + this.marketPricePendingTokenIds.add(update.tokenId); }); - this.priceSubscriptions.forEach((callbacks, key) => { - const subscribedTokenIds = new Set(key.split(',')); - const relevantUpdates = updates.filter((u) => - subscribedTokenIds.has(u.tokenId), - ); - - if (relevantUpdates.length > 0) { - callbacks.forEach((callback) => { - try { - callback(relevantUpdates); - } catch (error) { - DevLogger.log( - 'WebSocketManager: Market price subscriber failed', - { - error, - subscriptionKey: key, - }, - ); - Logger.error( - this.toError(error), - this.getErrorContext('handleMarketMessage', 'market', { - subscriptionKey: key, - }), - ); - } - }); - } - }); + // Coalesce emission instead of fanning out synchronously on every + // message. Live odds stream far faster than the UI needs to render. + this.scheduleMarketPriceEmit(); // Intentionally NOT forwarding `price_change` to orderbook subscribers. // The payload only carries `best_bid` / `best_ask` (no per-level @@ -1054,6 +1113,11 @@ export class WebSocketManager { this.cleanupMarketConnection(); this.marketReconnectAttempts = 0; this.marketPriceCache.clear(); + if (this.marketPriceEmitTimer) { + clearTimeout(this.marketPriceEmitTimer); + this.marketPriceEmitTimer = null; + } + this.marketPricePendingTokenIds.clear(); // Drop cached orderbook state so a future reconnect doesn't replay a // stale snapshot to subscribers. The provider's REST bootstrap and the // next live `book` event will repopulate. Also flush throttle timers so @@ -1456,6 +1520,7 @@ export class WebSocketManager { this.disconnectAll(); this.gameSubscriptions.clear(); this.priceSubscriptions.clear(); + this.priceSubscriptionTokenSets.clear(); this.cryptoPriceSubscriptions.clear(); this.marketPriceCache.clear(); this.orderbookSubscriptions.clear(); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 12c17dc506d9..4e5c19c7f0ba 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -228,69 +228,87 @@ const PredictMarketDetails: React.FC = () => { timeframes, } = useChartData({ market, hasAnyOutcomeToken }); + // The game branch (PredictGameDetailsContent) does not consume openOutcomes / + // yesPercentage and runs its own live price subscriptions internally. Keeping + // this hook live for game markets only adds a redundant high-frequency price + // subscription that re-renders this whole screen on every tick. const shouldRefreshOpenOutcomePrices = - market?.status === PredictMarketStatus.OPEN && !isBuySheetOpen; + market?.status === PredictMarketStatus.OPEN && + !isBuySheetOpen && + !market?.game; const { closedOutcomes, openOutcomes, yesPercentage } = useOpenOutcomes({ market, enabled: shouldRefreshOpenOutcomePrices, }); - const handleBackPress = () => { + const handleBackPress = useCallback(() => { if (navigation.canGoBack()) { navigation.goBack(); } else { // If we can't go back, navigate to the main predict screen navigation.navigate(Routes.PREDICT.ROOT); } - }; + }, [navigation]); - const handleBuyPress = ( - token: PredictOutcomeToken, - selectedMarket: typeof market = market, - ) => { - if (!selectedMarket) { - return; - } - executeGuardedAction( - () => { - const selectedOpenOutcomes = - selectedMarket.id === market?.id - ? openOutcomes - : selectedMarket.outcomes.filter( - (outcome) => outcome.status === OPEN_PREDICT_OUTCOME_STATUS, - ); - const matchingOutcome = - selectedMarket.outcomes.find((o) => - o.tokens.some((marketToken) => marketToken.id === token.id), - ) ?? - selectedOpenOutcomes[0] ?? - selectedMarket.outcomes?.[0]; - openBuySheet({ - market: selectedMarket, - outcome: matchingOutcome, - outcomeToken: token, - entryPoint: - entryPoint || PredictEventValues.ENTRY_POINT.PREDICT_MARKET_DETAILS, - ...(predictFeedTab && { predictFeedTab }), - ...(predictScreen && { predictScreen }), - ...(transactionActiveAbTests?.length && { transactionActiveAbTests }), - }); - }, - { - attemptedAction: PredictEventValues.ATTEMPTED_ACTION.PREDICT, - }, - ); - }; + const handleBuyPress = useCallback( + (token: PredictOutcomeToken, selectedMarket: typeof market = market) => { + if (!selectedMarket) { + return; + } + executeGuardedAction( + () => { + const selectedOpenOutcomes = + selectedMarket.id === market?.id + ? openOutcomes + : selectedMarket.outcomes.filter( + (outcome) => outcome.status === OPEN_PREDICT_OUTCOME_STATUS, + ); + const matchingOutcome = + selectedMarket.outcomes.find((o) => + o.tokens.some((marketToken) => marketToken.id === token.id), + ) ?? + selectedOpenOutcomes[0] ?? + selectedMarket.outcomes?.[0]; + openBuySheet({ + market: selectedMarket, + outcome: matchingOutcome, + outcomeToken: token, + entryPoint: + entryPoint || + PredictEventValues.ENTRY_POINT.PREDICT_MARKET_DETAILS, + ...(predictFeedTab && { predictFeedTab }), + ...(predictScreen && { predictScreen }), + ...(transactionActiveAbTests?.length && { + transactionActiveAbTests, + }), + }); + }, + { + attemptedAction: PredictEventValues.ATTEMPTED_ACTION.PREDICT, + }, + ); + }, + [ + executeGuardedAction, + openOutcomes, + market, + openBuySheet, + entryPoint, + predictFeedTab, + predictScreen, + transactionActiveAbTests, + ], + ); - const handleClaimPress = async () => { + const handleClaimPress = useCallback(async () => { await executeGuardedAction( async () => { await claim(); }, { attemptedAction: PredictEventValues.ATTEMPTED_ACTION.CLAIM }, ); - }; + }, [executeGuardedAction, claim]); const handleTabPress = (tabIndex: number) => { if (!tabsReady) return; diff --git a/app/components/UI/Predict/views/PredictMarketDetails/hooks/useOpenOutcomes.ts b/app/components/UI/Predict/views/PredictMarketDetails/hooks/useOpenOutcomes.ts index 2a2f9369a7a8..d54a9f6ff47e 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/hooks/useOpenOutcomes.ts +++ b/app/components/UI/Predict/views/PredictMarketDetails/hooks/useOpenOutcomes.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import { usePredictPrices } from '../../../hooks/usePredictPrices'; import { useLiveMarketPrices } from '../../../hooks/useLiveMarketPrices'; import { getPredictBuyPrice } from '../../../utils/prices'; @@ -56,34 +56,74 @@ export const useOpenOutcomes = ({ ? priceQueries : EMPTY_PRICE_QUERIES; - const { prices } = usePredictPrices({ - queries: activePriceQueries, - enabled: shouldFetchPrices, - pollingInterval: shouldFetchPrices ? 2000 : undefined, - }); - const tokenIds = useMemo( () => activePriceQueries.map((q) => q.outcomeTokenId), [activePriceQueries], ); - const { getPrice: getLivePrice } = useLiveMarketPrices(tokenIds, { + const { getPrice: getLivePrice, isConnected: isLiveConnected } = + useLiveMarketPrices(tokenIds, { + enabled: shouldFetchPrices, + }); + + // The live WebSocket feed is the primary source of truth for prices. When it + // is connected we only keep a slow REST poll as a freshness fallback; this + // avoids both sources driving full re-renders at the same time. + const { prices } = usePredictPrices({ + queries: activePriceQueries, enabled: shouldFetchPrices, + pollingInterval: shouldFetchPrices + ? isLiveConnected + ? 10000 + : 2000 + : undefined, }); + const previousOpenOutcomesRef = useRef([]); + const previousOpenOutcomesBaseRef = useRef(null); + // Price precedence: live WebSocket bestAsk > REST buy price > base market price. - const openOutcomes = useMemo( - () => - openOutcomesBase.map((outcome) => ({ - ...outcome, - tokens: outcome.tokens.map((token) => ({ - ...token, - price: - getPredictBuyPrice(token, getLivePrice(token.id), prices) ?? - token.price, - })), - })), - [openOutcomesBase, prices, getLivePrice], - ); + // + // Identity preservation: rebuilding every outcome/token object on each price + // tick gives all of them new references, which forces every (memoized) row to + // re-render even when only one token's price changed. Here we reuse the + // previous token/outcome object whenever the computed price is unchanged, so + // only the rows that actually changed get a new reference. When the base + // market data changes (different array identity), we rebuild from scratch. + const openOutcomes = useMemo(() => { + const baseUnchanged = + previousOpenOutcomesBaseRef.current === openOutcomesBase; + const previousById = baseUnchanged + ? new Map(previousOpenOutcomesRef.current.map((o) => [o.id, o])) + : undefined; + + const next = openOutcomesBase.map((outcome) => { + const previousOutcome = previousById?.get(outcome.id); + let changed = !previousOutcome; + + const tokens = outcome.tokens.map((token) => { + const price = + getPredictBuyPrice(token, getLivePrice(token.id), prices) ?? + token.price; + const previousToken = previousOutcome?.tokens.find( + (t) => t.id === token.id, + ); + if (previousToken && previousToken.price === price) { + return previousToken; + } + changed = true; + return { ...token, price }; + }); + + if (previousOutcome && !changed) { + return previousOutcome; + } + return { ...outcome, tokens }; + }); + + previousOpenOutcomesRef.current = next; + previousOpenOutcomesBaseRef.current = openOutcomesBase; + return next; + }, [openOutcomesBase, prices, getLivePrice]); const yesPercentage = useMemo((): number => { // Use real-time price if available from open outcomes diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.tsx index 2f743dfefa21..24636ff45827 100644 --- a/app/components/UI/TokenDetails/Views/TokenDetails.tsx +++ b/app/components/UI/TokenDetails/Views/TokenDetails.tsx @@ -216,6 +216,7 @@ const TokenDetails: React.FC<{ timePeriod, setTimePeriod, chartNavigationButtons, + hasInsufficientCoverage, } = useTokenPrice({ token }); const [chartPricePositive, setChartPricePositive] = useState( @@ -316,6 +317,7 @@ const TokenDetails: React.FC<{ comparePrice={comparePrice} prices={prices} isLoading={isLoading} + hasInsufficientCoverage={hasInsufficientCoverage} timePeriod={timePeriod} setTimePeriod={setTimePeriod} chartNavigationButtons={chartNavigationButtons} diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx index eea482074dbb..314043482328 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx @@ -164,6 +164,7 @@ export interface AssetOverviewContentProps { comparePrice: number; prices: TokenPrice[]; isLoading: boolean; + hasInsufficientCoverage?: boolean; timePeriod: TimePeriod; setTimePeriod: (period: TimePeriod) => void; @@ -234,6 +235,7 @@ const AssetOverviewContent: React.FC = ({ comparePrice, prices, isLoading, + hasInsufficientCoverage, timePeriod, setTimePeriod, chartNavigationButtons, @@ -752,6 +754,7 @@ const AssetOverviewContent: React.FC = ({ currentPrice={currentPrice} comparePrice={comparePrice} isLoading={isLoading} + hasInsufficientCoverage={hasInsufficientCoverage} onPriceDirectionChange={onPriceDirectionChange} useAmbientColor={useAmbientColor} /> diff --git a/app/components/UI/TokenDetails/hooks/useTokenPrice.test.ts b/app/components/UI/TokenDetails/hooks/useTokenPrice.test.ts index c511a7ea468f..ae97562cb30c 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenPrice.test.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenPrice.test.ts @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react-native'; +import { renderHook, waitFor, act } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import { useTokenPrice } from './useTokenPrice'; import { TokenI } from '../../Tokens/types'; @@ -10,6 +10,7 @@ import { import { isAssetFromSearch } from '../../../../selectors/tokenSearchDiscoveryDataController'; import { selectTokenMarketData } from '../../../../selectors/tokenRatesController'; import useTokenHistoricalPrices from '../../../hooks/useTokenHistoricalPrices'; +import { getTokenExchangeRate } from '../../Bridge/utils/exchange-rates'; jest.mock('react-redux', () => ({ useSelector: jest.fn(), @@ -43,6 +44,7 @@ jest.mock('../../Bridge/utils/exchange-rates', () => ({ const mockUseSelector = jest.mocked(useSelector); const mockIsAssetFromSearch = jest.mocked(isAssetFromSearch); const mockUseTokenHistoricalPrices = jest.mocked(useTokenHistoricalPrices); +const mockGetTokenExchangeRate = jest.mocked(getTokenExchangeRate); describe('useTokenPrice', () => { const defaultCurrencyRates = { @@ -85,6 +87,7 @@ describe('useTokenPrice', () => { data: [], isLoading: false, error: undefined, + hasInsufficientCoverage: false, }); setupDefaultMocks(); }); @@ -103,6 +106,7 @@ describe('useTokenPrice', () => { isLoading: true, data: undefined, error: undefined, + hasInsufficientCoverage: false, }); const { result } = renderHook(() => useTokenPrice({ token })); @@ -135,6 +139,7 @@ describe('useTokenPrice', () => { ], isLoading: false, error: undefined, + hasInsufficientCoverage: false, }); const { result } = renderHook(() => useTokenPrice({ token })); @@ -209,6 +214,7 @@ describe('useTokenPrice', () => { data: [['1700000000', 145.0]], isLoading: false, error: undefined, + hasInsufficientCoverage: false, }); const { result } = renderHook(() => @@ -221,4 +227,81 @@ describe('useTokenPrice', () => { expect(result.current.comparePrice).toBe(145.0); expect(result.current.priceDiff).toBe(5.5); }); + + it('forwards hasInsufficientCoverage from the historical prices hook', async () => { + const token = { + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + chainId: '0x1', + } as TokenI; + + mockUseTokenHistoricalPrices.mockReturnValue({ + data: [['1700000000', 1.0]], + isLoading: false, + error: undefined, + hasInsufficientCoverage: true, + }); + + const { result } = renderHook(() => useTokenPrice({ token })); + + await waitFor(() => { + expect(result.current.hasInsufficientCoverage).toBe(true); + }); + }); + + it('clears stale fetchedMarketData when token changes', async () => { + const tokenA = { + address: '0xaaaa', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + } as TokenI; + const tokenB = { + address: '0xbbbb', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + } as TokenI; + + setupDefaultMocks(); + mockUseTokenHistoricalPrices.mockReturnValue({ + data: [], + isLoading: false, + error: undefined, + hasInsufficientCoverage: false, + }); + + let resolveA!: (v: unknown) => void; + let resolveB!: (v: unknown) => void; + + mockGetTokenExchangeRate + .mockImplementationOnce( + () => + new Promise((r) => { + resolveA = r as (v: unknown) => void; + }), + ) + .mockImplementationOnce( + () => + new Promise((r) => { + resolveB = r as (v: unknown) => void; + }), + ); + + const { rerender, result } = renderHook( + ({ token }: { token: TokenI }) => useTokenPrice({ token }), + { initialProps: { token: tokenA } }, + ); + + rerender({ token: tokenB }); + + await act(async () => { + resolveA({ price: 999, pricePercentChange1d: 50 }); + }); + + expect(result.current.currentPrice).toBe(0); + + await act(async () => { + resolveB({ price: 42, pricePercentChange1d: 5 }); + }); + + await waitFor(() => { + expect(result.current.currentPrice).toBe(42); + }); + }); }); diff --git a/app/components/UI/TokenDetails/hooks/useTokenPrice.ts b/app/components/UI/TokenDetails/hooks/useTokenPrice.ts index 04b46efd9ad3..71e69cc3b22b 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenPrice.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenPrice.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { Hex } from '@metamask/utils'; import { selectNativeCurrencyByChainId } from '../../../../selectors/networkController'; @@ -19,10 +19,27 @@ import { import { calculateAssetPrice } from '../../AssetOverview/utils/calculateAssetPrice'; import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { selectTokenMarketData } from '../../../../selectors/tokenRatesController'; +import { type MarketDataDetails } from '@metamask/assets-controllers'; import { getTokenExchangeRate } from '../../Bridge/utils/exchange-rates'; import { isNonEvmChainId } from '../../../../core/Multichain/utils'; import { safeToChecksumAddress } from '../../../../util/address'; +/** + * Time ranges where the spot-prices API provides a reliable pre-computed + * percentage, mapped to the corresponding MarketDataDetails field. + * Ranges not covered here (3m, 3y, all) have no matching API field and + * will fall back to the historical-prices-derived percentage. + */ +const SPOT_PRICE_PCT_BY_TIME_PERIOD: Partial< + Record +> = { + '1d': 'pricePercentChange1d', + '1w': 'pricePercentChange7d', + '7d': 'pricePercentChange7d', + '1m': 'pricePercentChange30d', + '1y': 'pricePercentChange1y', +}; + export interface UseTokenPriceResult { currentPrice: number; priceDiff: number; @@ -33,6 +50,7 @@ export interface UseTokenPriceResult { setTimePeriod: (period: TimePeriod) => void; chartNavigationButtons: TimePeriod[]; currentCurrency: string; + hasInsufficientCoverage: boolean; } export interface UseTokenPriceParams { @@ -72,7 +90,11 @@ export const useTokenPrice = ({ ? safeToChecksumAddress(token.address) : token.address; - const { data: prices = [], isLoading } = useTokenHistoricalPrices({ + const { + data: prices = [], + isLoading, + hasInsufficientCoverage, + } = useTokenHistoricalPrices({ asset: token, address: token.address as Hex, chainId, @@ -88,13 +110,29 @@ export const useTokenPrice = ({ [isNonEvmToken], ); - const marketDataRate = - allTokenMarketData?.[chainId]?.[itemAddress as Hex]?.price; + const tokenMarketEntry = allTokenMarketData?.[chainId]?.[itemAddress as Hex]; + const marketDataRate = tokenMarketEntry?.price; const [fetchedRate, setFetchedRate] = useState(); + const [fetchedMarketData, setFetchedMarketData] = useState< + MarketDataDetails | undefined + >(); + const fetchIdRef = useRef(0); + // Stable token key to prevent unnecessary re-fetches + const tokenKey = `${chainId}-${itemAddress}-${currentCurrency}`; + + // For non-imported tokens (not in Redux), fetch price + market data in a + // single call. This gives us both the exchange rate and the pre-computed + // percentage changes from the spot-prices API, avoiding the historical-prices + // endpoint's incomplete-data problem for newly-listed tokens. useEffect(() => { + setFetchedRate(undefined); + setFetchedMarketData(undefined); + if (marketDataRate !== undefined || !itemAddress) { + // Token data already available in Redux or no address - mark fetch as "not needed" + setFetchedMarketData({} as MarketDataDetails); return; } @@ -104,42 +142,63 @@ export const useTokenPrice = ({ conversionRateByTicker?.[nativeCurrency]?.conversionRate; if (!isNonEvm && !nativeTokenConversionRate) { + // Can't fetch without conversion rate - mark fetch as "not possible" + setFetchedMarketData({} as MarketDataDetails); return; } - const fetchRate = async () => { + const id = ++fetchIdRef.current; + + const fetchData = async () => { try { - const tokenFiatPrice = await getTokenExchangeRate({ + const data = (await getTokenExchangeRate({ chainId, tokenAddress: itemAddress, currency: currentCurrency, - }); + includeMarketData: true, + })) as MarketDataDetails | undefined; + + if (id !== fetchIdRef.current) return; - if (!tokenFiatPrice) { + if (!data?.price) { setFetchedRate(undefined); + // Set empty object to indicate "fetch completed but no data available" + // This prevents infinite loading when API returns incomplete data + setFetchedMarketData({} as MarketDataDetails); return; } + setFetchedMarketData(data); + if (isNonEvm) { - setFetchedRate(tokenFiatPrice); + setFetchedRate(data.price); } else if (nativeTokenConversionRate) { - setFetchedRate(tokenFiatPrice / nativeTokenConversionRate); + setFetchedRate(data.price / nativeTokenConversionRate); } - } catch (error) { - console.error('Failed to fetch token exchange rate:', error); + } catch { + if (id !== fetchIdRef.current) return; setFetchedRate(undefined); + // Set empty object to indicate "fetch attempted but failed" + // This prevents infinite loading when API request fails + setFetchedMarketData({} as MarketDataDetails); } }; - fetchRate(); - }, [ - chainId, - itemAddress, - currentCurrency, - marketDataRate, - nativeCurrency, - conversionRateByTicker, - ]); + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tokenKey, marketDataRate]); + + // For time periods that use spot-prices percentage (1d, 1w, 1m, 1y), + // wait for spot-prices to load before showing data to avoid flicker. + // For other periods (3m, 3y, all), we must rely on historical prices. + const spotPctField = SPOT_PRICE_PCT_BY_TIME_PERIOD[timePeriod]; + const needsSpotPriceFetch = marketDataRate === undefined && !!itemAddress; + // Wait for spot-prices data when: token not in Redux, time period uses spot %, + // and we haven't fetched yet (fetchedMarketData is still undefined). + const isWaitingForSpotPrice = + needsSpotPriceFetch && + spotPctField !== undefined && + fetchedMarketData === undefined; const exchangeRate = marketDataRate ?? fetchedRate; @@ -169,16 +228,40 @@ export const useTokenPrice = ({ comparePrice = calculatedComparePrice; } + // When the spot-prices API has a pre-computed percentage for the active + // time range, prefer it over the percentage derived from historical-prices. + // The historical-prices endpoint can return incomplete data for newly-listed + // tokens (e.g., only 6h of data when 24h is requested), causing wildly + // incorrect percentages. + // For imported tokens, read from Redux (tokenMarketEntry); for non-imported + // tokens, fall back to the fetched market data. + const spotPct = spotPctField + ? ((tokenMarketEntry?.[spotPctField] as number | undefined) ?? + (fetchedMarketData?.[spotPctField] as number | undefined) ?? + null) + : null; + + if (spotPct != null && currentPrice > 0) { + const derivedComparePrice = currentPrice / (1 + spotPct / 100); + comparePrice = derivedComparePrice; + priceDiff = currentPrice - derivedComparePrice; + } + + // Combine loading states: wait for both historical prices AND spot-prices + // (when needed for the current time period) to avoid percentage flicker. + const combinedIsLoading = isLoading || isWaitingForSpotPrice; + return { currentPrice, priceDiff, comparePrice, prices, - isLoading, + isLoading: combinedIsLoading, timePeriod, setTimePeriod, chartNavigationButtons, currentCurrency, + hasInsufficientCoverage, }; }; diff --git a/app/components/Views/ActivityList/ActivityList.styles.ts b/app/components/Views/ActivityList/ActivityList.styles.ts new file mode 100644 index 000000000000..480f28298088 --- /dev/null +++ b/app/components/Views/ActivityList/ActivityList.styles.ts @@ -0,0 +1,28 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { colors } = params.theme; + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + emptyList: { + width: '100%', + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 40, + }, + modal: { + justifyContent: 'flex-end', + margin: 0, + }, + scrollViewContent: { + flex: 1, + justifyContent: 'flex-end', + }, + }); +}; + +export default styleSheet; diff --git a/app/components/Views/ActivityList/ActivityList.test.tsx b/app/components/Views/ActivityList/ActivityList.test.tsx new file mode 100644 index 000000000000..a3724af5968c --- /dev/null +++ b/app/components/Views/ActivityList/ActivityList.test.tsx @@ -0,0 +1,564 @@ +import React from 'react'; +import { + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import ActivityList from './ActivityList'; +import { ActivityListSelectorsIDs } from './ActivityList.testIds'; +import { useTransactionsQuery } from './useTransactionsQuery'; +import { useLocalActivityItems } from './hooks/useLocalActivityItems'; +import { useUnifiedTxActions } from './useUnifiedTxActions'; +import Engine from '../../../core/Engine'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../selectors/accountsController', () => ({ + selectSelectedInternalAccount: jest.fn((state) => state.selectedAccount), +})); + +jest.mock('../../../selectors/currencyRateController', () => ({ + selectCurrentCurrency: jest.fn((state) => state.currentCurrency), +})); + +jest.mock('../../../selectors/multichain/multichain', () => ({ + selectNonEvmTransactionsForSelectedAccountGroup: jest.fn( + (state) => state.nonEvmState, + ), +})); + +jest.mock( + '../../../selectors/multichainAccounts/accountTreeController', + () => ({ + selectSelectedAccountGroupInternalAccounts: jest.fn( + (state) => state.selectedGroupAccounts, + ), + }), +); + +jest.mock('../../../selectors/networkController', () => ({ + selectEvmNetworkConfigurationsByChainId: jest.fn((state) => state.evmConfigs), + selectProviderType: jest.fn((state) => state.providerType), +})); + +jest.mock('../../../selectors/networkEnablementController', () => ({ + selectEVMEnabledNetworks: jest.fn((state) => state.enabledEvm), + selectNonEVMEnabledNetworks: jest.fn((state) => state.enabledNonEvm), +})); + +jest.mock('../../../selectors/transactionController', () => ({ + selectRelatedChainIdsByTransactionId: jest.fn((state) => state.related), +})); + +jest.mock('../../../selectors/bridgeStatusController', () => ({ + selectBridgeHistoryForAccount: jest.fn((state) => state.bridgeHistory), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: () => ({}) }), +})); + +jest.mock('@shopify/flash-list', () => { + const ReactActual = jest.requireActual('react'); + const { Text, TouchableOpacity, View } = jest.requireActual('react-native'); + return { + FlashList: ReactActual.forwardRef( + ( + { + data, + keyExtractor, + ListEmptyComponent, + ListFooterComponent, + ListHeaderComponent, + onScroll, + onViewableItemsChanged, + refreshControl, + renderItem, + testID, + }: { + data: unknown[]; + keyExtractor: (item: unknown) => string; + ListEmptyComponent?: React.ReactElement | (() => React.ReactElement); + ListFooterComponent?: React.ReactElement | null; + ListHeaderComponent?: React.ReactElement; + onScroll?: (event: object) => void; + onViewableItemsChanged?: (args: object) => void; + refreshControl?: React.ReactElement; + renderItem: (args: { + item: unknown; + index: number; + }) => React.ReactNode; + testID?: string; + }, + ref: React.Ref<{ scrollToOffset: jest.Mock }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + scrollToOffset: jest.fn(), + })); + const empty = + typeof ListEmptyComponent === 'function' ? ( + + ) : ( + ListEmptyComponent + ); + return ( + + {ListHeaderComponent} + {data.length + ? data.map((item, index) => ( + + {renderItem({ item, index })} + + )) + : empty} + {ListFooterComponent} + + ( + refreshControl as + | React.ReactElement<{ onRefresh: () => void }> + | undefined + )?.props.onRefresh() + } + /> + + onScroll?.({ nativeEvent: { contentOffset: { y: 12 } } }) + } + /> + + onViewableItemsChanged?.({ viewableItems: [{ index: 0 }] }) + } + > + viewable + + + ); + }, + ), + }; +}); + +jest.mock('../../../util/theme', () => ({ + useTheme: () => ({ + colors: { + background: { default: 'white' }, + icon: { default: 'black' }, + primary: { default: 'blue' }, + }, + }), +})); + +jest.mock('../../hooks/useStyles', () => ({ + useStyles: () => ({ + styles: { + container: {}, + emptyList: {}, + }, + }), +})); + +jest.mock('../../UI/AssetOverview/PriceChart/PriceChart.context', () => { + const ReactActual = jest.requireActual('react'); + const PriceChartContext = { + Consumer: ({ + children, + }: { + children: (value: object) => React.ReactNode; + }) => children({ isChartBeingTouched: false }), + }; + return { + __esModule: true, + default: PriceChartContext, + PriceChartProvider: ({ children }: { children?: React.ReactNode }) => ( + <>{children} + ), + }; +}); + +jest.mock('./useTransactionsQuery', () => ({ + useTransactionsQuery: jest.fn(), +})); + +jest.mock('./hooks/useLocalActivityItems', () => ({ + useLocalActivityItems: jest.fn(), +})); + +jest.mock('./useUnifiedTxActions', () => ({ + useUnifiedTxActions: jest.fn(), +})); + +jest.mock('../../../core/Engine', () => ({ + context: { + TransactionController: { + updateIncomingTransactions: jest.fn(() => Promise.resolve()), + }, + }, +})); + +const updateIncomingTransactions = ( + Engine.context.TransactionController as unknown as { + updateIncomingTransactions: jest.Mock; + } +).updateIncomingTransactions; + +jest.mock('../../UI/ActivityListItemRow/ActivityListItemRow', () => ({ + ActivityListItemRow: ({ + item, + onPress, + title, + }: { + item: { data: { hash?: string } }; + onPress: (item: unknown) => void; + title?: string; + }) => { + const { Text, TouchableOpacity } = jest.requireActual('react-native'); + return ( + onPress(item)} + > + {title ?? item.data.hash} + + ); + }, + resolveActivityListItemTitle: jest.fn(() => 'Activity title'), +})); + +jest.mock('../../UI/TransactionElement', () => { + const { Text, View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ tx }: { tx: { id: string } }) => ( + + Pending tx + + ), + }; +}); + +jest.mock('../../UI/MultichainBridgeTransactionListItem', () => { + const { Text, View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ transaction }: { transaction: { id: string } }) => ( + + Bridge tx + + ), + }; +}); + +jest.mock('../../UI/Transactions/TransactionsFooter', () => { + const { Text, TouchableOpacity } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ onViewBlockExplorer }: { onViewBlockExplorer: () => void }) => ( + + EVM footer + + ), + }; +}); + +jest.mock('../MultichainTransactionsView/MultichainTransactionsFooter', () => { + const { Text, TouchableOpacity } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ onViewMore }: { onViewMore: () => void }) => ( + + Non-EVM footer + + ), + }; +}); + +jest.mock('../confirmations/components/modals/cancel-speedup-modal', () => { + const { View } = jest.requireActual('react-native'); + return { + CancelSpeedupModal: ({ isVisible }: { isVisible: boolean }) => + isVisible ? : null, + }; +}); + +jest.mock('../../hooks/useBlockExplorer', () => ({ + __esModule: true, + default: () => ({ + getBlockExplorerName: jest.fn(() => 'Explorer'), + getBlockExplorerUrl: jest.fn(() => 'https://explorer.test/address/0xevm'), + }), +})); + +jest.mock('../../UI/Bridge/hooks/useBridgeHistoryItemBySrcTxHash', () => ({ + useBridgeHistoryItemBySrcTxHash: jest.fn(() => ({ + bridgeHistoryItemsBySrcTxHash: { + '0xconfirmed': { title: 'bridge-history' }, + solanaBridge: { title: 'solana-bridge' }, + }, + })), +})); + +jest.mock('../../UI/Bridge/utils/transaction-history', () => ({ + getSwapBridgeTxActivityTitle: jest.fn(() => 'Bridge title'), + handleUnifiedSwapsTxHistoryItemClick: jest.fn(), +})); + +jest.mock('../../../util/multichain/multichainTransactionTokenScan', () => ({ + filterMultichainTransactionsExcludingMaliciousTokenActivity: jest.fn( + (txs) => txs, + ), +})); + +jest.mock( + '../../hooks/useMultichainActivityMaliciousTokenKeys/useMultichainActivityMaliciousTokenKeys', + () => ({ + useMultichainActivityMaliciousTokenKeys: jest.fn(() => ({ + maliciousTokenKeys: new Set(), + })), + }), +); + +jest.mock('../../../core/Multichain/utils', () => ({ + getAddressUrl: jest.fn(() => 'https://solana.explorer/address/sol'), +})); + +jest.mock('../../../util/networks', () => ({ + getBlockExplorerAddressUrl: jest.fn(() => ({ + title: 'Configured Explorer', + url: 'https://configured.explorer/address/0xevm', + })), +})); + +jest.mock('./helpers/adapters', () => ({ + normalizeTransaction: jest.fn(() => ({ + chainId: '0x1', + id: 'normalized', + txParams: { from: '0xevm', to: '0xto' }, + })), +})); + +jest.mock('./helpers/transformations', () => { + const actual = jest.requireActual('./helpers/transformations'); + return { + ...actual, + mapNonEvmTransactions: jest.fn((txs) => + txs.map((tx: { id: string; chain: string }) => ({ + type: 'send', + chainId: tx.chain, + status: 'success', + timestamp: 2, + data: { hash: tx.id }, + raw: { type: 'keyringTransaction', data: tx }, + })), + ), + mergeTransactionsByTime: jest.fn((local, confirmed, nonEvm) => [ + ...local, + ...confirmed, + ...nonEvm, + ]), + }; +}); + +const mockNavigate = jest.fn(); +const mockFetchNextPage = jest.fn(); +const mockRefetch = jest.fn(() => Promise.resolve()); +const mockOnScroll = jest.fn(); + +const selectorValues = { + bridgeHistory: { + solanaBridge: { status: { srcChain: { txHash: 'solanaBridge' } } }, + }, + currentCurrency: 'usd', + enabledEvm: ['0x1'], + enabledNonEvm: [] as string[], + evmConfigs: { + '0x1': { + blockExplorerUrls: ['https://configured.explorer'], + defaultBlockExplorerUrlIndex: 0, + }, + }, + nonEvmState: { transactions: [] as unknown[] }, + providerType: 'mainnet', + related: new Map(), + selectedAccount: { address: '0xselected' }, + selectedGroupAccounts: [{ address: '0xevm', type: 'eip155:eoa' }], +}; + +const confirmedItem = { + type: 'send', + chainId: 'eip155:1', + status: 'success', + timestamp: 3, + data: { + hash: '0xconfirmed', + from: '0xevm', + to: '0xto', + token: { symbol: 'ETH' }, + }, + raw: { + type: 'apiEvmTransaction', + data: { chainId: 1, from: '0xevm', hash: '0xconfirmed', nonce: 7 }, + }, +}; + +const localPendingItem = { + type: 'send', + chainId: 'eip155:1', + status: 'pending', + timestamp: 4, + data: { hash: '0xlocal' }, + raw: { + type: 'localTransaction', + data: { + primaryTransaction: { + chainId: '0x1', + hash: '0xlocal', + id: 'local-id', + txParams: { from: '0xevm', nonce: '0x8' }, + }, + }, + }, +}; + +describe('ActivityList', () => { + beforeEach(() => { + jest.clearAllMocks(); + selectorValues.enabledEvm = ['0x1']; + selectorValues.enabledNonEvm = []; + selectorValues.nonEvmState = { transactions: [] }; + selectorValues.selectedGroupAccounts = [ + { address: '0xevm', type: 'eip155:eoa' }, + ]; + (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate }); + (useTransactionsQuery as jest.Mock).mockReturnValue({ + data: { pages: [{ data: [confirmedItem] }] }, + fetchNextPage: mockFetchNextPage, + hasNextPage: true, + isFetchingNextPage: false, + isInitialLoading: false, + refetch: mockRefetch, + }); + (useLocalActivityItems as jest.Mock).mockReturnValue([localPendingItem]); + (useUnifiedTxActions as jest.Mock).mockReturnValue({ + cancelIsOpen: false, + cancelTransaction: jest.fn(), + cancelUnsignedQRTransaction: jest.fn(), + confirmDisabled: false, + existingTx: null, + onCancelAction: jest.fn(), + onSpeedUpAction: jest.fn(), + onSpeedUpCancelCompleted: jest.fn(), + signLedgerTransaction: jest.fn(), + signQRTransaction: jest.fn(), + speedUpIsOpen: false, + speedUpTransaction: jest.fn(), + }); + (useSelector as unknown as jest.Mock).mockImplementation((selector) => + selector(selectorValues), + ); + }); + + it('renders local pending and confirmed transaction rows', () => { + render(} onScroll={mockOnScroll} />); + + expect( + screen.getByTestId(ActivityListSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect(screen.getByTestId('pending-local-id')).toBeOnTheScreen(); + expect(screen.getByTestId('row-0xconfirmed')).toBeOnTheScreen(); + }); + + it('refreshes incoming transactions and refetches the list on pull-to-refresh', async () => { + render(} onScroll={mockOnScroll} />); + + fireEvent.press(screen.getByTestId('mock-refresh')); + + await waitFor(() => expect(updateIncomingTransactions).toHaveBeenCalled()); + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + + it('forwards scroll events to onScroll', () => { + render(} onScroll={mockOnScroll} />); + + fireEvent.press(screen.getByTestId('mock-scroll')); + + expect(mockOnScroll).toHaveBeenCalledTimes(1); + }); + + it('fetches the next page when viewable items change', () => { + render(} onScroll={mockOnScroll} />); + + fireEvent.press(screen.getByTestId('mock-viewable')); + + expect(mockFetchNextPage).toHaveBeenCalledTimes(1); + }); + + it('opens the EVM block explorer from the footer', () => { + render(} onScroll={mockOnScroll} />); + + fireEvent.press(screen.getByTestId('evm-footer')); + + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + params: { + title: 'Configured Explorer', + url: 'https://configured.explorer/address/0xevm', + }, + screen: 'SimpleWebview', + }); + }); + + it('navigates to transaction details when a confirmed row is pressed', () => { + render(} onScroll={mockOnScroll} />); + + fireEvent.press(screen.getByTestId('row-0xconfirmed')); + + expect(mockNavigate).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + screen: expect.any(String), + }), + ); + }); + + it('renders non-EVM bridge rows and footer when only non-EVM chains are enabled', () => { + selectorValues.enabledEvm = []; + selectorValues.enabledNonEvm = ['solana:mainnet']; + selectorValues.nonEvmState = { + transactions: [{ chain: 'solana:mainnet', id: 'solanaBridge' }], + }; + selectorValues.selectedGroupAccounts = [ + { address: 'solana-address', type: 'solana:data-account' }, + ]; + (useTransactionsQuery as jest.Mock).mockReturnValue({ + data: { pages: [{ data: [] }] }, + fetchNextPage: mockFetchNextPage, + hasNextPage: false, + isFetchingNextPage: false, + isInitialLoading: false, + refetch: mockRefetch, + }); + (useLocalActivityItems as jest.Mock).mockReturnValue([]); + + render(); + + expect(screen.getByTestId('bridge-solanaBridge')).toBeOnTheScreen(); + fireEvent.press(screen.getByTestId('non-evm-footer')); + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + params: { url: 'https://solana.explorer/address/sol' }, + screen: 'SimpleWebview', + }); + }); +}); diff --git a/app/components/Views/ActivityList/ActivityList.testIds.ts b/app/components/Views/ActivityList/ActivityList.testIds.ts new file mode 100644 index 000000000000..aa281b4b8fb3 --- /dev/null +++ b/app/components/Views/ActivityList/ActivityList.testIds.ts @@ -0,0 +1,9 @@ +export const ActivityListSelectorsIDs = { + CONTAINER: 'activity-list', +} as const; + +export const activityListRowItemTestId = (index: number): string => + `transaction-item-${index}`; + +export const activityListRowStatusTestId = (index: number): string => + `transaction-status-${index}`; diff --git a/app/components/Views/ActivityList/ActivityList.tsx b/app/components/Views/ActivityList/ActivityList.tsx new file mode 100644 index 000000000000..8e3ad8ed8138 --- /dev/null +++ b/app/components/Views/ActivityList/ActivityList.tsx @@ -0,0 +1,862 @@ +import { SupportedCaipChainId } from '@metamask/multichain-network-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import { numberToHex } from '@metamask/utils'; +import { useNavigation } from '@react-navigation/native'; +import { + FlashList, + type FlashListRef, + type ViewToken, +} from '@shopify/flash-list'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + type NativeScrollEvent, + type NativeSyntheticEvent, + RefreshControl, + View, +} from 'react-native'; +import { useSelector } from 'react-redux'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { strings } from '../../../../locales/i18n'; +import ExtendedKeyringTypes from '../../../constants/keyringTypes'; +import Routes from '../../../constants/navigation/Routes'; +import { RPC } from '../../../constants/network'; +import { selectSelectedInternalAccount } from '../../../selectors/accountsController'; +import { selectCurrentCurrency } from '../../../selectors/currencyRateController'; +import { selectNonEvmTransactionsForSelectedAccountGroup } from '../../../selectors/multichain/multichain'; +import { selectSelectedAccountGroupInternalAccounts } from '../../../selectors/multichainAccounts/accountTreeController'; +import { + selectEvmNetworkConfigurationsByChainId, + selectProviderType, +} from '../../../selectors/networkController'; +import { + selectEVMEnabledNetworks, + selectNonEVMEnabledNetworks, +} from '../../../selectors/networkEnablementController'; +import { selectRelatedChainIdsByTransactionId } from '../../../selectors/transactionController'; +import { baseStyles } from '../../../styles/common'; +import { isHardwareAccount } from '../../../util/address'; +import { getBlockExplorerAddressUrl } from '../../../util/networks'; +import { useTheme } from '../../../util/theme'; +import Engine from '../../../core/Engine'; +import { useStyles } from '../../hooks/useStyles'; +import PriceChartContext, { + PriceChartProvider, +} from '../../UI/AssetOverview/PriceChart/PriceChart.context'; +import { useBridgeHistoryItemBySrcTxHash } from '../../UI/Bridge/hooks/useBridgeHistoryItemBySrcTxHash'; +import { + getSwapBridgeTxActivityTitle, + handleUnifiedSwapsTxHistoryItemClick, +} from '../../UI/Bridge/utils/transaction-history'; +import MultichainBridgeTransactionListItem from '../../UI/MultichainBridgeTransactionListItem'; +import TransactionElement from '../../UI/TransactionElement'; +import TransactionsFooter from '../../UI/Transactions/TransactionsFooter'; +// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog +import MultichainTransactionsFooter from '../MultichainTransactionsView/MultichainTransactionsFooter'; +import { getAddressUrl } from '../../../core/Multichain/utils'; +// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog +import { CancelSpeedupModal } from '../confirmations/components/modals/cancel-speedup-modal'; +import styleSheet from './ActivityList.styles'; +import { useUnifiedTxActions } from './useUnifiedTxActions'; +import { TransactionDetailLocation } from '../../../core/Analytics/events/transactions'; +import { useTransactionAutoScroll } from './useTransactionAutoScroll'; +import useBlockExplorer from '../../hooks/useBlockExplorer'; +import { selectBridgeHistoryForAccount } from '../../../selectors/bridgeStatusController'; +import { TabEmptyState } from '../../../component-library/components-temp/TabEmptyState'; +import { ActivityListSelectorsIDs } from './ActivityList.testIds'; +import { useMultichainActivityMaliciousTokenKeys } from '../../hooks/useMultichainActivityMaliciousTokenKeys/useMultichainActivityMaliciousTokenKeys'; +import { filterMultichainTransactionsExcludingMaliciousTokenActivity } from '../../../util/multichain/multichainTransactionTokenScan'; +import { useTransactionsQuery } from './useTransactionsQuery'; +import { type ActivityListItem } from './types'; +import { + isBridgeHistoryForEvmTransaction, + mergeTransactionsByTime, + mapNonEvmTransactions, +} from './helpers/transformations'; +import { normalizeTransaction } from './helpers/adapters'; +import { useLocalActivityItems } from './hooks/useLocalActivityItems'; +import { + ActivityListItemRow, + resolveActivityListItemTitle, +} from '../../UI/ActivityListItemRow/ActivityListItemRow'; + +const confirmedEvmOverscan = 5; +const visibilityConfig = { itemVisiblePercentThreshold: 1 }; + +const updateIncomingTransactions = () => + ( + Engine.context.TransactionController as unknown as { + updateIncomingTransactions: () => Promise; + } + ).updateIncomingTransactions(); + +const generateKey = (item: ActivityListItem): string => { + const hash = item.data.hash; + if (hash) { + return `${item.chainId}:${hash}`; + } + return `${item.chainId}:${item.timestamp}:${item.type}`; +}; + +const noop = () => undefined; + +const getActivityValue = (item: ActivityListItem) => { + const { data } = item; + + if ('token' in data && data.token?.symbol) { + return `${data.token.amount ?? ''} ${data.token.symbol}`.trim(); + } + + if ('destinationToken' in data && data.destinationToken?.symbol) { + return `${data.destinationToken.amount ?? ''} ${ + data.destinationToken.symbol + }`.trim(); + } + + if ('sourceToken' in data && data.sourceToken?.symbol) { + return `${data.sourceToken.amount ?? ''} ${data.sourceToken.symbol}`.trim(); + } + + return undefined; +}; + +const getActivityFromTo = (item: ActivityListItem) => { + const { data } = item; + return { + from: 'from' in data && typeof data.from === 'string' ? data.from : '', + to: 'to' in data && typeof data.to === 'string' ? data.to : '', + }; +}; + +interface ActivityListProps { + header?: React.ReactElement; + tabLabel?: string; + chainId?: string; // used by non-EVM list items for explorer links + location?: TransactionDetailLocation; + onScroll?: (event: NativeSyntheticEvent) => void; +} + +const ActivityList = ({ + header, + chainId, + location, + onScroll, +}: ActivityListProps) => { + const navigation = useNavigation(); + const { colors } = useTheme(); + const tw = useTailwind(); + const { styles } = useStyles(styleSheet, {}); + const { bridgeHistoryItemsBySrcTxHash } = useBridgeHistoryItemBySrcTxHash(); + + const getBridgeHistoryItemByHash = useCallback( + (hash?: string) => { + if (!hash) { + return undefined; + } + + const normalizedHash = hash.toLowerCase(); + return ( + bridgeHistoryItemsBySrcTxHash[hash] ?? + Object.entries(bridgeHistoryItemsBySrcTxHash).find( + ([key]) => key.toLowerCase() === normalizedHash, + )?.[1] + ); + }, + [bridgeHistoryItemsBySrcTxHash], + ); + + const { + data: evmTransactions, + fetchNextPage, + hasNextPage, + isInitialLoading, + isFetchingNextPage, + refetch, + } = useTransactionsQuery(); + + const allConfirmedFiltered = useMemo( + () => evmTransactions?.pages.flatMap((page) => page.data) ?? [], + [evmTransactions], + ); + + // Local EVM transactions mapped through the shared adapter + const localActivityItems = useLocalActivityItems(); + + const nonEvmState = useSelector( + selectNonEvmTransactionsForSelectedAccountGroup, + ); + const nonEvmTransactions = useMemo( + () => nonEvmState?.transactions ?? [], + [nonEvmState?.transactions], + ); + + const currentCurrency = useSelector(selectCurrentCurrency); + + const selectedInternalAccount = useSelector(selectSelectedInternalAccount); + const selectedAccountGroupInternalAccounts = useSelector( + selectSelectedAccountGroupInternalAccounts, + ); + const selectedAccountGroupEvmAddress = useMemo(() => { + const evmAccount = selectedAccountGroupInternalAccounts.find( + (account) => + account.type === 'eip155:eoa' || account.type === 'eip155:erc4337', + ); + return evmAccount?.address ?? ''; + }, [selectedAccountGroupInternalAccounts]); + + const selectedAccountGroupSolanaAddress = useMemo(() => { + const solanaAccount = selectedAccountGroupInternalAccounts.find( + (account) => account.type === 'solana:data-account', + ); + return solanaAccount?.address ?? ''; + }, [selectedAccountGroupInternalAccounts]); + + const enabledEVMNetworks = useSelector(selectEVMEnabledNetworks); + const enabledEVMChainIds = useMemo( + () => enabledEVMNetworks ?? [], + [enabledEVMNetworks], + ); + const enabledNonEVMNetworks = useSelector(selectNonEVMEnabledNetworks); + const enabledNonEVMChainIds = useMemo( + () => enabledNonEVMNetworks ?? [], + [enabledNonEVMNetworks], + ); + + const relatedChainIdsByTransactionId = useSelector( + selectRelatedChainIdsByTransactionId, + ); + + const bridgeHistory = useSelector(selectBridgeHistoryForAccount); + + /** Drop confirmed EVM rows not on currently enabled chains (guards stale query pages). */ + const allConfirmedForEnabledChains = useMemo(() => { + const chains = enabledEVMChainIds ?? []; + if (chains.length === 0) return []; + const allowed = new Set(chains.map((c) => c.toLowerCase())); + return allConfirmedFiltered.filter((item) => { + // chainId is a CaipChainId like eip155:1 — extract hex part + const hexChainId = item.chainId.split(':')[1]; + if (!hexChainId) return false; + const hexFormatted = `0x${parseInt(hexChainId, 10).toString(16)}`; + return allowed.has(hexFormatted.toLowerCase()); + }); + }, [allConfirmedFiltered, enabledEVMChainIds]); + + const { maliciousTokenKeys } = + useMultichainActivityMaliciousTokenKeys(nonEvmTransactions); + + const providerType = useSelector(selectProviderType); + const evmNetworkConfigurationsByChainId = useSelector( + selectEvmNetworkConfigurationsByChainId, + ); + + const unifiedTransactionSource = useMemo<{ + localItems: ActivityListItem[]; + confirmedEvmItems: ActivityListItem[]; + nonEvmItems: ActivityListItem[]; + }>(() => { + const bridgeHistoryValues = Object.values(bridgeHistory ?? {}); + const enabledEvmSet = new Set( + (enabledEVMChainIds ?? []).map((id) => id.toLowerCase()), + ); + + // Filter local items to enabled EVM chains only, also deduplicate against confirmed + const confirmedHashes = new Set( + allConfirmedForEnabledChains + .map((item) => item.data.hash?.toLowerCase()) + .filter(Boolean) as string[], + ); + + // localActivityItems are already mapped from TransactionMeta via the adapter; + // here we apply the same chain-filter and EVM-confirmed dedup that existed before. + const filteredLocalItems = localActivityItems.filter((item) => { + const raw = item.raw; + if (raw?.type !== 'localTransaction') return true; + const tx = raw.data.primaryTransaction; + + const txChainId = tx.chainId?.toLowerCase() ?? ''; + const relatedChainIds = relatedChainIdsByTransactionId.get(tx.id) ?? [ + txChainId, + ]; + + if (!relatedChainIds.some((id) => enabledEvmSet.has(id))) { + return false; + } + + // Dedup against confirmed by hash — bridge txns are exempt from nonce dedup + const hash = tx.hash?.toLowerCase(); + if (hash && confirmedHashes.has(hash)) return false; + + // Nonce dedup: skip local if a confirmed tx has the same nonce+from+chain + // (bridge txns exempt, as they may have same nonce as their approval) + const isBridgeTx = isBridgeHistoryForEvmTransaction( + tx, + bridgeHistoryValues, + ); + if (!isBridgeTx) { + const nonce = tx.txParams?.nonce; + const from = tx.txParams?.from?.toLowerCase(); + if (nonce !== undefined && nonce !== null && from) { + const matchedByNonce = allConfirmedForEnabledChains.some( + (confirmed) => { + // parse nonce from confirmed item if available + const confirmedRaw = confirmed.raw; + if (confirmedRaw?.type !== 'apiEvmTransaction') return false; + const confirmedApiTx = confirmedRaw.data; + // hexChainId from caip: eip155:1 → 0x1 + const hexChainId = confirmed.chainId.split(':')[1] + ? `0x${parseInt(confirmed.chainId.split(':')[1], 10).toString(16)}` + : ''; + return ( + confirmedApiTx.nonce === parseInt(String(nonce), 16) && + hexChainId.toLowerCase() === txChainId && + confirmedApiTx.from?.toLowerCase() === from + ); + }, + ); + if (matchedByNonce) return false; + } + } + + return true; + }); + + // Non-EVM: filter by enabled chains, include bridge txns whose dest chain is enabled + const filteredNonEvmTransactions = nonEvmTransactions + .filter((tx) => { + if (enabledNonEVMChainIds.includes(tx.chain)) return true; + const bridge = Object.values(bridgeHistory ?? {}).find( + (item) => item.status?.srcChain?.txHash === tx.id, + ); + return ( + bridge?.quote?.destChainId !== undefined && + enabledEVMChainIds.includes(numberToHex(bridge.quote.destChainId)) + ); + }) + .filter( + (tx, index, self) => index === self.findIndex((t) => t.id === tx.id), + ); + + const filteredNonEvmForMalicious = + filterMultichainTransactionsExcludingMaliciousTokenActivity( + filteredNonEvmTransactions, + maliciousTokenKeys, + ); + + const nonEvmItems = mapNonEvmTransactions(filteredNonEvmForMalicious); + + return { + localItems: filteredLocalItems, + confirmedEvmItems: allConfirmedForEnabledChains, + nonEvmItems, + }; + }, [ + allConfirmedForEnabledChains, + localActivityItems, + nonEvmTransactions, + enabledEVMChainIds, + enabledNonEVMChainIds, + bridgeHistory, + relatedChainIdsByTransactionId, + maliciousTokenKeys, + ]); + + const data = useMemo(() => { + const { localItems, confirmedEvmItems, nonEvmItems } = + unifiedTransactionSource; + return mergeTransactionsByTime(localItems, confirmedEvmItems, nonEvmItems); + }, [unifiedTransactionSource]); + + const hasEvmChainsEnabled = enabledEVMChainIds.length > 0; + const popularListBlockExplorer = useBlockExplorer( + hasEvmChainsEnabled ? enabledEVMChainIds[0] : undefined, + ); + + const configBlockExplorerUrl = useMemo(() => { + if (!enabledEVMChainIds?.length || enabledEVMChainIds.length !== 1) { + return undefined; + } + const selectedChainId = enabledEVMChainIds[0]; + const config = evmNetworkConfigurationsByChainId?.[selectedChainId]; + if (!config) return undefined; + const index = config.defaultBlockExplorerUrlIndex ?? 0; + return config.blockExplorerUrls?.[index]; + }, [enabledEVMChainIds, evmNetworkConfigurationsByChainId]); + + const blockExplorerUrl = useMemo(() => { + if (configBlockExplorerUrl) { + return configBlockExplorerUrl; + } + return hasEvmChainsEnabled + ? popularListBlockExplorer.getBlockExplorerUrl( + selectedAccountGroupEvmAddress, + ) || undefined + : undefined; + }, [ + configBlockExplorerUrl, + popularListBlockExplorer, + selectedAccountGroupEvmAddress, + hasEvmChainsEnabled, + ]); + + const hasNonEvmChainsEnabled = enabledNonEVMChainIds.length > 0; + + const showEvmFooter = hasEvmChainsEnabled && !hasNonEvmChainsEnabled; + const showNonEvmFooter = hasNonEvmChainsEnabled && !hasEvmChainsEnabled; + + const onViewBlockExplorer = useCallback(() => { + if (!selectedAccountGroupEvmAddress) { + return; + } + + let url; + let title; + if (configBlockExplorerUrl) { + const result = getBlockExplorerAddressUrl( + RPC, + selectedAccountGroupEvmAddress, + blockExplorerUrl, + ); + url = result.url; + title = result.title; + if (!url) return; + } else { + url = blockExplorerUrl; + title = hasEvmChainsEnabled + ? popularListBlockExplorer.getBlockExplorerName(enabledEVMChainIds[0]) + : undefined; + } + + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { url, title }, + }); + }, [ + navigation, + blockExplorerUrl, + selectedAccountGroupEvmAddress, + popularListBlockExplorer, + enabledEVMChainIds, + configBlockExplorerUrl, + hasEvmChainsEnabled, + ]); + + const allNonEvmChainsAreSolana = useMemo( + () => + enabledNonEVMChainIds.every((chain) => + chain.toLowerCase().startsWith('solana:'), + ), + [enabledNonEVMChainIds], + ); + + const nonEvmExplorerChainId = useMemo(() => { + if (enabledNonEVMChainIds.length) return enabledNonEVMChainIds[0]; + if (chainId?.includes(':')) return chainId; + return undefined; + }, [enabledNonEVMChainIds, chainId]); + + const nonEvmExplorerUrl = useMemo(() => { + if (!selectedAccountGroupSolanaAddress || !nonEvmExplorerChainId) return ''; + return getAddressUrl( + selectedAccountGroupSolanaAddress, + nonEvmExplorerChainId as SupportedCaipChainId, + ); + }, [nonEvmExplorerChainId, selectedAccountGroupSolanaAddress]); + + const showNonEvmExplorerLink = + showNonEvmFooter && allNonEvmChainsAreSolana && Boolean(nonEvmExplorerUrl); + + const onViewNonEvmExplorer = useCallback(() => { + if (!nonEvmExplorerUrl) return; + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { url: nonEvmExplorerUrl }, + }); + }, [navigation, nonEvmExplorerUrl]); + + const footerComponent = useMemo(() => { + if (isFetchingNextPage) { + return ( + + + + ); + } + + if (showEvmFooter) { + return ( + + ); + } + + if (showNonEvmFooter) { + return ( + 0 + } + showDisclaimer + showExplorerLink={showNonEvmExplorerLink} + onViewMore={onViewNonEvmExplorer} + /> + ); + } + + return null; + }, [ + enabledEVMChainIds, + unifiedTransactionSource.nonEvmItems?.length, + onViewBlockExplorer, + onViewNonEvmExplorer, + providerType, + blockExplorerUrl, + nonEvmExplorerUrl, + showEvmFooter, + isFetchingNextPage, + showNonEvmExplorerLink, + showNonEvmFooter, + configBlockExplorerUrl, + tw, + ]); + + const [refreshing, setRefreshing] = useState(false); + const { + speedUpIsOpen, + cancelIsOpen, + confirmDisabled, + existingTx, + onSpeedUpAction, + onCancelAction, + onSpeedUpCancelCompleted, + speedUpTransaction, + cancelTransaction, + signQRTransaction, + signLedgerTransaction, + cancelUnsignedQRTransaction, + } = useUnifiedTxActions(); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + try { + await Promise.all([updateIncomingTransactions(), refetch()]); + } finally { + setRefreshing(false); + } + }, [refetch]); + + const handleActivityItemPress = useCallback( + (item: ActivityListItem) => { + const { raw } = item; + if (!raw) return; + + const itemBridgeHistoryItem = getBridgeHistoryItemByHash(item.data.hash); + const actionKey = resolveActivityListItemTitle( + item, + itemBridgeHistoryItem + ? getSwapBridgeTxActivityTitle(itemBridgeHistoryItem) + : undefined, + ); + + const selectedEvmAddress = + selectedAccountGroupEvmAddress || + selectedInternalAccount?.address || + ''; + + if (raw.type === 'keyringTransaction') { + const { from, to } = getActivityFromTo(item); + const value = getActivityValue(item); + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.MULTICHAIN_TRANSACTION_DETAILS, + params: { + transaction: raw.data, + displayData: { + title: actionKey, + from: from + ? { address: from, amount: value ?? '', unit: '' } + : undefined, + to: to + ? { address: to, amount: value ?? '', unit: '' } + : undefined, + isRedeposit: false, + }, + }, + }); + return; + } + + const tx = + raw.type === 'apiEvmTransaction' + ? selectedEvmAddress + ? normalizeTransaction(selectedEvmAddress, raw.data) + : undefined + : raw.data.primaryTransaction; + + if (!tx) return; + + if ( + raw.type === 'localTransaction' && + tx.type === TransactionType.bridge + ) { + const bridgeTxHistoryItem = + bridgeHistory[tx.id] ?? + (tx.actionId ? bridgeHistory[tx.actionId] : undefined) ?? + Object.values(bridgeHistory).find( + (itemValue) => + (itemValue as unknown as { originalTransactionId?: string }) + .originalTransactionId === tx.id, + ); + + handleUnifiedSwapsTxHistoryItemClick({ + navigation, + evmTxMeta: tx, + bridgeTxHistoryItem, + }); + return; + } + + const { from, to } = getActivityFromTo(item); + const value = getActivityValue(item); + + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.TRANSACTION_DETAILS, + params: { + tx, + transactionElement: { + actionKey, + value, + }, + transactionDetails: { + hash: item.data.hash, + renderFrom: from, + renderTo: to, + renderValue: value, + transactionType: item.type, + txChainId: tx.chainId, + }, + showSpeedUpModal: noop, + showCancelModal: noop, + }, + }); + }, + [ + bridgeHistory, + getBridgeHistoryItemByHash, + navigation, + selectedAccountGroupEvmAddress, + selectedInternalAccount?.address, + ], + ); + + // Index of the last API-confirmed EVM item — used to trigger pagination. + const lastConfirmedEvmIndex = useMemo(() => { + for (let index = data.length - 1; index >= 0; index -= 1) { + const item = data[index]; + if (item.raw?.type === 'apiEvmTransaction') { + return index; + } + } + return -1; + }, [data]); + + const lastConfirmedEvmKey = + lastConfirmedEvmIndex >= 0 + ? generateKey(data[lastConfirmedEvmIndex]) + : undefined; + + const onViewableItemsChanged = useCallback( + ({ viewableItems }: { viewableItems: ViewToken[] }) => { + if ( + !hasNextPage || + isFetchingNextPage || + !lastConfirmedEvmKey || + lastConfirmedEvmIndex < 0 + ) { + return; + } + + const prefetchIndex = Math.max( + lastConfirmedEvmIndex - confirmedEvmOverscan, + 0, + ); + const isNearPrefetchThreshold = viewableItems.some( + ({ index }) => typeof index === 'number' && index >= prefetchIndex, + ); + + if (!isNearPrefetchThreshold) return; + fetchNextPage(); + }, + [ + fetchNextPage, + hasNextPage, + isFetchingNextPage, + lastConfirmedEvmIndex, + lastConfirmedEvmKey, + ], + ); + + const listRef = useRef>(null); + + const { handleScroll } = useTransactionAutoScroll(data, listRef, { + keyExtractor: (item: ActivityListItem) => generateKey(item), + }); + + const handleListScroll = useCallback( + (event: NativeSyntheticEvent) => { + handleScroll(); + onScroll?.(event); + }, + [handleScroll, onScroll], + ); + + const renderEmptyList = () => ( + + + + ); + + const renderInitialLoading = () => ( + + + + ); + + const shouldShowTransactionList = !isInitialLoading && data.length > 0; + const items = shouldShowTransactionList ? data : []; + + const renderItem = ({ + item, + index, + }: { + item: ActivityListItem; + index: number; + }) => { + const raw = item.raw; + + // Pending local EVM transactions: route to TransactionElement for speed-up/cancel UI. + if (raw?.type === 'localTransaction' && item.status === 'pending') { + const tx = raw.data.primaryTransaction; + return ( + + ); + } + + // Non-EVM bridge transactions: route to MultichainBridgeTransactionListItem. + if (raw?.type === 'keyringTransaction') { + const srcTxHash = raw.data.id; + const bridgeHistoryItem = getBridgeHistoryItemByHash(srcTxHash); + if (bridgeHistoryItem) { + return ( + + ); + } + } + + // All other items (API EVM confirmed, completed local EVM, non-EVM non-bridge): + // render from the shared ActivityListItem shape. + // + // Preserve the legacy Activity title for swap/bridge rows (e.g. + // "Swap ETH to USDC", "Bridge to Optimism") by deriving it from bridge + // history, mirroring TransactionElement. Falls back to the kind-based title. + const bridgeHistoryItem = getBridgeHistoryItemByHash(item.data.hash); + const title = bridgeHistoryItem + ? getSwapBridgeTxActivityTitle(bridgeHistoryItem) + : undefined; + + return ( + + ); + }; + + return ( + + + + {({ isChartBeingTouched }) => ( + + } + onScroll={handleListScroll} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={visibilityConfig} + scrollEventThrottle={16} + scrollEnabled={!isChartBeingTouched} + /> + )} + + {/* Speed up / Cancel modals */} + + + + ); +}; + +export default ActivityList; diff --git a/app/components/Views/ActivityList/ActivityList.view.test.tsx b/app/components/Views/ActivityList/ActivityList.view.test.tsx new file mode 100644 index 000000000000..db9cbacdb690 --- /dev/null +++ b/app/components/Views/ActivityList/ActivityList.view.test.tsx @@ -0,0 +1,84 @@ +import '../../../../tests/component-view/mocks'; +import { fireEvent, waitFor, within } from '@testing-library/react-native'; +import { RefreshControl } from 'react-native'; +import Engine from '../../../core/Engine'; +import Routes from '../../../constants/navigation/Routes'; +import { strings } from '../../../../locales/i18n'; +import { describeForPlatforms } from '../../../../tests/component-view/platform'; +import { + buildConfirmedLocalSendTransaction, + buildPendingLocalSendTransaction, + initialStateActivityWithLocalTransactions, +} from '../../../../tests/component-view/presets/activity'; +import { + renderActivityListView, + renderActivityListViewWithRoutes, +} from '../../../../tests/component-view/renderers/activity'; +import { getRouteProbeTestId } from '../../../../tests/component-view/render'; +import { + activityListRowItemTestId, + activityListRowStatusTestId, +} from './ActivityList.testIds'; + +const transactionControllerWithIncomingSync = Engine.context + .TransactionController as unknown as { + updateIncomingTransactions: jest.MockedFunction<() => Promise>; +}; + +describeForPlatforms('ActivityList', () => { + it('shows pending and confirmed local rows then opens transaction details from a confirmed row', async () => { + const state = initialStateActivityWithLocalTransactions([ + buildConfirmedLocalSendTransaction(), + buildPendingLocalSendTransaction(), + ]).build(); + + const { findByTestId } = renderActivityListViewWithRoutes({ + state, + extraRoutes: [{ name: Routes.MODAL.ROOT_MODAL_FLOW }], + }); + + const pendingStatus = await findByTestId(activityListRowStatusTestId(0)); + + expect(pendingStatus).toHaveTextContent(strings('transaction.submitted')); + + const confirmedRow = await findByTestId(activityListRowItemTestId(1)); + const confirmedScope = within(confirmedRow); + + expect(confirmedScope.getByText('Sent ETH')).toBeOnTheScreen(); + expect( + confirmedScope.getByTestId(activityListRowStatusTestId(1)), + ).toHaveTextContent(strings('transaction.confirmed')); + expect(confirmedScope.getByText('-1 ETH')).toBeOnTheScreen(); + + fireEvent.press(confirmedRow); + + expect( + await findByTestId(getRouteProbeTestId(Routes.MODAL.ROOT_MODAL_FLOW)), + ).toBeOnTheScreen(); + }); + + it('pull to refresh on an empty list syncs incoming transactions through Engine', async () => { + const updateIncomingSpy = jest + .spyOn( + transactionControllerWithIncomingSync, + 'updateIncomingTransactions', + ) + .mockResolvedValue(undefined); + + const { UNSAFE_getByType } = renderActivityListView({ + overrides: { + settings: { + basicFunctionalityEnabled: true, + }, + }, + }); + + fireEvent(UNSAFE_getByType(RefreshControl), 'refresh'); + + await waitFor(() => { + expect(updateIncomingSpy).toHaveBeenCalledTimes(1); + }); + + updateIncomingSpy.mockRestore(); + }); +}); diff --git a/app/components/Views/ActivityList/helpers/adapters.test.ts b/app/components/Views/ActivityList/helpers/adapters.test.ts new file mode 100644 index 000000000000..d4ef537547ae --- /dev/null +++ b/app/components/Views/ActivityList/helpers/adapters.test.ts @@ -0,0 +1,150 @@ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import { normalizeTransaction } from './adapters'; + +const address = '0x9bed78535d6a03a955f1504aadba974d9a29e292'; +const recipient = '0x80181d3ba89220cdb80234fc7aa19d5cc56229cc'; +const token = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'; + +const makeTx = ( + overrides: Partial = {}, +): V1TransactionByHashResponse => + ({ + blockNumber: 10, + chainId: 8453, + from: address, + gas: '21000', + gasPrice: '1', + gasUsed: '21000', + hash: '0xhash', + isError: false, + methodId: '0x', + nonce: 7, + timestamp: '2026-01-01T00:00:00.000Z', + to: recipient, + value: '1', + valueTransfers: [], + ...overrides, + }) as unknown as V1TransactionByHashResponse; + +describe('normalizeTransaction', () => { + it('normalizes a simple send into TransactionMeta', () => { + const meta = normalizeTransaction(address, makeTx()); + + expect(meta).toMatchObject({ + chainId: '0x2105', + hash: '0xhash', + id: '0xhash-8453', + status: TransactionStatus.confirmed, + time: 1767225600000, + type: TransactionType.simpleSend, + txParams: { + chainId: '0x2105', + from: address, + to: recipient, + value: '0x1', + }, + }); + }); + + it('marks errored transactions as failed and maps known calldata method ids', () => { + expect( + normalizeTransaction( + address, + makeTx({ + isError: true, + methodId: '0x095ea7b3', + to: token, + value: '0', + }), + ), + ).toMatchObject({ + error: expect.any(Error), + status: TransactionStatus.failed, + type: TransactionType.tokenMethodApprove, + }); + + expect( + normalizeTransaction( + address, + makeTx({ methodId: '0xa9059cbb', to: token, value: '0' }), + ).type, + ).toBe(TransactionType.tokenMethodTransfer); + expect( + normalizeTransaction( + address, + makeTx({ methodId: '0x23b872dd', to: token, value: '0' }), + ).type, + ).toBe(TransactionType.tokenMethodTransferFrom); + expect( + normalizeTransaction( + address, + makeTx({ methodId: '0xf242432a', to: token, value: '0' }), + ).type, + ).toBe(TransactionType.tokenMethodSafeTransferFrom); + expect( + normalizeTransaction( + address, + makeTx({ methodId: '0x39509351', to: token, value: '0' }), + ).type, + ).toBe(TransactionType.tokenMethodIncreaseAllowance); + }); + + it('normalizes incoming token transfers using transfer information and recipient address', () => { + const sender = '0x1111111111111111111111111111111111111111'; + const meta = normalizeTransaction( + address, + makeTx({ + from: sender, + to: token, + value: '0', + valueTransfers: [ + { + amount: '1000000', + contractAddress: token, + decimal: 6, + from: sender, + name: 'USD Coin', + symbol: 'USDC', + to: address, + transferType: 'ERC20', + }, + ], + }), + ); + + expect(meta).toMatchObject({ + isTransfer: true, + transferInformation: { + amount: '1000000', + contractAddress: token, + decimals: 6, + symbol: 'USDC', + }, + type: TransactionType.incoming, + txParams: { + to: address, + value: '0xf4240', + }, + }); + }); + + it('classifies contract deployments and value-bearing contract interactions', () => { + expect( + normalizeTransaction( + address, + makeTx({ methodId: '0xabcdef12', to: undefined, value: '0' }), + ).type, + ).toBe(TransactionType.deployContract); + + expect( + normalizeTransaction( + address, + makeTx({ methodId: '0xabcdef12', to: recipient, value: '1' }), + ).type, + ).toBe(TransactionType.contractInteraction); + }); +}); diff --git a/app/components/Views/ActivityList/helpers/adapters.ts b/app/components/Views/ActivityList/helpers/adapters.ts new file mode 100644 index 000000000000..865a58c2361f --- /dev/null +++ b/app/components/Views/ActivityList/helpers/adapters.ts @@ -0,0 +1,138 @@ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { + type TransactionMeta, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import { + APPROVE_FUNCTION_SIGNATURE, + INCREASE_ALLOWANCE_SIGNATURE, + NFT_SAFE_TRANSFER_FROM_FUNCTION_SIGNATURE, + SET_APPROVAL_FOR_ALL_SIGNATURE, + TRANSFER_FROM_FUNCTION_SIGNATURE, + TRANSFER_FUNCTION_SIGNATURE, +} from '../../../../util/transactions'; +import { Hex } from 'viem'; +import { toHex } from '@metamask/controller-utils'; + +// Ported from transaction-controller +// - AccountsApiRemoteTransactionSource +// - determineTransactionType +function resolveTransactionMetaType( + transaction: V1TransactionByHashResponse, + isOutgoing: boolean, +) { + if (!isOutgoing) { + return TransactionType.incoming; + } + + const rawData = transaction.methodId?.toLowerCase(); + // Treat '0x' (empty calldata) the same as no methodId, since the API + // returns '0x' for simple ETH sends. + const data = rawData && rawData !== '0x' ? rawData : undefined; + + if (data && !transaction.to) { + return TransactionType.deployContract; + } + + const isContractAddress = Boolean(data?.length); + + if (!isContractAddress) { + return TransactionType.simpleSend; + } + + const hasValue = BigInt(transaction.value ?? '0') !== BigInt(0); + + if (hasValue) { + return TransactionType.contractInteraction; + } + + if (!data) { + return TransactionType.contractInteraction; + } + + switch (data) { + case APPROVE_FUNCTION_SIGNATURE: + return TransactionType.tokenMethodApprove; + case SET_APPROVAL_FOR_ALL_SIGNATURE: + return TransactionType.tokenMethodSetApprovalForAll; + case TRANSFER_FUNCTION_SIGNATURE: + return TransactionType.tokenMethodTransfer; + case TRANSFER_FROM_FUNCTION_SIGNATURE: + return TransactionType.tokenMethodTransferFrom; + case NFT_SAFE_TRANSFER_FROM_FUNCTION_SIGNATURE: + return TransactionType.tokenMethodSafeTransferFrom; + case INCREASE_ALLOWANCE_SIGNATURE: + return TransactionType.tokenMethodIncreaseAllowance; + default: + return TransactionType.contractInteraction; + } +} + +// Ported from transaction-controller normalizeTransaction +export function normalizeTransaction( + address: string, + transaction: V1TransactionByHashResponse, +) { + const { from, hash, methodId } = transaction; + const normalizedAddress = address.toLowerCase(); + + const status = transaction.isError + ? TransactionStatus.failed + : TransactionStatus.confirmed; + + // Find token transfer that involves the current address + const valueTransfer = transaction.valueTransfers?.find( + (vt) => + (vt.to?.toLowerCase() === normalizedAddress || + vt.from?.toLowerCase() === normalizedAddress) && + vt.contractAddress, + ); + + const isIncomingTokenTransfer = + valueTransfer?.to?.toLowerCase() === normalizedAddress && + from.toLowerCase() !== normalizedAddress; + const isOutgoing = from.toLowerCase() === normalizedAddress; + + const transferInformation = valueTransfer + ? { + amount: valueTransfer.amount, + contractAddress: valueTransfer.contractAddress, + decimals: valueTransfer.decimal, + symbol: valueTransfer.symbol, + } + : undefined; + + const meta: TransactionMeta = { + blockNumber: String(transaction.blockNumber), + chainId: toHex(transaction.chainId), + error: transaction.isError ? new Error('Transaction failed') : undefined, + hash, + id: `${hash}-${transaction.chainId}`, + isTransfer: isIncomingTokenTransfer, + networkClientId: '', + status, + time: Date.parse(transaction.timestamp) || 0, + toSmartContract: false, + transferInformation, + txParams: { + chainId: toHex(transaction.chainId), + data: methodId as Hex, + from: from as Hex, + gas: toHex(transaction.gas), + gasPrice: toHex(transaction.gasPrice), + gasUsed: toHex(transaction.gasUsed), + nonce: toHex(transaction.nonce), + to: isIncomingTokenTransfer ? address : transaction.to, + value: toHex( + isIncomingTokenTransfer + ? (valueTransfer?.amount ?? transaction.value) + : transaction.value, + ), + }, + type: resolveTransactionMetaType(transaction, isOutgoing), + verifiedOnBlockchain: false, + }; + + return meta; +} diff --git a/app/components/Views/ActivityList/helpers/transformations.test.ts b/app/components/Views/ActivityList/helpers/transformations.test.ts new file mode 100644 index 000000000000..e2e3ece9e5a3 --- /dev/null +++ b/app/components/Views/ActivityList/helpers/transformations.test.ts @@ -0,0 +1,240 @@ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { + isBridgeHistoryForEvmTransaction, + mapNonEvmTransactions, + mergeTransactionsByTime, + selectApiEvmTransactions, + shouldSkipTransaction, +} from './transformations'; +import { + mapApiEvmTransactions, + mapKeyringTransaction, +} from '../../../../util/activity-adapters'; + +jest.mock('../../../../util/activity-adapters', () => ({ + mapApiEvmTransactions: jest.fn(({ transaction }) => ({ + type: 'send', + chainId: `eip155:${transaction.chainId}`, + status: 'success', + timestamp: Date.parse(transaction.timestamp), + data: { hash: transaction.hash }, + raw: { type: 'apiEvmTransaction', data: transaction }, + })), + mapKeyringTransaction: jest.fn(({ transaction }) => ({ + type: 'send', + chainId: transaction.chain, + status: 'success', + timestamp: 1, + data: { hash: transaction.id }, + raw: { type: 'keyringTransaction', data: transaction }, + })), +})); + +const address = '0x9bed78535d6a03a955f1504aadba974d9a29e292'; +const otherAddress = '0x80181d3ba89220cdb80234fc7aa19d5cc56229cc'; + +const makeTx = ( + overrides: Partial = {}, +): V1TransactionByHashResponse => + ({ + chainId: 1, + from: address, + hash: '0xhash', + methodId: '0x', + timestamp: '2026-01-01T00:00:00.000Z', + to: otherAddress, + value: '1', + valueTransfers: [], + ...overrides, + }) as unknown as V1TransactionByHashResponse; + +describe('ActivityList transformations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('shouldSkipTransaction', () => { + it('skips excluded hashes, unrelated transactions, spam, and empty self transfers', () => { + expect( + shouldSkipTransaction(address, makeTx(), new Set(['0xhash'])), + ).toBe(true); + expect( + shouldSkipTransaction( + address, + makeTx({ from: otherAddress, to: otherAddress }), + ), + ).toBe(true); + expect( + shouldSkipTransaction( + address, + makeTx({ transactionType: 'SPAM_TOKEN_TRANSFER' }), + ), + ).toBe(true); + expect( + shouldSkipTransaction( + address, + makeTx({ + from: address, + methodId: '0x', + to: address, + value: '0', + valueTransfers: [], + }), + ), + ).toBe(true); + }); + + it('skips incoming transfers already handled by local sources and keeps normal outgoing transactions', () => { + expect( + shouldSkipTransaction( + address, + makeTx({ + from: otherAddress, + to: address, + valueTransfers: [ + { + amount: '1000000', + contractAddress: '0xtoken', + decimal: 6, + from: otherAddress, + name: 'Mock Token', + symbol: 'MOCK', + to: address, + transferType: 'ERC20', + }, + ], + }), + ), + ).toBe(true); + expect( + shouldSkipTransaction( + address, + makeTx({ + from: otherAddress, + to: address, + valueTransfers: [ + { + amount: '1', + contractAddress: '', + decimal: 18, + from: otherAddress, + name: 'Ether', + symbol: 'ETH', + to: address, + transferType: 'NATIVE', + }, + ], + }), + ), + ).toBe(true); + expect(shouldSkipTransaction(address, makeTx())).toBe(false); + }); + }); + + it('matches bridge history by tx ids, original id, and source hash', () => { + const bridgeHistoryValues = [ + { + txMetaId: 'meta-id', + originalTransactionId: 'original-id', + status: { srcChain: { txHash: '0xBridgeHash' } }, + }, + ]; + + expect( + isBridgeHistoryForEvmTransaction( + { id: 'meta-id' }, + bridgeHistoryValues as never, + ), + ).toBe(true); + expect( + isBridgeHistoryForEvmTransaction( + { actionId: 'original-id' }, + bridgeHistoryValues as never, + ), + ).toBe(true); + expect( + isBridgeHistoryForEvmTransaction( + { hash: '0xbridgehash' }, + bridgeHistoryValues as never, + ), + ).toBe(true); + expect( + isBridgeHistoryForEvmTransaction( + { hash: '0xother' }, + bridgeHistoryValues as never, + ), + ).toBe(false); + }); + + it('selects and maps API EVM transactions while preserving the infinite-query shape', () => { + const tx = makeTx({ hash: '0xkept' }); + const skipped = makeTx({ hash: '0xskipped' }); + const select = selectApiEvmTransactions({ + address, + excludedTxHashes: new Set(['0xskipped']), + }); + + const result = select({ + pageParams: [undefined], + pages: [ + { + data: [tx, skipped], + pageInfo: { + count: 2, + endCursor: 'next', + hasNextPage: true, + }, + unprocessedNetworks: [], + }, + ], + } as never); + + expect(result.pages[0].pageInfo.endCursor).toBe('next'); + expect(result.pages[0].data).toHaveLength(1); + expect(mapApiEvmTransactions).toHaveBeenCalledWith({ + subjectAddress: address.toLowerCase(), + transaction: tx, + environment: undefined, + }); + }); + + it('maps non-EVM transactions and merges by descending timestamp', () => { + const nonEvmItems = mapNonEvmTransactions([ + { id: 'solana-tx', chain: 'solana:mainnet' }, + ] as never); + + expect(mapKeyringTransaction).toHaveBeenCalledWith({ + transaction: { id: 'solana-tx', chain: 'solana:mainnet' }, + }); + expect(nonEvmItems[0].data.hash).toBe('solana-tx'); + + const merged = mergeTransactionsByTime( + [ + { + ...nonEvmItems[0], + timestamp: 1, + }, + ], + [ + { + type: 'receive', + chainId: 'eip155:1', + status: 'success', + timestamp: 3, + data: { from: otherAddress, hash: '0xconfirmed', to: address }, + }, + ], + [ + { + type: 'send', + chainId: 'solana:mainnet', + status: 'success', + timestamp: 2, + data: { from: otherAddress, hash: '0xnon-evm', to: address }, + }, + ], + ); + + expect(merged.map((item) => item.timestamp)).toEqual([3, 2, 1]); + }); +}); diff --git a/app/components/Views/ActivityList/helpers/transformations.ts b/app/components/Views/ActivityList/helpers/transformations.ts new file mode 100644 index 000000000000..9464761ac019 --- /dev/null +++ b/app/components/Views/ActivityList/helpers/transformations.ts @@ -0,0 +1,192 @@ +/** + * Transformation helpers for the UnifiedTransactionsView. + * Produces ActivityListItem[] from API EVM and non-EVM transaction sources. + * Local EVM transactions are handled separately by useLocalActivityItems. + */ +import { + type V1TransactionByHashResponse, + type V4MultiAccountTransactionsResponse, +} from '@metamask/core-backend'; +import type { BridgeHistoryItem } from '@metamask/bridge-status-controller'; +import type { Transaction as NonEvmTransaction } from '@metamask/keyring-api'; +import type { InfiniteData } from '@tanstack/react-query'; +import { + mapApiEvmTransactions, + mapKeyringTransaction, + type ActivityListItem, + type ActivityAdapterEnvironment, +} from '../../../../util/activity-adapters'; +import { mergeActivityItems } from '../../../../util/activity-adapters/adapters/dedup'; +import { equalsIgnoreCase } from '../../../../util/string'; + +export type { ActivityListItem }; + +const excludedTransactionTypes = ['SPAM_TOKEN_TRANSFER']; + +const getOriginalTransactionId = (bridgeHistoryItem: BridgeHistoryItem) => + (bridgeHistoryItem as unknown as { originalTransactionId?: string }) + .originalTransactionId; + +export const isBridgeHistoryForEvmTransaction = ( + tx: { id?: string; actionId?: string; hash?: string }, + bridgeHistoryValues: BridgeHistoryItem[], +) => + bridgeHistoryValues.some((bridgeHistoryItem) => { + const originalTransactionId = getOriginalTransactionId(bridgeHistoryItem); + return ( + bridgeHistoryItem.txMetaId === tx.id || + bridgeHistoryItem.txMetaId === tx.actionId || + originalTransactionId === tx.id || + originalTransactionId === tx.actionId || + equalsIgnoreCase(bridgeHistoryItem.status?.srcChain?.txHash, tx.hash) + ); + }); + +function isIncomingTokenTransfer( + address: string, + transaction: V1TransactionByHashResponse, +) { + const normalizedAddress = address.toLowerCase(); + return ( + transaction.valueTransfers?.some( + (transfer) => + Boolean(transfer.contractAddress) && + transfer.to?.toLowerCase() === normalizedAddress && + transfer.from?.toLowerCase() !== normalizedAddress, + ) ?? false + ); +} + +function isIncomingNativeTransfer( + address: string, + transaction: V1TransactionByHashResponse, +) { + const normalizedAddress = address.toLowerCase(); + let hasOutgoingTransfer = false; + let hasIncomingNativeTransfer = false; + + for (const transfer of transaction.valueTransfers ?? []) { + if ( + !hasOutgoingTransfer && + transfer.from?.toLowerCase() === normalizedAddress + ) { + hasOutgoingTransfer = true; + } + + if ( + !hasIncomingNativeTransfer && + transfer.to?.toLowerCase() === normalizedAddress && + !transfer.contractAddress + ) { + hasIncomingNativeTransfer = true; + } + + if (hasOutgoingTransfer && hasIncomingNativeTransfer) { + break; + } + } + + return hasIncomingNativeTransfer && !hasOutgoingTransfer; +} + +export function shouldSkipTransaction( + address: string, + transaction: V1TransactionByHashResponse, + excludedTxHashes?: Set, +) { + const rawFrom = transaction.from?.toLowerCase(); + const rawTo = transaction.to?.toLowerCase(); + const hash = transaction.hash?.toLowerCase(); + + if (hash && excludedTxHashes?.has(hash)) { + return true; + } + + if (rawFrom !== address && rawTo !== address) { + return true; + } + + if (excludedTransactionTypes.includes(transaction.transactionType ?? '')) { + return true; + } + + if ( + rawFrom === address && + rawTo === address && + transaction.value === '0' && + !transaction.valueTransfers?.length && + (!transaction.methodId || transaction.methodId === '0x') + ) { + return true; + } + + if (isIncomingTokenTransfer(address, transaction)) { + return true; + } + + return rawFrom !== address && isIncomingNativeTransfer(address, transaction); +} + +function transformApiTransactions( + address: string, + transactions: V1TransactionByHashResponse[], + excludedTxHashes?: Set, + environment?: ActivityAdapterEnvironment, +): ActivityListItem[] { + const items: ActivityListItem[] = []; + const subjectAddress = address.toLowerCase(); + + for (const tx of transactions) { + if (shouldSkipTransaction(subjectAddress, tx, excludedTxHashes)) { + continue; + } + items.push( + mapApiEvmTransactions({ subjectAddress, transaction: tx, environment }), + ); + } + + return items; +} + +export function selectApiEvmTransactions({ + address, + excludedTxHashes, + environment, +}: { + address: string; + excludedTxHashes?: Set; + environment?: ActivityAdapterEnvironment; +}) { + return (data: InfiniteData) => ({ + ...data, + pages: data.pages.map((page) => ({ + ...page, + data: transformApiTransactions( + address, + page.data, + excludedTxHashes, + environment, + ), + })), + }); +} + +export function mapNonEvmTransactions( + transactions: NonEvmTransaction[], +): ActivityListItem[] { + return transactions.map((transaction) => + mapKeyringTransaction({ transaction }), + ); +} + +/** + * Merges and sorts all three transaction sources into a single ActivityListItem list. + * API-confirmed EVM items win deduplication by hash over local items. + */ +export function mergeTransactionsByTime( + localItems: ActivityListItem[], + confirmedEvmItems: ActivityListItem[], + nonEvmItems: ActivityListItem[], +): ActivityListItem[] { + return mergeActivityItems(localItems, confirmedEvmItems, nonEvmItems); +} diff --git a/app/components/Views/ActivityList/hooks/useLocalActivityItems.test.ts b/app/components/Views/ActivityList/hooks/useLocalActivityItems.test.ts new file mode 100644 index 000000000000..4cbc00772f33 --- /dev/null +++ b/app/components/Views/ActivityList/hooks/useLocalActivityItems.test.ts @@ -0,0 +1,205 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { + TransactionStatus, + TransactionType, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import { useSelector } from 'react-redux'; +import { useLocalActivityItems } from './useLocalActivityItems'; +import { selectLocalTransactions } from '../../../../selectors/transactionController'; +import { selectBridgeHistoryForAccount } from '../../../../selectors/bridgeStatusController'; +import { selectEvmNetworkConfigurationsByChainId } from '../../../../selectors/networkController'; +import { selectAllTokens } from '../../../../selectors/tokensController'; +import { selectSelectedAccountGroupEvmInternalAccount } from '../../../../selectors/multichainAccounts/accountTreeController'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../selectors/transactionController', () => ({ + selectLocalTransactions: jest.fn(), +})); + +jest.mock('../../../../selectors/bridgeStatusController', () => ({ + selectBridgeHistoryForAccount: jest.fn(), +})); + +jest.mock('../../../../selectors/networkController', () => ({ + selectEvmNetworkConfigurationsByChainId: jest.fn(), +})); + +jest.mock('../../../../selectors/tokensController', () => ({ + selectAllTokens: jest.fn(), +})); + +jest.mock( + '../../../../selectors/multichainAccounts/accountTreeController', + () => ({ + selectSelectedAccountGroupEvmInternalAccount: jest.fn(), + }), +); + +const from = '0x9bed78535d6a03a955f1504aadba974d9a29e292'; +const recipient = '0x80181d3ba89220cdb80234fc7aa19d5cc56229cc'; +const usdc = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'; + +const makeTx = (overrides: Partial = {}): TransactionMeta => + ({ + chainId: '0x2105', + hash: '0xhash', + id: 'tx-id', + status: TransactionStatus.submitted, + time: 1, + type: TransactionType.simpleSend, + txParams: { + from, + nonce: '0x1', + to: recipient, + value: '0x1', + }, + ...overrides, + }) as TransactionMeta; + +const selectorState = { + allTokens: { + '0x2105': { + [from.toLowerCase()]: [{ address: usdc, decimals: 6, symbol: 'USDC' }], + }, + }, + bridgeHistory: {}, + groupAccount: { address: from }, + localTransactions: [] as unknown[], + networks: { + '0x2105': { nativeCurrency: 'ETH' }, + }, +}; + +describe('useLocalActivityItems', () => { + beforeEach(() => { + jest.clearAllMocks(); + selectorState.localTransactions = []; + selectorState.bridgeHistory = {}; + (useSelector as unknown as jest.Mock).mockImplementation((selector) => { + switch (selector) { + case selectLocalTransactions: + return selectorState.localTransactions; + case selectBridgeHistoryForAccount: + return selectorState.bridgeHistory; + case selectEvmNetworkConfigurationsByChainId: + return selectorState.networks; + case selectAllTokens: + return selectorState.allTokens; + case selectSelectedAccountGroupEvmInternalAccount: + return selectorState.groupAccount; + default: + return undefined; + } + }); + }); + + it('groups retried transactions by nonce and marks only the lowest nonce as earliest', () => { + selectorState.localTransactions = [ + makeTx({ + id: 'nonce-1', + hash: '0xnonce1', + time: 1, + txParams: { from, nonce: '0x1', to: recipient, value: '0x1' }, + }), + makeTx({ + id: 'nonce-2', + hash: '0xnonce2', + time: 2, + txParams: { from, nonce: '0x2', to: recipient, value: '0x1' }, + }), + makeTx({ + id: 'nonce-2-retry', + hash: '0xnonce2retry', + time: 3, + type: TransactionType.retry, + txParams: { from, nonce: '0x2', to: recipient, value: '0x2' }, + }), + { id: 'smart-tx-without-params' }, + ]; + + const { result } = renderHook(() => useLocalActivityItems()); + + expect(result.current).toHaveLength(2); + expect(result.current[0]).toMatchObject({ + data: { hash: '0xnonce1' }, + isEarliestNonce: true, + }); + expect(result.current[1]).toMatchObject({ + data: { hash: '0xnonce2retry' }, + isEarliestNonce: false, + }); + }); + + it('enriches contract-token metadata from the selected account token list', () => { + selectorState.localTransactions = [ + makeTx({ + hash: '0xtoken', + type: TransactionType.tokenMethodTransfer, + txParams: { + data: '0xa9059cbb00000000000000000000000080181d3ba89220cdb80234fc7aa19d5cc56229cc00000000000000000000000000000000000000000000000000000000000f4240', + from, + nonce: '0x1', + to: usdc, + value: '0x0', + }, + }), + ]; + + const { result } = renderHook(() => useLocalActivityItems()); + + expect(result.current[0]).toMatchObject({ + data: { + token: { + amount: '1000000', + assetId: + 'eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + decimals: 6, + symbol: 'USDC', + }, + }, + }); + }); + + it('uses bridge history status and swap metadata enrichment', () => { + selectorState.bridgeHistory = { + bridgeTx: { + status: { destChain: { txHash: '0xdestination' } }, + }, + }; + selectorState.localTransactions = [ + makeTx({ + actionId: 'bridgeTx', + hash: '0xbridge', + id: 'bridge-id', + status: TransactionStatus.submitted, + type: TransactionType.bridge, + } as Partial), + makeTx({ + destinationTokenAddress: usdc, + destinationTokenSymbol: 'USDC', + hash: '0xswap', + id: 'swap-id', + sourceTokenSymbol: 'ETH', + type: TransactionType.swap, + txParams: { from, nonce: '0x2', to: usdc, value: '0x1' }, + } as Partial), + ]; + + const { result } = renderHook(() => useLocalActivityItems()); + + expect(result.current[0]).toMatchObject({ + data: { hash: '0xbridge' }, + status: 'success', + }); + expect(result.current[1]).toMatchObject({ + data: { + destinationToken: { direction: 'in', symbol: 'USDC' }, + sourceToken: { direction: 'out', symbol: 'ETH' }, + }, + }); + }); +}); diff --git a/app/components/Views/ActivityList/hooks/useLocalActivityItems.ts b/app/components/Views/ActivityList/hooks/useLocalActivityItems.ts new file mode 100644 index 000000000000..e1042b937849 --- /dev/null +++ b/app/components/Views/ActivityList/hooks/useLocalActivityItems.ts @@ -0,0 +1,273 @@ +/** + * Builds enriched TransactionGroups from Mobile's local transactions and maps them + * to ActivityListItem[] using the shared mapLocalTransaction adapter. + */ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + TransactionMeta, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { selectLocalTransactions } from '../../../../selectors/transactionController'; +import { selectBridgeHistoryForAccount } from '../../../../selectors/bridgeStatusController'; +import { selectEvmNetworkConfigurationsByChainId } from '../../../../selectors/networkController'; +import { selectAllTokens } from '../../../../selectors/tokensController'; +import { selectSelectedAccountGroupEvmInternalAccount } from '../../../../selectors/multichainAccounts/accountTreeController'; +import { + mapLocalTransaction, + mobileActivityAdapterEnvironment, + type TransactionGroup, + type ActivityListItem, + type Status, + type TokenAmount, +} from '../../../../util/activity-adapters'; + +const BRIDGE_FAIL_STATUSES = [ + TransactionStatus.failed, + TransactionStatus.dropped, + TransactionStatus.rejected, +] as string[]; + +/** + * Checks whether a transaction is a TransactionMeta (vs SmartTransaction). + * SmartTransaction objects have chainId on the object when returned from the Mobile selector. + */ +function isTransactionMetaLike( + tx: unknown, +): tx is TransactionMeta & { isSmartTransaction?: boolean } { + return Boolean( + tx && + typeof tx === 'object' && + 'txParams' in tx && + (tx as { txParams?: unknown }).txParams !== undefined, + ); +} + +/** + * Returns whether the pending transaction has the lowest nonce among all pending + * transactions for the same sender+chain, which determines if it is "earliest". + */ +function computeIsEarliestNonce( + tx: TransactionMeta, + allLocalTxs: TransactionMeta[], +): boolean { + const { txParams } = tx; + if ( + !txParams?.from || + txParams.nonce === undefined || + txParams.nonce === null + ) { + return true; + } + const ownNonce = Number(txParams.nonce); + const from = txParams.from.toLowerCase(); + const chain = tx.chainId?.toLowerCase(); + + return !allLocalTxs.some((other) => { + if (other.id === tx.id) return false; + const otherNonce = Number(other.txParams?.nonce); + return ( + other.txParams?.from?.toLowerCase() === from && + other.chainId?.toLowerCase() === chain && + otherNonce < ownNonce + ); + }); +} + +function getTransactionGroupKey(tx: TransactionMeta): string { + const chainId = tx.chainId?.toLowerCase() ?? 'unknown-chain'; + const from = tx.txParams?.from?.toLowerCase() ?? 'unknown-from'; + const nonce = tx.txParams?.nonce; + + if (nonce !== undefined && nonce !== null) { + return `${chainId}:${from}:${nonce}`; + } + + return `${chainId}:${from}:${tx.id}`; +} + +function buildTransactionGroups( + transactions: (TransactionMeta & { isSmartTransaction?: boolean })[], +): TransactionGroup[] { + const groupsByKey = new Map< + string, + (TransactionMeta & { isSmartTransaction?: boolean })[] + >(); + + for (const tx of transactions) { + const key = getTransactionGroupKey(tx); + const group = groupsByKey.get(key) ?? []; + group.push(tx); + groupsByKey.set(key, group); + } + + return [...groupsByKey.values()].map((groupTransactions) => { + const sorted = [...groupTransactions].sort( + (a, b) => (a.time ?? 0) - (b.time ?? 0), + ); + const initialTransaction = sorted[0]; + const primaryTransaction = sorted[sorted.length - 1]; + const nonce = initialTransaction.txParams?.nonce; + + return { + hasCancelled: sorted.some((tx) => tx.type === TransactionType.cancel), + hasRetried: sorted.some((tx) => tx.type === TransactionType.retry), + initialTransaction, + nonce: nonce === undefined || nonce === null ? undefined : String(nonce), + primaryTransaction, + transactions: sorted, + }; + }); +} + +/** + * Returns bridge activity status override for a local bridge transaction. + */ +function getBridgeActivityStatus( + tx: TransactionMeta, + bridgeHistory: ReturnType, +): Status | undefined { + if (tx.type !== TransactionType.bridge) return undefined; + const historyItem = + bridgeHistory[tx.id] ?? + (tx.actionId ? bridgeHistory[tx.actionId] : undefined) ?? + Object.values(bridgeHistory).find( + (item) => + (item as unknown as { originalTransactionId?: string }) + .originalTransactionId === tx.id, + ); + + if (!historyItem) return undefined; + + if (historyItem.status?.destChain?.txHash) { + return 'success'; + } + + if (BRIDGE_FAIL_STATUSES.includes(tx.status)) { + return 'failed'; + } + + return undefined; +} + +/** + * Derives source+destination token enrichment from swap metadata stored on TransactionMeta. + */ +function getSwapTokenEnrichment( + tx: TransactionMeta, + nativeSymbol: string | undefined, +): { sourceToken?: TokenAmount; destinationToken?: TokenAmount } { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const meta = tx as any; + const srcSymbol: string | undefined = + meta.sourceTokenSymbol ?? meta.swapMetaData?.token_from; + const dstSymbol: string | undefined = + meta.destinationTokenSymbol ?? meta.swapMetaData?.token_to; + + // For swaps where source is native, fallback to nativeSymbol if no explicit srcSymbol + const effectiveSrcSymbol = + srcSymbol ?? + (meta.destinationTokenAddress && nativeSymbol ? nativeSymbol : undefined); + + if (!effectiveSrcSymbol && !dstSymbol) return {}; + + const sourceToken: TokenAmount | undefined = effectiveSrcSymbol + ? { direction: 'out', symbol: effectiveSrcSymbol } + : undefined; + const destinationToken: TokenAmount | undefined = dstSymbol + ? { direction: 'in', symbol: dstSymbol } + : undefined; + + return { sourceToken, destinationToken }; +} + +export function useLocalActivityItems(): ActivityListItem[] { + // Outgoing / user-initiated txs only — excludes incoming spam from TransactionController. + const localTransactions = useSelector(selectLocalTransactions); + const bridgeHistory = useSelector(selectBridgeHistoryForAccount); + const networkConfigurations = useSelector( + selectEvmNetworkConfigurationsByChainId, + ); + const groupEvmAccount = useSelector( + selectSelectedAccountGroupEvmInternalAccount, + ); + // allTokens: Record> + const allTokens = useSelector(selectAllTokens) as Record< + string, + Record + >; + + const transactionMetaList = useMemo( + () => localTransactions.filter(isTransactionMetaLike), + [localTransactions], + ); + + return useMemo(() => { + const items: ActivityListItem[] = []; + const accountAddress = groupEvmAccount?.address?.toLowerCase(); + const groupedTransactions = buildTransactionGroups(transactionMetaList); + + for (const baseGroup of groupedTransactions) { + const { primaryTransaction: tx } = baseGroup; + const txChainId = tx.chainId as Hex | undefined; + + // Native symbol from network configuration + const chainConfig = txChainId + ? networkConfigurations?.[txChainId] + : undefined; + const nativeAssetSymbol = chainConfig?.nativeCurrency; + + // Token metadata for contract interactions (ERC-20 transfers, approvals) + const contractAddress = tx.txParams?.to?.toLowerCase(); + let contractTokenMetadata: + | { symbol?: string; decimals?: number } + | undefined; + + if (accountAddress && txChainId && contractAddress) { + const chainTokens = allTokens[txChainId]?.[accountAddress as Hex] ?? []; + const matchingToken = chainTokens.find( + (t) => t.address?.toLowerCase() === contractAddress, + ); + if (matchingToken) { + contractTokenMetadata = { + symbol: matchingToken.symbol, + decimals: matchingToken.decimals, + }; + } + } + + // Bridge activity status override + const activityStatus = getBridgeActivityStatus(tx, bridgeHistory); + + // Swap token enrichment + const { sourceToken, destinationToken } = getSwapTokenEnrichment( + tx, + nativeAssetSymbol, + ); + + const isEarliestNonce = computeIsEarliestNonce(tx, transactionMetaList); + + const group: TransactionGroup = { + ...baseGroup, + activityStatus, + sourceToken, + destinationToken, + nativeAssetSymbol, + contractTokenMetadata, + }; + + const item = mapLocalTransaction(group, mobileActivityAdapterEnvironment); + items.push({ ...item, isEarliestNonce }); + } + + return items; + }, [ + transactionMetaList, + bridgeHistory, + networkConfigurations, + allTokens, + groupEvmAccount?.address, + ]); +} diff --git a/app/components/Views/ActivityList/index.ts b/app/components/Views/ActivityList/index.ts new file mode 100644 index 000000000000..a25d2aef78fe --- /dev/null +++ b/app/components/Views/ActivityList/index.ts @@ -0,0 +1 @@ +export { default } from './ActivityList'; diff --git a/app/components/Views/ActivityList/types.ts b/app/components/Views/ActivityList/types.ts new file mode 100644 index 000000000000..0139a47e1606 --- /dev/null +++ b/app/components/Views/ActivityList/types.ts @@ -0,0 +1,10 @@ +/** + * Unified Activity list item type - re-exported from the shared activity adapters. + * All three transaction sources (API EVM, local EVM, non-EVM) produce this shape. + */ +export type { + ActivityListItem, + ActivityKind, + Status, + TokenAmount, +} from '../../../util/activity-adapters'; diff --git a/app/components/Views/ActivityList/useTransactionAutoScroll.test.ts b/app/components/Views/ActivityList/useTransactionAutoScroll.test.ts new file mode 100644 index 000000000000..91314482271f --- /dev/null +++ b/app/components/Views/ActivityList/useTransactionAutoScroll.test.ts @@ -0,0 +1,137 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { useTransactionAutoScroll } from './useTransactionAutoScroll'; +import Logger from '../../../util/Logger'; + +jest.mock('../../../util/Logger', () => ({ + error: jest.fn(), +})); + +describe('useTransactionAutoScroll', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const keyExtractor = (item: { id: string }) => item.id; + + it('does not scroll on initial render, then scrolls when a new first item appears', () => { + const listRef = { + current: { + scrollToOffset: jest.fn(), + }, + }; + const { rerender } = renderHook( + ({ data }) => + useTransactionAutoScroll(data, listRef as never, { + delay: 50, + keyExtractor, + }), + { + initialProps: { data: [{ id: 'old' }] }, + }, + ); + + act(() => { + jest.runOnlyPendingTimers(); + }); + expect(listRef.current.scrollToOffset).not.toHaveBeenCalled(); + + rerender({ data: [{ id: 'new' }, { id: 'old' }] }); + + act(() => { + jest.advanceTimersByTime(50); + }); + + expect(listRef.current.scrollToOffset).toHaveBeenCalledWith({ + animated: true, + offset: 0, + }); + }); + + it('clears pending auto-scroll when disabled and ignores new items while user is scrolling', () => { + const listRef = { + current: { + scrollToOffset: jest.fn(), + }, + }; + const { rerender, result } = renderHook( + ({ data, enabled }) => + useTransactionAutoScroll(data, listRef as never, { + delay: 50, + enabled, + keyExtractor, + }), + { + initialProps: { data: [{ id: 'old' }], enabled: true }, + }, + ); + + act(() => { + result.current.handleScroll(); + }); + rerender({ data: [{ id: 'new' }, { id: 'old' }], enabled: true }); + + act(() => { + jest.advanceTimersByTime(50); + }); + expect(listRef.current.scrollToOffset).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + rerender({ + data: [{ id: 'newer' }, { id: 'new' }, { id: 'old' }], + enabled: false, + }); + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(listRef.current.scrollToOffset).not.toHaveBeenCalled(); + }); + + it('logs extractor and scroll errors without throwing', () => { + const listRef = { + current: { + scrollToOffset: jest.fn(() => { + throw new Error('scroll failed'); + }), + }, + }; + const { rerender } = renderHook( + ({ data }) => + useTransactionAutoScroll(data, listRef as never, { + delay: 50, + keyExtractor: (item: { id: string }) => { + if (item.id === 'bad') { + throw new Error('bad item'); + } + return item.id; + }, + }), + { + initialProps: { data: [{ id: 'old' }] }, + }, + ); + + rerender({ data: [{ id: 'bad' }, { id: 'old' }] }); + expect(Logger.error).toHaveBeenCalledWith( + expect.any(Error), + 'useTransactionAutoScroll: Failed to extract item ID', + ); + + rerender({ data: [{ id: 'new' }, { id: 'bad' }, { id: 'old' }] }); + act(() => { + jest.advanceTimersByTime(50); + }); + + expect(Logger.error).toHaveBeenCalledWith( + expect.any(Error), + 'useTransactionAutoScroll: Auto-scroll failed', + ); + }); +}); diff --git a/app/components/Views/ActivityList/useTransactionAutoScroll.ts b/app/components/Views/ActivityList/useTransactionAutoScroll.ts new file mode 100644 index 000000000000..ad814e19bdb7 --- /dev/null +++ b/app/components/Views/ActivityList/useTransactionAutoScroll.ts @@ -0,0 +1,155 @@ +import { useRef, useEffect, useCallback, RefObject } from 'react'; +import { FlashListRef } from '@shopify/flash-list'; +import Logger from '../../../util/Logger'; + +interface UseTransactionAutoScrollOptions { + /** + * Whether auto-scroll is enabled + */ + enabled?: boolean; + /** + * Delay in milliseconds before scrolling (to ensure list has updated) + */ + delay?: number; + /** + * Function to extract a unique ID from an item + */ + keyExtractor: (item: T) => string | null; +} + +/** + * Custom hook to handle automatic scrolling to top when new transactions are added + * + * @param data - Array of items to monitor for changes + * @param listRef - Reference to the FlashList component + * @param options - Configuration options + * @returns Object containing the scroll handler + */ +export function useTransactionAutoScroll( + data: T[], + listRef: RefObject | null>, + options: UseTransactionAutoScrollOptions, +) { + const { enabled = true, delay = 150, keyExtractor } = options; + + const scrollTimeoutRef = useRef(null); + const isUserScrollingRef = useRef(false); + const userScrollTimeoutRef = useRef(null); + const isInitialRenderRef = useRef(true); + + // Track the first item ID to detect when a new item is added to the top + const previousFirstItemIdRef = useRef(null); + const previousDataLengthRef = useRef(0); + + /** + * Track user scrolling to prevent disruptive auto-scrolls + */ + const handleScroll = useCallback(() => { + isUserScrollingRef.current = true; + if (userScrollTimeoutRef.current) { + clearTimeout(userScrollTimeoutRef.current); + } + userScrollTimeoutRef.current = setTimeout(() => { + isUserScrollingRef.current = false; + }, 1000); + }, []); + + /** + * Scroll to top when a new item is added + */ + useEffect(() => { + // Clear any pending scroll when disabled + if (!enabled) { + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + scrollTimeoutRef.current = null; + } + return; + } + + // Safely extract the first item ID with error handling + const getCurrentFirstItemId = (): string | null => { + try { + if (data.length === 0) return null; + const firstItem = data[0]; + if (!firstItem) return null; + return keyExtractor(firstItem); + } catch (error) { + // Gracefully handle any unexpected item structure + Logger.error( + error as Error, + 'useTransactionAutoScroll: Failed to extract item ID', + ); + return null; + } + }; + + const currentFirstItemId = getCurrentFirstItemId(); + + // Skip auto-scroll on initial mount + if (isInitialRenderRef.current) { + isInitialRenderRef.current = false; + previousFirstItemIdRef.current = currentFirstItemId; + previousDataLengthRef.current = data.length; + return; + } + + // Detect if a new item was added + const listGrew = data.length > previousDataLengthRef.current; + const firstItemChanged = + previousFirstItemIdRef.current !== null && + currentFirstItemId !== null && + currentFirstItemId !== previousFirstItemIdRef.current; + + // Only scroll if we have valid item data and a change occurred + const hasNewItem = + data.length > 0 && + currentFirstItemId !== null && + (listGrew || firstItemChanged); + + if (hasNewItem && listRef.current) { + // Clear any pending scroll + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + // Small delay to ensure list has updated before scrolling + scrollTimeoutRef.current = setTimeout(() => { + // Check user scrolling state when timeout fires, not when scheduled + if (isUserScrollingRef.current) { + return; + } + + try { + listRef.current?.scrollToOffset({ offset: 0, animated: true }); + } catch (error) { + // Silently handle scroll errors (e.g., list not ready) + Logger.error( + error as Error, + 'useTransactionAutoScroll: Auto-scroll failed', + ); + } + }, delay); + } + + previousFirstItemIdRef.current = currentFirstItemId; + previousDataLengthRef.current = data.length; + }, [data, enabled, delay, keyExtractor, listRef]); + + /** + * Cleanup timeouts on unmount + */ + useEffect( + () => () => { + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + if (userScrollTimeoutRef.current) { + clearTimeout(userScrollTimeoutRef.current); + } + }, + [], + ); + + return { handleScroll }; +} diff --git a/app/components/Views/ActivityList/useTransactionsQuery.test.ts b/app/components/Views/ActivityList/useTransactionsQuery.test.ts new file mode 100644 index 000000000000..b6349b9ac34c --- /dev/null +++ b/app/components/Views/ActivityList/useTransactionsQuery.test.ts @@ -0,0 +1,113 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useSelector } from 'react-redux'; +import { apiClient } from '../../../core/apiClient'; +import { useTransactionsQuery } from './useTransactionsQuery'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('@tanstack/react-query', () => ({ + useInfiniteQuery: jest.fn((options) => options), +})); + +jest.mock('../../../core/apiClient', () => ({ + apiClient: { + accounts: { + getV4MultiAccountTransactionsInfiniteQueryOptions: jest.fn(() => ({ + queryKey: ['transactions'], + queryFn: jest.fn(), + })), + }, + }, +})); + +jest.mock('./helpers/transformations', () => ({ + selectApiEvmTransactions: jest.fn((args) => ({ selectorArgs: args })), +})); + +const mockUseSelector = useSelector as unknown as jest.Mock; + +describe('useTransactionsQuery', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses the selected account-group EVM address and enabled networks', () => { + const excludedTxHashes = new Set(['0xskip']); + mockUseSelector + .mockReturnValueOnce({ address: '0xGroupAddress' }) + .mockReturnValueOnce('0xGlobalAddress') + .mockReturnValueOnce(['eip155:1']) + .mockReturnValueOnce(excludedTxHashes); + + renderHook(() => useTransactionsQuery()); + + expect( + apiClient.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions, + ).toHaveBeenCalledWith({ + accountAddresses: ['eip155:0:0xGroupAddress'], + networks: ['eip155:1'], + includeTxMetadata: true, + }); + expect(useInfiniteQuery).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + retry: false, + select: { + selectorArgs: { + address: '0xGroupAddress', + excludedTxHashes, + }, + }, + }), + ); + }); + + it('falls back to the global EVM address and disables the query without networks', () => { + mockUseSelector + .mockReturnValueOnce(undefined) + .mockReturnValueOnce('0xGlobalAddress') + .mockReturnValueOnce([]) + .mockReturnValueOnce(new Set()); + + renderHook(() => useTransactionsQuery()); + + expect( + apiClient.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions, + ).toHaveBeenCalledWith({ + accountAddresses: ['eip155:0:0xGlobalAddress'], + networks: [], + includeTxMetadata: true, + }); + expect(useInfiniteQuery).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }), + ); + }); + + it('does not request account addresses when no EVM address exists', () => { + mockUseSelector + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(['eip155:1']) + .mockReturnValueOnce(new Set()); + + renderHook(() => useTransactionsQuery()); + + expect( + apiClient.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions, + ).toHaveBeenCalledWith({ + accountAddresses: [], + networks: ['eip155:1'], + includeTxMetadata: true, + }); + expect(useInfiniteQuery).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }), + ); + }); +}); diff --git a/app/components/Views/ActivityList/useTransactionsQuery.ts b/app/components/Views/ActivityList/useTransactionsQuery.ts new file mode 100644 index 000000000000..6c6ba30a6aed --- /dev/null +++ b/app/components/Views/ActivityList/useTransactionsQuery.ts @@ -0,0 +1,46 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { KnownCaipNamespace, toCaipAccountId } from '@metamask/utils'; +import { apiClient } from '../../../core/apiClient'; +import { selectEvmAddress } from '../../../selectors/accountsController'; +import { selectSelectedAccountGroupEvmInternalAccount } from '../../../selectors/multichainAccounts/accountTreeController'; +import { selectEvmEnabledCaipNetworks } from '../../../selectors/networkEnablementController'; +import { selectApiEvmTransactions } from './helpers/transformations'; +import { MINUTE } from '../../../constants/time'; +import { selectRequiredTransactionHashes } from '../../../selectors/transactionController'; + +export const useTransactionsQuery = () => { + const groupEvmAccount = useSelector( + selectSelectedAccountGroupEvmInternalAccount, + ); + const globalEvmAddress = useSelector(selectEvmAddress); + /** Activity must load EVM history for the group's EVM account when e.g. Bitcoin is selected. */ + const evmAddress = (groupEvmAccount?.address ?? globalEvmAddress ?? '') || ''; + const networks = useSelector(selectEvmEnabledCaipNetworks); + const excludedTxHashes = useSelector(selectRequiredTransactionHashes); + const accountAddresses = evmAddress + ? [toCaipAccountId(KnownCaipNamespace.Eip155, '0', evmAddress)] + : []; + + const queryOptions = + apiClient.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions({ + accountAddresses, + networks, + includeTxMetadata: true, + }); + + const selectFn = useMemo( + () => selectApiEvmTransactions({ address: evmAddress, excludedTxHashes }), + [evmAddress, excludedTxHashes], + ); + + // @ts-expect-error apiClient returns v5 types, repo still in v4 + return useInfiniteQuery({ + ...queryOptions, + select: selectFn, + enabled: accountAddresses.length > 0 && networks.length > 0, + staleTime: 5 * MINUTE, + retry: false, + }); +}; diff --git a/app/components/Views/ActivityList/useUnifiedTxActions.test.ts b/app/components/Views/ActivityList/useUnifiedTxActions.test.ts new file mode 100644 index 000000000000..872e5db2253d --- /dev/null +++ b/app/components/Views/ActivityList/useUnifiedTxActions.test.ts @@ -0,0 +1,267 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { + TransactionType, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import Engine from '../../../core/Engine'; +import { useUnifiedTxActions } from './useUnifiedTxActions'; +import { + executeHardwareWalletOperation, + useHardwareWallet, +} from '../../../core/HardwareWallet'; +import { + speedUpTransaction, + getPreviousGasFromController, +} from '../../../util/transaction-controller'; +import { validateTransactionActionBalance } from '../../../util/transactions'; +import { isHardwareAccount } from '../../../util/address'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../component-library/components/Toast', () => { + const ReactActual = jest.requireActual('react'); + return { + ToastContext: ReactActual.createContext({ + toastRef: { current: { showToast: jest.fn() } }, + }), + }; +}); + +jest.mock('../../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + ApprovalController: { + acceptRequest: jest.fn(), + rejectRequest: jest.fn(), + }, + TransactionController: { + stopTransaction: jest.fn(), + }, + }, + }, +})); + +jest.mock('../../../core/HardwareWallet', () => ({ + executeHardwareWalletOperation: jest.fn(async ({ execute }) => { + await execute(); + return true; + }), + useHardwareWallet: jest.fn(() => ({ + ensureDeviceReady: jest.fn(), + hideAwaitingConfirmation: jest.fn(), + setPendingOperationAddress: jest.fn(), + showAwaitingConfirmation: jest.fn(), + showHardwareWalletError: jest.fn(), + })), +})); + +jest.mock('../../../util/address', () => ({ + isHardwareAccount: jest.fn(() => false), +})); + +jest.mock('../../../util/confirmation/gas', () => ({ + getGasValuesForReplacement: jest.fn( + (params) => params ?? { gasPrice: '0x2' }, + ), + getMediumGasPriceHex: jest.fn(() => '0x2'), + normalizeReplacementGasFeeParams: jest.fn((params) => params), +})); + +jest.mock('../../../util/transaction-controller', () => ({ + getPreviousGasFromController: jest.fn(() => ({ gasPrice: '0x1' })), + speedUpTransaction: jest.fn(), +})); + +jest.mock('../../../util/transactions', () => ({ + validateTransactionActionBalance: jest.fn(() => false), +})); + +jest.mock('../../../util/confirmation/transactions', () => ({ + getTransactionUpdateErrorToastOptions: jest.fn((error) => ({ error })), +})); + +jest.mock('../../UI/QRHardware/QRSigningTransactionModal', () => ({ + QRSignMode: { Cancel: 'Cancel', SpeedUp: 'SpeedUp' }, + createQRSigningTransactionModalNavDetails: jest.fn((params) => [ + 'QRModal', + params, + ]), +})); + +const mockNavigate = jest.fn(); +const mockUseSelector = useSelector as unknown as jest.Mock; +const mockIsHardwareAccount = isHardwareAccount as unknown as jest.Mock; +let hardwareAccountType: 'ledger' | 'qr' | undefined; + +const tx = { + chainId: '0x1', + id: 'tx-id', + networkClientId: 'mainnet', + status: 'submitted', + time: 1, + type: TransactionType.simpleSend, + txParams: { + from: '0xselected', + gasPrice: '0x0', + to: '0xrecipient', + value: '0x1', + }, +} as unknown as TransactionMeta; + +describe('useUnifiedTxActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate }); + hardwareAccountType = undefined; + mockIsHardwareAccount.mockImplementation( + (_address: string, keyringTypes: string[]) => + keyringTypes.includes( + hardwareAccountType === 'ledger' + ? 'Ledger Hardware' + : hardwareAccountType === 'qr' + ? 'QR Hardware Wallet Device' + : 'none', + ), + ); + mockUseSelector + .mockReturnValueOnce({ medium: { suggestedMaxFeePerGas: '2' } }) + .mockReturnValueOnce({}) + .mockReturnValueOnce('0xselected'); + }); + + it('opens and closes speed-up/cancel modal state and sets disabled from balance validation', () => { + (validateTransactionActionBalance as jest.Mock).mockReturnValueOnce(true); + const { result } = renderHook(() => useUnifiedTxActions()); + + act(() => { + result.current.onSpeedUpAction(true, tx); + }); + + expect(result.current.speedUpIsOpen).toBe(true); + expect(result.current.speedUpTxId).toBe('tx-id'); + expect(result.current.confirmDisabled).toBe(true); + + act(() => { + result.current.onSpeedUpCancelCompleted(); + }); + + expect(result.current.speedUpIsOpen).toBe(false); + expect(result.current.speedUpTxId).toBeNull(); + + act(() => { + result.current.onCancelAction(true, tx); + }); + + expect(result.current.cancelIsOpen).toBe(true); + expect(result.current.cancelTxId).toBe('tx-id'); + }); + + it('executes software speed-up and cancel actions', async () => { + const { result } = renderHook(() => useUnifiedTxActions()); + + act(() => { + result.current.onSpeedUpAction(true, tx); + }); + await act(async () => { + await result.current.speedUpTransaction({ gasPrice: '0x0' }); + }); + + expect(getPreviousGasFromController).toHaveBeenCalledWith('tx-id'); + expect(speedUpTransaction).toHaveBeenCalledWith('tx-id', { + gasPrice: '0x2', + }); + expect(result.current.speedUpIsOpen).toBe(false); + + act(() => { + result.current.onCancelAction(true, tx); + }); + await act(async () => { + await result.current.cancelTransaction({ gasPrice: '0x3' }); + }); + + expect( + Engine.context.TransactionController.stopTransaction, + ).toHaveBeenCalledWith('tx-id', { gasPrice: '0x3' }); + }); + + it('routes QR hardware replacement transactions through the QR modal', async () => { + hardwareAccountType = 'qr'; + const { result } = renderHook(() => useUnifiedTxActions()); + + act(() => { + result.current.onSpeedUpAction(true, tx); + }); + await act(async () => { + await result.current.speedUpTransaction({ gasPrice: '0x3' }); + }); + + expect(mockNavigate).toHaveBeenCalledWith( + 'QRModal', + expect.objectContaining({ + transactionId: 'tx-id', + signMode: 'SpeedUp', + }), + ); + }); + + it('executes Ledger signing and direct QR/unsigned helpers', async () => { + mockIsHardwareAccount.mockImplementation(() => true); + const { result } = renderHook(() => useUnifiedTxActions()); + + await act(async () => { + await result.current.signLedgerTransaction({ + id: 'tx-id', + replacementParams: { + maxFeePerGas: '0x4', + type: 'cancel', + } as never, + }); + }); + + expect(executeHardwareWalletOperation).toHaveBeenCalled(); + expect( + Engine.context.TransactionController.stopTransaction, + ).toHaveBeenCalledWith( + 'tx-id', + expect.objectContaining({ maxFeePerGas: '0x4' }), + ); + + await act(async () => { + await result.current.signQRTransaction(tx); + }); + expect(mockNavigate).toHaveBeenCalledWith( + 'QRModal', + expect.objectContaining({ transactionId: 'tx-id' }), + ); + + await act(async () => { + await result.current.cancelUnsignedQRTransaction(tx); + }); + expect( + Engine.context.ApprovalController.rejectRequest, + ).toHaveBeenCalledWith('tx-id', expect.objectContaining({ code: 4001 })); + }); + + it('throws from Ledger signing when no selected address exists', async () => { + mockUseSelector.mockReset(); + mockUseSelector + .mockReturnValueOnce({}) + .mockReturnValueOnce({}) + .mockReturnValueOnce(undefined); + const { result } = renderHook(() => useUnifiedTxActions()); + + await expect( + result.current.signLedgerTransaction({ id: 'ledger-tx' }), + ).rejects.toThrow('Missing selected address'); + expect(useHardwareWallet).toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/ActivityList/useUnifiedTxActions.ts b/app/components/Views/ActivityList/useUnifiedTxActions.ts new file mode 100644 index 000000000000..854a7a4227e4 --- /dev/null +++ b/app/components/Views/ActivityList/useUnifiedTxActions.ts @@ -0,0 +1,420 @@ +import { providerErrors } from '@metamask/rpc-errors'; +import { + CANCEL_RATE, + SPEED_UP_RATE, + type FeeMarketEIP1559Values, + type GasPriceValue, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import { useNavigation } from '@react-navigation/native'; +import { useCallback, useContext, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { ToastContext } from '../../../component-library/components/Toast'; +import ExtendedKeyringTypes from '../../../constants/keyringTypes'; +import Engine from '../../../core/Engine'; +import { selectAccounts } from '../../../selectors/accountTrackerController'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; +import { selectGasFeeEstimates } from '../../../selectors/confirmTransaction'; +import { isHardwareAccount } from '../../../util/address'; +import { + getGasValuesForReplacement, + getMediumGasPriceHex, + normalizeReplacementGasFeeParams, +} from '../../../util/confirmation/gas'; +import { + getPreviousGasFromController, + speedUpTransaction as speedUpTx, +} from '../../../util/transaction-controller'; +import { validateTransactionActionBalance } from '../../../util/transactions'; +import { LedgerReplacementTxTypes } from '../../UI/LedgerModals/LedgerTransactionModal'; +import { type ReplacementTxParams } from '../../../core/HardwareWallet/transactionReplacementParams'; +import { + createQRSigningTransactionModalNavDetails, + QRSignMode, +} from '../../UI/QRHardware/QRSigningTransactionModal'; +import { + useHardwareWallet, + executeHardwareWalletOperation, +} from '../../../core/HardwareWallet'; +import { getTransactionUpdateErrorToastOptions } from '../../../util/confirmation/transactions'; + +type Maybe = T | null | undefined; + +export interface LegacyExistingGas { + isEIP1559Transaction?: false; + gasPrice?: string | number; +} + +export interface Eip1559ExistingGas { + isEIP1559Transaction: true; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; +} + +export type ExistingGas = LegacyExistingGas | Eip1559ExistingGas; + +/** Params for speed-up/cancel: controller shape only; optional error for UI flow */ +export type SpeedUpCancelParams = (GasPriceValue | FeeMarketEIP1559Values) & { + error?: string; +}; + +interface LedgerSignRequest { + id: string; + replacementParams?: ReplacementTxParams; + speedUpParams?: { + type?: string; + }; +} + +export const SpeedUpCancelModalState = { + Closed: 'closed', + SpeedUp: 'speedUp', + Cancel: 'cancel', +} as const; + +export type SpeedUpCancelModalState = + (typeof SpeedUpCancelModalState)[keyof typeof SpeedUpCancelModalState]; + +export function useUnifiedTxActions() { + const navigation = useNavigation(); + const { + ensureDeviceReady, + setPendingOperationAddress, + showAwaitingConfirmation, + hideAwaitingConfirmation, + showHardwareWalletError, + } = useHardwareWallet(); + const toastContext = useContext(ToastContext); + const toastRef = toastContext?.toastRef; + + const gasFeeEstimates = useSelector(selectGasFeeEstimates); + const accounts = useSelector(selectAccounts); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + + const [speedUpCancelModalState, setSpeedUpCancelModalState] = + useState(SpeedUpCancelModalState.Closed); + const [confirmDisabled, setConfirmDisabled] = useState(false); + const [existingTx, setExistingTx] = useState(null); + const [speedUpTxId, setSpeedUpTxId] = useState>(null); + const [cancelTxId, setCancelTxId] = useState>(null); + + const isLedgerAccount = isHardwareAccount(selectedAddress ?? '', [ + ExtendedKeyringTypes.ledger, + ]); + + const isQRHardwareAccount = isHardwareAccount(selectedAddress ?? '', [ + ExtendedKeyringTypes.qr, + ]); + + const showTransactionUpdateErrorToast = useCallback( + (error: unknown) => { + toastRef?.current?.showToast( + getTransactionUpdateErrorToastOptions(error), + ); + }, + [toastRef], + ); + + const closeSpeedUpCancelModal = useCallback(() => { + setSpeedUpCancelModalState(SpeedUpCancelModalState.Closed); + setSpeedUpTxId(null); + setCancelTxId(null); + setExistingTx(null); + }, []); + + const onSpeedUpCancelCompleted = useCallback(() => { + closeSpeedUpCancelModal(); + }, [closeSpeedUpCancelModal]); + + const signLedgerTransaction = useCallback( + async (transaction: LedgerSignRequest) => { + if (!selectedAddress) { + throw new Error( + 'Missing selected address for hardware wallet operation', + ); + } + + const gasFeeParams = normalizeReplacementGasFeeParams( + transaction?.replacementParams, + ); + + const didComplete = await executeHardwareWalletOperation({ + address: selectedAddress, + operationType: 'transaction', + ensureDeviceReady, + setPendingOperationAddress, + showAwaitingConfirmation, + hideAwaitingConfirmation, + showHardwareWalletError, + execute: async () => { + if ( + transaction?.replacementParams?.type === + LedgerReplacementTxTypes.SPEED_UP + ) { + await speedUpTx(transaction.id, gasFeeParams); + return; + } + + if ( + transaction?.replacementParams?.type === + LedgerReplacementTxTypes.CANCEL + ) { + await Engine.context.TransactionController.stopTransaction( + transaction.id, + gasFeeParams, + ); + return; + } + + await Engine.context.ApprovalController.acceptRequest( + transaction.id, + undefined, + { + waitForResult: true, + }, + ); + }, + onRejected: onSpeedUpCancelCompleted, + }); + + if (didComplete) { + onSpeedUpCancelCompleted(); + } + }, + [ + selectedAddress, + ensureDeviceReady, + setPendingOperationAddress, + showAwaitingConfirmation, + hideAwaitingConfirmation, + showHardwareWalletError, + onSpeedUpCancelCompleted, + ], + ); + + const getGasPriceEstimate = () => getMediumGasPriceHex(gasFeeEstimates); + + const getCancelOrSpeedupValues = (): + | GasPriceValue + | FeeMarketEIP1559Values + | undefined => { + const txParams = existingTx?.txParams; + const existingGasPriceHex = txParams?.gasPrice; + if (existingGasPriceHex !== undefined && existingGasPriceHex !== '0x0') { + const existingGasPriceDecimal = parseInt(String(existingGasPriceHex), 16); + if (existingGasPriceDecimal !== 0) { + return undefined; + } + } + return { gasPrice: getGasPriceEstimate() }; + }; + + const onSpeedUpAction = (open: boolean, tx?: TransactionMeta) => { + if (!open) { + setSpeedUpCancelModalState(SpeedUpCancelModalState.Closed); + return; + } + if (!tx) { + return; + } + setExistingTx(tx); + setSpeedUpTxId(tx.id ?? null); + setConfirmDisabled( + Boolean( + validateTransactionActionBalance( + tx, + SPEED_UP_RATE.toString(), + accounts, + ), + ), + ); + setSpeedUpCancelModalState(SpeedUpCancelModalState.SpeedUp); + }; + + const onCancelAction = (open: boolean, tx?: TransactionMeta) => { + if (!open) { + setSpeedUpCancelModalState(SpeedUpCancelModalState.Closed); + return; + } + if (!tx) { + return; + } + setExistingTx(tx); + setCancelTxId(tx.id ?? null); + setConfirmDisabled( + Boolean( + validateTransactionActionBalance(tx, CANCEL_RATE.toString(), accounts), + ), + ); + setSpeedUpCancelModalState(SpeedUpCancelModalState.Cancel); + }; + + const getParamsToSend = ( + params?: SpeedUpCancelParams, + ): GasPriceValue | FeeMarketEIP1559Values | undefined => { + if (params?.error) { + return undefined; + } + if ( + params && + 'gasPrice' in params && + (params.gasPrice === '0x0' || parseInt(String(params.gasPrice), 16) === 0) + ) { + return getCancelOrSpeedupValues(); + } + if (params && ('maxFeePerGas' in params || 'gasPrice' in params)) { + return params; + } + return getCancelOrSpeedupValues(); + }; + + const speedUpTransaction = async (params?: SpeedUpCancelParams) => { + try { + if (params && 'error' in params && params.error) { + throw new Error(params.error); + } + if (!speedUpTxId) { + throw new Error('Missing transaction id for speed up'); + } + + const rawGasValues = getParamsToSend(params); + const gasValues = getGasValuesForReplacement( + rawGasValues, + getPreviousGasFromController(speedUpTxId), + SPEED_UP_RATE, + ); + + if (isLedgerAccount) { + const isEip1559 = gasValues && 'maxFeePerGas' in gasValues; + + await signLedgerTransaction({ + id: speedUpTxId, + speedUpParams: { type: 'SpeedUp' }, + replacementParams: { + type: LedgerReplacementTxTypes.SPEED_UP, + ...(isEip1559 + ? { eip1559GasFee: gasValues } + : { legacyGasFee: gasValues }), + }, + }); + return; + } + + if (isQRHardwareAccount) { + const transactionId = speedUpTxId; + navigation.navigate( + ...createQRSigningTransactionModalNavDetails({ + transactionId, + signMode: QRSignMode.SpeedUp, + gasValues, + onConfirmationComplete: () => undefined, + }), + ); + onSpeedUpCancelCompleted(); + return; + } + + await speedUpTx(speedUpTxId, gasValues); + onSpeedUpCancelCompleted(); + } catch (error: unknown) { + showTransactionUpdateErrorToast(error); + setSpeedUpCancelModalState(SpeedUpCancelModalState.Closed); + } + }; + + const cancelTransaction = async (params?: SpeedUpCancelParams) => { + try { + if (params && 'error' in params && params.error) { + throw new Error(params.error); + } + if (!cancelTxId) { + throw new Error('Missing transaction id for cancel'); + } + + const rawGasValues = getParamsToSend(params); + const gasValues = getGasValuesForReplacement( + rawGasValues, + getPreviousGasFromController(cancelTxId), + CANCEL_RATE, + ); + + if (isLedgerAccount) { + const isEip1559 = gasValues && 'maxFeePerGas' in gasValues; + + await signLedgerTransaction({ + id: cancelTxId, + replacementParams: { + type: LedgerReplacementTxTypes.CANCEL, + ...(isEip1559 + ? { eip1559GasFee: gasValues } + : { legacyGasFee: gasValues }), + }, + }); + return; + } + + if (isQRHardwareAccount) { + const transactionId = cancelTxId; + navigation.navigate( + ...createQRSigningTransactionModalNavDetails({ + transactionId, + signMode: QRSignMode.Cancel, + gasValues, + onConfirmationComplete: () => undefined, + }), + ); + onSpeedUpCancelCompleted(); + return; + } + + await Engine.context.TransactionController.stopTransaction( + cancelTxId, + gasValues, + ); + onSpeedUpCancelCompleted(); + } catch (error: unknown) { + showTransactionUpdateErrorToast(error); + setSpeedUpCancelModalState(SpeedUpCancelModalState.Closed); + } + }; + + const signQRTransaction = useCallback( + async (transactionMeta: TransactionMeta) => { + navigation.navigate( + ...createQRSigningTransactionModalNavDetails({ + transactionId: transactionMeta.id, + onConfirmationComplete: () => { + // Modal handles confirmation/rejection internally + }, + }), + ); + }, + [navigation], + ); + + const cancelUnsignedQRTransaction = async (tx: TransactionMeta) => { + await Engine.context.ApprovalController.rejectRequest( + tx.id, + providerErrors.userRejectedRequest(), + ); + }; + + return { + speedUpIsOpen: speedUpCancelModalState === SpeedUpCancelModalState.SpeedUp, + cancelIsOpen: speedUpCancelModalState === SpeedUpCancelModalState.Cancel, + confirmDisabled, + existingTx, + speedUpTxId, + cancelTxId, + onSpeedUpAction, + onCancelAction, + onSpeedUpCancelCompleted, + speedUpTransaction, + cancelTransaction, + signQRTransaction, + signLedgerTransaction, + cancelUnsignedQRTransaction, + } as const; +} diff --git a/app/components/Views/ActivityScreen/ActivityScreen.testIds.ts b/app/components/Views/ActivityScreen/ActivityScreen.testIds.ts new file mode 100644 index 000000000000..d28010f36ee3 --- /dev/null +++ b/app/components/Views/ActivityScreen/ActivityScreen.testIds.ts @@ -0,0 +1,12 @@ +export const ActivityScreenSelectorsIDs = { + SAFE_AREA_VIEW: 'activity-screen-safe-area-view', + HEADER: 'activity-screen-header', + BACK_BUTTON: 'activity-screen-back-button', + SEARCH_INPUT: 'activity-screen-search-input', + NETWORK_FILTER_CHIP: 'activity-screen-network-filter-chip', + TYPE_FILTER_CHIP: 'activity-screen-type-filter-chip', + TYPE_FILTER_SHEET: 'activity-screen-type-filter-sheet', + TYPE_FILTER_OPTION_PREFIX: 'activity-screen-type-filter-option-', + LIST: 'activity-screen-list', + EMPTY_STATE: 'activity-screen-empty-state', +}; diff --git a/app/components/Views/ActivityScreen/ActivityScreen.tsx b/app/components/Views/ActivityScreen/ActivityScreen.tsx new file mode 100644 index 000000000000..20f29d44bc0c --- /dev/null +++ b/app/components/Views/ActivityScreen/ActivityScreen.tsx @@ -0,0 +1,220 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { + LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from 'react-native'; +import { type SharedValue, useSharedValue } from 'react-native-reanimated'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useNavigation } from '@react-navigation/native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + HeaderStandardAnimated, + Text, + TextFieldSearch, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../locales/i18n'; +import Routes from '../../../constants/navigation/Routes'; +import { ActivityScreenSelectorsIDs } from './ActivityScreen.testIds'; +import ActivityTypeFilterSheet, { + ACTIVITY_TYPE_FILTER_LABEL_KEY, +} from './components/ActivityTypeFilterSheet'; +import AssetListControlBar from './components/AssetListControlBar'; +// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog +import ActivityList from '../ActivityList'; +import { TrendingTokenNetworkBottomSheet } from '../../UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet'; +import { TRENDING_NETWORKS_LIST } from '../../UI/Trending/utils/trendingNetworksList'; +import type { CaipChainId } from '@metamask/utils'; +import { ActivityTypeFilter } from './types'; +// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog +import ErrorBoundary from '../ErrorBoundary'; + +const updateScrollY = ( + scrollY: SharedValue, + event: NativeSyntheticEvent, +) => { + scrollY.value = event.nativeEvent.contentOffset.y; +}; + +const ActivityScreen = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + + const scrollY = useSharedValue(0); + const titleSectionHeight = useSharedValue(0); + + const handleTitleLayout = useCallback( + (event: LayoutChangeEvent) => { + titleSectionHeight.value = event.nativeEvent.layout.height; + }, + [titleSectionHeight], + ); + + const [searchQuery, setSearchQuery] = useState(''); + // TODO: restore `ActivityTypeFilter.All` as the default once data-source + // unification lands. See `ACTIVITY_TYPE_FILTER_ORDER` in ./types.ts. + const [typeFilter, setTypeFilter] = useState( + ActivityTypeFilter.Transactions, + ); + const [isTypeSheetOpen, setIsTypeSheetOpen] = useState(false); + const [networkFilter, setNetworkFilter] = useState( + null, + ); + const [isNetworkSheetOpen, setIsNetworkSheetOpen] = useState(false); + + const handleClearSearch = useCallback(() => { + setSearchQuery(''); + }, []); + + const handleOpenTypeSheet = useCallback(() => { + setIsTypeSheetOpen(true); + }, []); + + const handleCloseTypeSheet = useCallback(() => { + setIsTypeSheetOpen(false); + }, []); + + const handleSelectTypeFilter = useCallback((filter: ActivityTypeFilter) => { + setTypeFilter(filter); + }, []); + + const isTypeFilterActive = typeFilter !== ActivityTypeFilter.All; + const typeFilterLabel = isTypeFilterActive + ? strings('activity_view.filter_types_selected', { + label: strings(ACTIVITY_TYPE_FILTER_LABEL_KEY[typeFilter]), + }) + : strings('activity_view.filter_all_types'); + + const isNetworkFilterActive = + Array.isArray(networkFilter) && networkFilter.length > 0; + const selectedNetworkName = isNetworkFilterActive + ? TRENDING_NETWORKS_LIST.find((n) => n.caipChainId === networkFilter[0]) + ?.name + : undefined; + const networkFilterLabel = + isNetworkFilterActive && selectedNetworkName + ? strings('activity_view.filter_network_selected', { + label: selectedNetworkName, + }) + : strings('activity_view.filter_all_networks'); + + const handleOpenNetworkSheet = useCallback(() => { + setIsNetworkSheetOpen(true); + }, []); + + const handleCloseNetworkSheet = useCallback(() => { + setIsNetworkSheetOpen(false); + }, []); + + const handleSelectNetwork = useCallback((chainIds: CaipChainId[] | null) => { + setNetworkFilter(chainIds); + }, []); + + const handleBackPress = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + navigation.navigate(Routes.HOME_TABS); + }, [navigation]); + + const handleActivityListScroll = useCallback( + (event: NativeSyntheticEvent) => { + updateScrollY(scrollY, event); + }, + [scrollY], + ); + + const activityListHeader = useMemo( + () => ( + <> + + + {strings('activity_view.title')} + + + + + {/* No functionality yet, just a placeholder for the search input */} + + + + + + + + ), + [ + handleClearSearch, + handleOpenNetworkSheet, + handleOpenTypeSheet, + handleTitleLayout, + isNetworkFilterActive, + isTypeFilterActive, + networkFilterLabel, + searchQuery, + typeFilterLabel, + ], + ); + + return ( + + + + + + + + + {isTypeSheetOpen ? ( + + ) : null} + + {/* This is a stub for the network filter bottom sheet. We may actually use this version depending on the feedback/requirements */} + + + + ); +}; + +export default ActivityScreen; diff --git a/app/components/Views/ActivityScreen/ActivityScreen.view.test.tsx b/app/components/Views/ActivityScreen/ActivityScreen.view.test.tsx new file mode 100644 index 000000000000..e07f1558a734 --- /dev/null +++ b/app/components/Views/ActivityScreen/ActivityScreen.view.test.tsx @@ -0,0 +1,81 @@ +import '../../../../tests/component-view/mocks'; +import { fireEvent, waitFor } from '@testing-library/react-native'; + +import Routes from '../../../constants/navigation/Routes'; +import { getRouteProbeTestId } from '../../../../tests/component-view/render'; +import { describeForPlatforms } from '../../../../tests/component-view/platform'; +import { + renderActivityScreenView, + renderActivityScreenViewWithRoutes, +} from '../../../../tests/component-view/renderers/activity'; +import { strings } from '../../../../locales/i18n'; +import { ActivityScreenSelectorsIDs } from './ActivityScreen.testIds'; +import { ACTIVITY_TYPE_FILTER_LABEL_KEY } from './components/ActivityTypeFilterSheet'; +import { ActivityTypeFilter } from './types'; + +const optionTestId = (filter: ActivityTypeFilter) => + `${ActivityScreenSelectorsIDs.TYPE_FILTER_OPTION_PREFIX}${filter}`; + +const selectedTypeFilterLabel = (filter: ActivityTypeFilter) => + strings('activity_view.filter_types_selected', { + label: strings(ACTIVITY_TYPE_FILTER_LABEL_KEY[filter]), + }); + +describeForPlatforms('ActivityScreen', () => { + it('updates search text and selected type filter through the real screen controls', async () => { + const { getByPlaceholderText, getByTestId, getByText, findByTestId } = + renderActivityScreenView(); + + const searchInput = getByPlaceholderText( + strings('activity_view.search_placeholder'), + ); + fireEvent.changeText(searchInput, 'swap'); + + expect(searchInput).toHaveProp('value', 'swap'); + + fireEvent.press(getByTestId(ActivityScreenSelectorsIDs.TYPE_FILTER_CHIP)); + + expect( + await findByTestId(ActivityScreenSelectorsIDs.TYPE_FILTER_SHEET), + ).toBeOnTheScreen(); + + fireEvent.press(await findByTestId(optionTestId(ActivityTypeFilter.Money))); + + await waitFor(() => { + expect( + getByText(selectedTypeFilterLabel(ActivityTypeFilter.Money)), + ).toBeOnTheScreen(); + }); + }); + + it('updates the selected network filter through the real network sheet', async () => { + const { getByTestId, getByText, findByText } = renderActivityScreenView(); + + fireEvent.press( + getByTestId(ActivityScreenSelectorsIDs.NETWORK_FILTER_CHIP), + ); + fireEvent.press(await findByText('Linea')); + + await waitFor(() => { + expect( + getByText( + strings('activity_view.filter_network_selected', { + label: 'Linea', + }), + ), + ).toBeOnTheScreen(); + }); + }); + + it('navigates back to home tabs when opened as the root activity route', async () => { + const { getByTestId, findByTestId } = renderActivityScreenViewWithRoutes({ + extraRoutes: [{ name: Routes.HOME_TABS }], + }); + + fireEvent.press(getByTestId(ActivityScreenSelectorsIDs.BACK_BUTTON)); + + expect( + await findByTestId(getRouteProbeTestId(Routes.HOME_TABS)), + ).toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/ActivityScreen/components/ActivityEmptyState/ActivityEmptyState.test.tsx b/app/components/Views/ActivityScreen/components/ActivityEmptyState/ActivityEmptyState.test.tsx new file mode 100644 index 000000000000..bbb99c247569 --- /dev/null +++ b/app/components/Views/ActivityScreen/components/ActivityEmptyState/ActivityEmptyState.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import ActivityEmptyState from './ActivityEmptyState'; +import { ActivityTypeFilter } from '../../types'; +import Routes from '../../../../../constants/navigation/Routes'; +import { ActivityScreenSelectorsIDs } from '../../ActivityScreen.testIds'; +import { useRampNavigation } from '../../../../UI/Ramp/hooks/useRampNavigation'; +import { useMoneyAccountDeposit } from '../../../../UI/Money/hooks/useMoneyAccount'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(() => true), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + Theme: { Dark: 'dark', Light: 'light' }, + useTheme: jest.fn(() => 'light'), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const ReactActual = jest.requireActual('react'); + const { Text, TouchableOpacity, View } = jest.requireActual('react-native'); + + return { + Box: ({ + children, + testID, + }: { + children?: React.ReactNode; + testID?: string; + }) => {children}, + TabEmptyState: ({ + actionButtonText, + description, + onAction, + testID, + }: { + actionButtonText: string; + description: string; + onAction: () => void; + testID?: string; + }) => ( + + {ReactActual.createElement(Text, null, description)} + {ReactActual.createElement(Text, null, actionButtonText)} + + ), + }; +}); + +jest.mock('../../../../../images/activity-empty-dark.svg', () => 'DarkIcon'); +jest.mock('../../../../../images/activity-empty-light.svg', () => 'LightIcon'); + +jest.mock('../../../../UI/Ramp/hooks/useRampNavigation', () => ({ + useRampNavigation: jest.fn(), +})); + +jest.mock('../../../../UI/Money/hooks/useMoneyAccount', () => ({ + useMoneyAccountDeposit: jest.fn(), +})); + +const mockNavigate = jest.fn(); +const mockGoToBuy = jest.fn(); +const mockInitiateDeposit = jest.fn(() => Promise.resolve()); + +describe('ActivityEmptyState', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate }); + (useRampNavigation as jest.Mock).mockReturnValue({ goToBuy: mockGoToBuy }); + (useMoneyAccountDeposit as jest.Mock).mockReturnValue({ + initiateDeposit: mockInitiateDeposit, + }); + }); + + it('renders the empty state container and action copy', () => { + render(); + + expect( + screen.getByTestId(ActivityScreenSelectorsIDs.LIST), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(ActivityScreenSelectorsIDs.EMPTY_STATE), + ).toBeOnTheScreen(); + expect(screen.getByText('Swap tokens')).toBeOnTheScreen(); + }); + + it('routes each CTA to the expected destination', async () => { + render(); + fireEvent.press(screen.getByTestId(ActivityScreenSelectorsIDs.EMPTY_STATE)); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + + cleanup(); + render(); + fireEvent.press(screen.getByTestId(ActivityScreenSelectorsIDs.EMPTY_STATE)); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_LIST, + }); + + cleanup(); + render(); + fireEvent.press(screen.getByTestId(ActivityScreenSelectorsIDs.EMPTY_STATE)); + expect(mockGoToBuy).toHaveBeenCalledTimes(1); + + cleanup(); + render(); + fireEvent.press(screen.getByTestId(ActivityScreenSelectorsIDs.EMPTY_STATE)); + await waitFor(() => expect(mockInitiateDeposit).toHaveBeenCalledTimes(1)); + + cleanup(); + render(); + fireEvent.press(screen.getByTestId(ActivityScreenSelectorsIDs.EMPTY_STATE)); + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + }); +}); diff --git a/app/components/Views/ActivityScreen/components/ActivityEmptyState/ActivityEmptyState.tsx b/app/components/Views/ActivityScreen/components/ActivityEmptyState/ActivityEmptyState.tsx new file mode 100644 index 000000000000..27aaa02fea56 --- /dev/null +++ b/app/components/Views/ActivityScreen/components/ActivityEmptyState/ActivityEmptyState.tsx @@ -0,0 +1,104 @@ +import React, { useCallback } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { + Theme, + useTheme as useDesignSystemTheme, +} from '@metamask/design-system-twrnc-preset'; +import { Box, TabEmptyState } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import Logger from '../../../../../util/Logger'; +import { selectAddressHasTokenBalances } from '../../../../../selectors/tokenBalancesController'; +import ActivityEmptyDarkIcon from '../../../../../images/activity-empty-dark.svg'; +import ActivityEmptyLightIcon from '../../../../../images/activity-empty-light.svg'; +// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog +import { useRampNavigation } from '../../../../UI/Ramp/hooks/useRampNavigation'; +// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog +import { useMoneyAccountDeposit } from '../../../../UI/Money/hooks/useMoneyAccount'; +import { ActivityScreenSelectorsIDs } from '../../ActivityScreen.testIds'; +import { ActivityTypeFilter } from '../../types'; +import { + ActivityEmptyStateAction, + getActivityEmptyState, +} from './empty-states'; + +export interface ActivityEmptyStateProps { + /** Currently selected type filter — drives copy + CTA. */ + typeFilter: ActivityTypeFilter; +} + +/** + * Filter-aware empty state for the Activity screen. Resolves the right copy, + * illustration, and CTA based on the active type filter and whether the + * account has any token balances. + */ +const ActivityEmptyState: React.FC = ({ + typeFilter, +}) => { + const designSystemTheme = useDesignSystemTheme(); + const navigation = useNavigation(); + const { goToBuy } = useRampNavigation(); + const { initiateDeposit } = useMoneyAccountDeposit(); + const hasFunds = useSelector(selectAddressHasTokenBalances); + + const Icon = + designSystemTheme === Theme.Dark + ? ActivityEmptyDarkIcon + : ActivityEmptyLightIcon; + + const emptyState = getActivityEmptyState({ filter: typeFilter, hasFunds }); + + const handleAction = useCallback(() => { + switch (emptyState.action) { + case ActivityEmptyStateAction.Swap: + navigation.navigate(Routes.BRIDGE.ROOT, { + screen: Routes.BRIDGE.BRIDGE_VIEW, + }); + return; + case ActivityEmptyStateAction.AddFunds: + goToBuy(); + return; + case ActivityEmptyStateAction.MakePrediction: + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + return; + case ActivityEmptyStateAction.BrowsePerpsMarkets: + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_LIST, + }); + return; + case ActivityEmptyStateAction.TransferToMoney: + initiateDeposit().catch((error) => { + Logger.error(error as Error, { + message: + '[ActivityEmptyState] Money deposit failed to initiate from empty state', + }); + }); + return; + case ActivityEmptyStateAction.OpenMetamaskCard: + navigation.navigate(Routes.CARD.ROOT); + return; + default: + return; + } + }, [emptyState.action, navigation, goToBuy, initiateDeposit]); + + return ( + + } + description={strings(emptyState.descriptionKey)} + actionButtonText={strings(emptyState.actionLabelKey)} + onAction={handleAction} + /> + + ); +}; + +export default ActivityEmptyState; diff --git a/app/components/Views/ActivityScreen/components/ActivityEmptyState/empty-states.test.ts b/app/components/Views/ActivityScreen/components/ActivityEmptyState/empty-states.test.ts new file mode 100644 index 000000000000..556d566ee618 --- /dev/null +++ b/app/components/Views/ActivityScreen/components/ActivityEmptyState/empty-states.test.ts @@ -0,0 +1,169 @@ +import { + ActivityEmptyStateAction, + getActivityEmptyState, +} from './empty-states'; +import { ActivityTypeFilter } from '../../types'; + +describe('getActivityEmptyState', () => { + describe('All filter', () => { + it('returns funded default copy + Swap action when hasFunds is true', () => { + expect( + getActivityEmptyState({ + filter: ActivityTypeFilter.All, + hasFunds: true, + }), + ).toEqual({ + descriptionKey: 'activity_view.empty_state.default_funded.description', + actionLabelKey: 'activity_view.empty_state.default_funded.action', + action: ActivityEmptyStateAction.Swap, + }); + }); + + it('returns unfunded default copy + AddFunds action when hasFunds is false', () => { + expect( + getActivityEmptyState({ + filter: ActivityTypeFilter.All, + hasFunds: false, + }), + ).toEqual({ + descriptionKey: + 'activity_view.empty_state.default_unfunded.description', + actionLabelKey: 'activity_view.empty_state.default_unfunded.action', + action: ActivityEmptyStateAction.AddFunds, + }); + }); + }); + + describe('Transactions filter', () => { + it('returns funded copy + Swap when hasFunds is true', () => { + expect( + getActivityEmptyState({ + filter: ActivityTypeFilter.Transactions, + hasFunds: true, + }), + ).toEqual({ + descriptionKey: + 'activity_view.empty_state.transactions_funded.description', + actionLabelKey: 'activity_view.empty_state.transactions_funded.action', + action: ActivityEmptyStateAction.Swap, + }); + }); + + it('returns unfunded copy + AddFunds when hasFunds is false', () => { + expect( + getActivityEmptyState({ + filter: ActivityTypeFilter.Transactions, + hasFunds: false, + }), + ).toEqual({ + descriptionKey: + 'activity_view.empty_state.transactions_unfunded.description', + actionLabelKey: + 'activity_view.empty_state.transactions_unfunded.action', + action: ActivityEmptyStateAction.AddFunds, + }); + }); + }); + + describe('Money filter', () => { + it('returns funded copy + TransferToMoney when hasFunds is true', () => { + expect( + getActivityEmptyState({ + filter: ActivityTypeFilter.Money, + hasFunds: true, + }), + ).toEqual({ + descriptionKey: 'activity_view.empty_state.money_funded.description', + actionLabelKey: 'activity_view.empty_state.money_funded.action', + action: ActivityEmptyStateAction.TransferToMoney, + }); + }); + + it('returns unfunded copy + TransferToMoney when hasFunds is false', () => { + expect( + getActivityEmptyState({ + filter: ActivityTypeFilter.Money, + hasFunds: false, + }), + ).toEqual({ + descriptionKey: 'activity_view.empty_state.money_unfunded.description', + actionLabelKey: 'activity_view.empty_state.money_unfunded.action', + action: ActivityEmptyStateAction.TransferToMoney, + }); + }); + }); + + describe('Filters that override hasFunds (themed copy regardless of balance)', () => { + it.each([true, false])( + 'Predictions returns themed copy + MakePrediction (hasFunds=%s)', + (hasFunds) => { + expect( + getActivityEmptyState({ + filter: ActivityTypeFilter.Predictions, + hasFunds, + }), + ).toEqual({ + descriptionKey: 'activity_view.empty_state.predictions.description', + actionLabelKey: 'activity_view.empty_state.predictions.action', + action: ActivityEmptyStateAction.MakePrediction, + }); + }, + ); + + it.each([true, false])( + 'Perps returns themed copy + BrowsePerpsMarkets (hasFunds=%s)', + (hasFunds) => { + expect( + getActivityEmptyState({ + filter: ActivityTypeFilter.Perps, + hasFunds, + }), + ).toEqual({ + descriptionKey: 'activity_view.empty_state.perps.description', + actionLabelKey: 'activity_view.empty_state.perps.action', + action: ActivityEmptyStateAction.BrowsePerpsMarkets, + }); + }, + ); + + it.each([true, false])( + 'BuySell returns themed copy + AddFunds (hasFunds=%s)', + (hasFunds) => { + expect( + getActivityEmptyState({ + filter: ActivityTypeFilter.BuySell, + hasFunds, + }), + ).toEqual({ + descriptionKey: 'activity_view.empty_state.buy_sell.description', + actionLabelKey: 'activity_view.empty_state.buy_sell.action', + action: ActivityEmptyStateAction.AddFunds, + }); + }, + ); + + it.each([true, false])( + 'MetamaskCard returns themed copy + OpenMetamaskCard (hasFunds=%s)', + (hasFunds) => { + expect( + getActivityEmptyState({ + filter: ActivityTypeFilter.MetamaskCard, + hasFunds, + }), + ).toEqual({ + descriptionKey: 'activity_view.empty_state.metamask_card.description', + actionLabelKey: 'activity_view.empty_state.metamask_card.action', + action: ActivityEmptyStateAction.OpenMetamaskCard, + }); + }, + ); + }); + + it('falls back to the All branch for an unknown filter value', () => { + const result = getActivityEmptyState({ + filter: 'something-unknown' as ActivityTypeFilter, + hasFunds: true, + }); + expect(result.action).toBe(ActivityEmptyStateAction.Swap); + }); +}); diff --git a/app/components/Views/ActivityScreen/components/ActivityEmptyState/empty-states.ts b/app/components/Views/ActivityScreen/components/ActivityEmptyState/empty-states.ts new file mode 100644 index 000000000000..fad62ef3e092 --- /dev/null +++ b/app/components/Views/ActivityScreen/components/ActivityEmptyState/empty-states.ts @@ -0,0 +1,120 @@ +import { ActivityTypeFilter } from '../../types'; + +/** + * Discrete CTA actions used by the Activity screen's empty states. The + * `ActivityEmptyState` component resolves each action to its real navigation + * handler — this enum keeps the config table free of navigation imports. + */ +export enum ActivityEmptyStateAction { + Swap = 'swap', + AddFunds = 'addFunds', + MakePrediction = 'makePrediction', + BrowsePerpsMarkets = 'browsePerpsMarkets', + TransferToMoney = 'transferToMoney', + OpenMetamaskCard = 'openMetamaskCard', +} + +export interface ActivityEmptyStateConfig { + /** i18n key for the empty-state description. */ + descriptionKey: string; + /** i18n key for the CTA button label. */ + actionLabelKey: string; + /** Which CTA the screen should dispatch. */ + action: ActivityEmptyStateAction; +} + +interface GetEmptyStateArgs { + filter: ActivityTypeFilter; + hasFunds: boolean; +} + +/** + * Returns the empty-state config for the given filter + funds state. + * + * Filter empty states override the no-funds default so a user filtering by + * Predictions still sees "your first prediction could be your best" even if + * their wallet is empty. + */ +export function getActivityEmptyState({ + filter, + hasFunds, +}: GetEmptyStateArgs): ActivityEmptyStateConfig { + switch (filter) { + case ActivityTypeFilter.Predictions: + return { + descriptionKey: 'activity_view.empty_state.predictions.description', + actionLabelKey: 'activity_view.empty_state.predictions.action', + action: ActivityEmptyStateAction.MakePrediction, + }; + + case ActivityTypeFilter.Perps: + return { + descriptionKey: 'activity_view.empty_state.perps.description', + actionLabelKey: 'activity_view.empty_state.perps.action', + action: ActivityEmptyStateAction.BrowsePerpsMarkets, + }; + + case ActivityTypeFilter.Transactions: + return hasFunds + ? { + descriptionKey: + 'activity_view.empty_state.transactions_funded.description', + actionLabelKey: + 'activity_view.empty_state.transactions_funded.action', + action: ActivityEmptyStateAction.Swap, + } + : { + descriptionKey: + 'activity_view.empty_state.transactions_unfunded.description', + actionLabelKey: + 'activity_view.empty_state.transactions_unfunded.action', + action: ActivityEmptyStateAction.AddFunds, + }; + + case ActivityTypeFilter.BuySell: + return { + descriptionKey: 'activity_view.empty_state.buy_sell.description', + actionLabelKey: 'activity_view.empty_state.buy_sell.action', + action: ActivityEmptyStateAction.AddFunds, + }; + + case ActivityTypeFilter.Money: + return hasFunds + ? { + descriptionKey: + 'activity_view.empty_state.money_funded.description', + actionLabelKey: 'activity_view.empty_state.money_funded.action', + action: ActivityEmptyStateAction.TransferToMoney, + } + : { + descriptionKey: + 'activity_view.empty_state.money_unfunded.description', + actionLabelKey: 'activity_view.empty_state.money_unfunded.action', + action: ActivityEmptyStateAction.TransferToMoney, + }; + + case ActivityTypeFilter.MetamaskCard: + // TODO: confirm card empty state copy with product + return { + descriptionKey: 'activity_view.empty_state.metamask_card.description', + actionLabelKey: 'activity_view.empty_state.metamask_card.action', + action: ActivityEmptyStateAction.OpenMetamaskCard, + }; + + case ActivityTypeFilter.All: + default: + return hasFunds + ? { + descriptionKey: + 'activity_view.empty_state.default_funded.description', + actionLabelKey: 'activity_view.empty_state.default_funded.action', + action: ActivityEmptyStateAction.Swap, + } + : { + descriptionKey: + 'activity_view.empty_state.default_unfunded.description', + actionLabelKey: 'activity_view.empty_state.default_unfunded.action', + action: ActivityEmptyStateAction.AddFunds, + }; + } +} diff --git a/app/components/Views/ActivityScreen/components/ActivityEmptyState/index.ts b/app/components/Views/ActivityScreen/components/ActivityEmptyState/index.ts new file mode 100644 index 000000000000..8b451bbd7532 --- /dev/null +++ b/app/components/Views/ActivityScreen/components/ActivityEmptyState/index.ts @@ -0,0 +1,7 @@ +export { default } from './ActivityEmptyState'; +export type { ActivityEmptyStateProps } from './ActivityEmptyState'; +export { + ActivityEmptyStateAction, + getActivityEmptyState, + type ActivityEmptyStateConfig, +} from './empty-states'; diff --git a/app/components/Views/ActivityScreen/components/ActivityTypeFilterSheet/ActivityTypeFilterSheet.test.tsx b/app/components/Views/ActivityScreen/components/ActivityTypeFilterSheet/ActivityTypeFilterSheet.test.tsx new file mode 100644 index 000000000000..c5d250cd2ea3 --- /dev/null +++ b/app/components/Views/ActivityScreen/components/ActivityTypeFilterSheet/ActivityTypeFilterSheet.test.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import ActivityTypeFilterSheet, { + ACTIVITY_TYPE_FILTER_LABEL_KEY, +} from './ActivityTypeFilterSheet'; +import { ActivityScreenSelectorsIDs } from '../../ActivityScreen.testIds'; +import { ACTIVITY_TYPE_FILTER_ORDER, ActivityTypeFilter } from '../../types'; + +// Capture the latest BottomSheet ref's close call so we can assert the sheet +// closes itself after a selection. +const mockOnCloseBottomSheet = jest.fn(); + +jest.mock('@metamask/design-system-react-native', () => { + const ReactNative = jest.requireActual('react-native'); + const React = jest.requireActual('react'); + + return { + IconColor: { IconDefault: 'IconDefault' }, + IconName: { Check: 'Check' }, + IconSize: { Md: 'Md' }, + TextVariant: { BodyMd: 'BodyMd' }, + Box: ({ + children, + testID, + }: { + children?: React.ReactNode; + testID?: string; + }) => {children}, + Text: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + Icon: ({ name, testID }: { name?: string; testID?: string }) => ( + + ), + BottomSheet: React.forwardRef( + ( + { + children, + onClose, + testID, + }: { + children?: React.ReactNode; + onClose?: () => void; + testID?: string; + }, + ref: React.Ref<{ onCloseBottomSheet: () => void }>, + ) => { + React.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + })); + return ( + + {children} + + + ); + }, + ), + BottomSheetHeader: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ + style: (..._args: unknown[]) => ({}), + }), +})); + +const renderSheet = ( + overrides: Partial> = {}, +) => + render( + , + ); + +const optionTestId = (filter: ActivityTypeFilter) => + `${ActivityScreenSelectorsIDs.TYPE_FILTER_OPTION_PREFIX}${filter}`; + +describe('ActivityTypeFilterSheet', () => { + beforeEach(() => { + mockOnCloseBottomSheet.mockClear(); + }); + + it('shows a check icon only on the selected row', () => { + renderSheet({ selected: ActivityTypeFilter.Perps }); + + const selectedRow = screen.getByTestId( + optionTestId(ActivityTypeFilter.Perps), + ); + expect(selectedRow).toHaveProp('accessibilityState', { selected: true }); + + const unselectedRow = screen.getByTestId( + optionTestId(ActivityTypeFilter.Transactions), + ); + expect(unselectedRow).toHaveProp('accessibilityState', { selected: false }); + }); + + it('calls onSelect, closes the sheet, and forwards onClose so the parent can re-open it', () => { + const onSelect = jest.fn(); + const onClose = jest.fn(); + renderSheet({ onSelect, onClose }); + + fireEvent.press(screen.getByTestId(optionTestId(ActivityTypeFilter.Money))); + + expect(onSelect).toHaveBeenCalledWith(ActivityTypeFilter.Money); + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + expect(mockOnCloseBottomSheet).toHaveBeenCalledWith(onClose); + }); + + it('invokes onClose when the sheet dispatches its close event', () => { + const onClose = jest.fn(); + renderSheet({ onClose }); + + fireEvent.press(screen.getByTestId('mock-bottom-sheet-close')); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('exports an i18n label key for every filter', () => { + for (const filter of ACTIVITY_TYPE_FILTER_ORDER) { + expect(ACTIVITY_TYPE_FILTER_LABEL_KEY[filter]).toMatch( + /^activity_view\.type_filter\./, + ); + } + }); +}); diff --git a/app/components/Views/ActivityScreen/components/ActivityTypeFilterSheet/ActivityTypeFilterSheet.tsx b/app/components/Views/ActivityScreen/components/ActivityTypeFilterSheet/ActivityTypeFilterSheet.tsx new file mode 100644 index 000000000000..abf7177dd5f4 --- /dev/null +++ b/app/components/Views/ActivityScreen/components/ActivityTypeFilterSheet/ActivityTypeFilterSheet.tsx @@ -0,0 +1,101 @@ +import React, { useCallback, useRef } from 'react'; +import { Pressable } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + BottomSheet, + BottomSheetHeader, + type BottomSheetRef, + Box, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import { ACTIVITY_TYPE_FILTER_ORDER, ActivityTypeFilter } from '../../types'; +import { ActivityScreenSelectorsIDs } from '../../ActivityScreen.testIds'; + +export const ACTIVITY_TYPE_FILTER_LABEL_KEY: Record< + ActivityTypeFilter, + string +> = { + // `All` is not currently selectable from the sheet — see the TODO above + // `ACTIVITY_TYPE_FILTER_ORDER` in ../../types.ts. Kept for type completeness + // and so chip labels keep resolving if the flag is re-enabled. + [ActivityTypeFilter.All]: 'activity_view.type_filter.all', + [ActivityTypeFilter.Transactions]: 'activity_view.type_filter.transactions', + [ActivityTypeFilter.BuySell]: 'activity_view.type_filter.buy_sell', + [ActivityTypeFilter.Perps]: 'activity_view.type_filter.perps', + [ActivityTypeFilter.Predictions]: 'activity_view.type_filter.predictions', + [ActivityTypeFilter.MetamaskCard]: 'activity_view.type_filter.metamask_card', + [ActivityTypeFilter.Money]: 'activity_view.type_filter.money', +}; + +export interface ActivityTypeFilterSheetProps { + selected: ActivityTypeFilter; + onSelect: (filter: ActivityTypeFilter) => void; + onClose: () => void; +} + +const ActivityTypeFilterSheet: React.FC = ({ + selected, + onSelect, + onClose, +}) => { + const tw = useTailwind(); + const sheetRef = useRef(null); + + const handleSelect = useCallback( + (filter: ActivityTypeFilter) => { + onSelect(filter); + sheetRef.current?.onCloseBottomSheet(onClose); + }, + [onSelect, onClose], + ); + + return ( + + + {strings('activity_view.type_filter.title')} + + + + {ACTIVITY_TYPE_FILTER_ORDER.map((filter) => { + const isSelected = filter === selected; + return ( + handleSelect(filter)} + style={tw.style( + 'flex-row items-center justify-between px-4 py-4', + isSelected && 'bg-muted', + )} + testID={`${ActivityScreenSelectorsIDs.TYPE_FILTER_OPTION_PREFIX}${filter}`} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} + > + + {strings(ACTIVITY_TYPE_FILTER_LABEL_KEY[filter])} + + {isSelected ? ( + + ) : null} + + ); + })} + + + ); +}; + +export default ActivityTypeFilterSheet; diff --git a/app/components/Views/ActivityScreen/components/ActivityTypeFilterSheet/index.ts b/app/components/Views/ActivityScreen/components/ActivityTypeFilterSheet/index.ts new file mode 100644 index 000000000000..0687597fb408 --- /dev/null +++ b/app/components/Views/ActivityScreen/components/ActivityTypeFilterSheet/index.ts @@ -0,0 +1,5 @@ +export { default } from './ActivityTypeFilterSheet'; +export { + ACTIVITY_TYPE_FILTER_LABEL_KEY, + type ActivityTypeFilterSheetProps, +} from './ActivityTypeFilterSheet'; diff --git a/app/components/Views/ActivityScreen/components/AssetListControlBar/AssetListControlBar.tsx b/app/components/Views/ActivityScreen/components/AssetListControlBar/AssetListControlBar.tsx new file mode 100644 index 000000000000..effb006fb812 --- /dev/null +++ b/app/components/Views/ActivityScreen/components/AssetListControlBar/AssetListControlBar.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { ScrollView } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + ButtonBase, + ButtonBaseSize, + IconColor, + IconName, + TextColor, +} from '@metamask/design-system-react-native'; +import { ActivityScreenSelectorsIDs } from '../../ActivityScreen.testIds'; + +export interface AssetListControlBarProps { + networkLabel: string; + isNetworkFilterActive: boolean; + onNetworkPress: () => void; + typeLabel: string; + isTypeFilterActive: boolean; + onTypePress: () => void; +} + +/** + * Horizontally-scrollable filter chip row for the Activity screen — mirrors + * the extension's `AssetListControlBar` pattern. Pure presentational: the + * parent screen owns filter state, label resolution, and sheet rendering. + * + * Sheets must remain siblings of the scroll content (not nested inside it), + * since the underlying `component-library` `BottomSheet` does not portal. + */ +const AssetListControlBar: React.FC = ({ + networkLabel, + isNetworkFilterActive, + onNetworkPress, + typeLabel, + isTypeFilterActive, + onTypePress, +}) => { + const tw = useTailwind(); + + return ( + + + {networkLabel} + + + {typeLabel} + + + ); +}; + +export default AssetListControlBar; diff --git a/app/components/Views/ActivityScreen/components/AssetListControlBar/index.ts b/app/components/Views/ActivityScreen/components/AssetListControlBar/index.ts new file mode 100644 index 000000000000..2f3178a8d800 --- /dev/null +++ b/app/components/Views/ActivityScreen/components/AssetListControlBar/index.ts @@ -0,0 +1,2 @@ +export { default } from './AssetListControlBar'; +export type { AssetListControlBarProps } from './AssetListControlBar'; diff --git a/app/components/Views/ActivityScreen/index.ts b/app/components/Views/ActivityScreen/index.ts new file mode 100644 index 000000000000..b2c08074921e --- /dev/null +++ b/app/components/Views/ActivityScreen/index.ts @@ -0,0 +1 @@ +export { default } from './ActivityScreen'; diff --git a/app/components/Views/ActivityScreen/types.ts b/app/components/Views/ActivityScreen/types.ts new file mode 100644 index 000000000000..488de2956945 --- /dev/null +++ b/app/components/Views/ActivityScreen/types.ts @@ -0,0 +1,109 @@ +import type { ActivityKind } from '../../../util/activity-adapters'; + +export type { ActivityKind }; + +/** + * Top-level "Types" filter buckets shown in the Activity screen filter sheet. + * Each bucket maps to a set of `ActivityKind`s via `ACTIVITY_TYPE_FILTER_KINDS`. + */ +export enum ActivityTypeFilter { + All = 'all', + Transactions = 'transactions', + BuySell = 'buySell', + Perps = 'perps', + Predictions = 'predictions', + MetamaskCard = 'metamaskCard', + Money = 'money', +} + +/** + * Bucket → kinds mapping. `null` means "no filtering" (matches everything). + * + * TODO: refine bucket membership with product/design once adapters land — + * Money/MetaMask Card definitions are best-guess based on the Figma options. + */ +export const ACTIVITY_TYPE_FILTER_KINDS: Record< + ActivityTypeFilter, + ReadonlySet | null +> = { + [ActivityTypeFilter.All]: null, + [ActivityTypeFilter.Transactions]: new Set([ + 'send', + 'receive', + 'swap', + 'swapIncomplete', + 'bridge', + 'wrap', + 'unwrap', + 'convert', + 'approveSpendingCap', + 'increaseSpendingCap', + 'revokeSpendingCap', + 'contractInteraction', + 'contractDeployment', + 'smartAccountUpgrade', + 'nftMint', + ]), + [ActivityTypeFilter.BuySell]: new Set([ + 'buy', + 'sell', + 'deposit', + ]), + [ActivityTypeFilter.Perps]: new Set([ + 'perpsAddFunds', + 'perpsWithdrawFunds', + 'perpsOpenLong', + 'perpsCloseLong', + 'perpsCloseLongLiquidated', + 'perpsCloseLongStopLoss', + 'perpsCloseLongTakeProfit', + 'perpsOpenShort', + 'perpsCloseShort', + 'perpsCloseShortLiquidated', + 'perpsCloseShortStopLoss', + 'perpsCloseShortTakeProfit', + 'perpsPaidFundingFees', + 'perpsReceivedFundingFees', + 'marketShort', + 'stopMarketCloseShort', + 'marketCloseShort', + ]), + [ActivityTypeFilter.Predictions]: new Set([ + 'predictionsAddFunds', + 'predictionsWithdrawFunds', + 'predictionClaimWinnings', + 'predictionCashedOut', + 'predictionPlaced', + ]), + [ActivityTypeFilter.MetamaskCard]: new Set([]), + [ActivityTypeFilter.Money]: new Set([ + 'claim', + 'claimMusdBonus', + 'lendingDeposit', + 'lendingWithdrawal', + ]), +}; + +// TODO: re-enable `ActivityTypeFilter.All` once the data sources are unified +// (deduped, time-sorted across EVM tx controller, non-EVM keyrings, perps, +// predict, etc.). Until then we ship type-only filtering — see TMCU thread. +export const ACTIVITY_TYPE_FILTER_ORDER: ActivityTypeFilter[] = [ + // ActivityTypeFilter.All, + ActivityTypeFilter.Transactions, + ActivityTypeFilter.BuySell, + ActivityTypeFilter.Perps, + ActivityTypeFilter.Predictions, + ActivityTypeFilter.MetamaskCard, + ActivityTypeFilter.Money, +]; + +export function activityKindMatchesTypeFilter( + kind: ActivityKind, + filter: ActivityTypeFilter, +): boolean { + const allowed = ACTIVITY_TYPE_FILTER_KINDS[filter]; + if (allowed === null) { + return true; + } + return allowed.has(kind); +} diff --git a/app/components/Views/ActivityView/ActivityView.styles.js b/app/components/Views/ActivityView/ActivityView.styles.js new file mode 100644 index 000000000000..179886a5a965 --- /dev/null +++ b/app/components/Views/ActivityView/ActivityView.styles.js @@ -0,0 +1,39 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = ({ theme }) => { + const { colors } = theme; + + return StyleSheet.create({ + tabWrapper: { + flex: 1, + backgroundColor: colors.background.default, + }, + controlButtonOuterWrapper: { + flexDirection: 'row', + width: '100%', + justifyContent: 'space-between', + alignItems: 'center', + marginVertical: 8, + paddingHorizontal: 16, + }, + controlButton: { + backgroundColor: colors.background.default, + borderColor: colors.border.muted, + borderWidth: 1, + borderRadius: 8, + maxWidth: '80%', + paddingHorizontal: 12, + }, + networkManagerWrapper: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + titleText: { + color: colors.text.default, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/Views/ActivityView/ActivityView.view.test.tsx b/app/components/Views/ActivityView/ActivityView.view.test.tsx new file mode 100644 index 000000000000..0ad3e038840b --- /dev/null +++ b/app/components/Views/ActivityView/ActivityView.view.test.tsx @@ -0,0 +1,60 @@ +import '../../../../tests/component-view/mocks'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import { strings } from '../../../../locales/i18n'; +import type { DeepPartial } from '../../../util/test/renderWithProvider'; +import type { RootState } from '../../../reducers'; +import Routes from '../../../constants/navigation/Routes'; +import { describeForPlatforms } from '../../../../tests/component-view/platform'; +import { + renderActivityView, + renderActivityViewWithRoutes, +} from '../../../../tests/component-view/renderers/activity'; +import { getRouteProbeTestId } from '../../../../tests/component-view/render'; +// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog +import { WalletViewSelectorsIDs } from '../Wallet/WalletView.testIds'; + +const legacyActivityViewState = { + engine: { + backgroundState: { + NetworkEnablementController: { + enabledNetworkMap: { + eip155: { '0x1': true }, + solana: {}, + }, + }, + }, + }, +} as unknown as DeepPartial; + +describeForPlatforms('ActivityView', () => { + it('opens network manager when the legacy network filter is pressed', async () => { + const { getByTestId, findByTestId } = renderActivityViewWithRoutes({ + overrides: legacyActivityViewState, + extraRoutes: [{ name: Routes.MODAL.ROOT_MODAL_FLOW }], + }); + + const networkFilter = await waitFor(() => + getByTestId(WalletViewSelectorsIDs.TOKEN_NETWORK_FILTER), + ); + + fireEvent.press(networkFilter); + + expect( + await findByTestId(getRouteProbeTestId(Routes.MODAL.ROOT_MODAL_FLOW)), + ).toBeOnTheScreen(); + }); + + it('types in search after the redesigned screen lazy-loads', async () => { + const { findByPlaceholderText } = renderActivityView({ + redesignEnabled: true, + }); + + const searchInput = await waitFor(() => + findByPlaceholderText(strings('activity_view.search_placeholder')), + ); + + fireEvent.changeText(searchInput, 'swap'); + + expect(searchInput).toHaveProp('value', 'swap'); + }); +}); diff --git a/app/components/Views/ActivityView/index.js b/app/components/Views/ActivityView/index.js index fc607753aa74..a3a06deff430 100644 --- a/app/components/Views/ActivityView/index.js +++ b/app/components/Views/ActivityView/index.js @@ -1,6 +1,6 @@ import { useFocusEffect, useNavigation } from '@react-navigation/native'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { BackHandler, StyleSheet, View } from 'react-native'; +import { BackHandler, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useSelector } from 'react-redux'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog @@ -47,48 +47,23 @@ import { useStyles } from '../../hooks/useStyles'; import ErrorBoundary from '../ErrorBoundary'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog import UnifiedTransactionsView from '../UnifiedTransactionsView/UnifiedTransactionsView'; +import styleSheet from './ActivityView.styles'; +import { selectIsActivityRedesignEnabled } from './selectors/featureFlags'; -const createStyles = (params) => { - const { theme } = params; - const { colors } = theme; - return StyleSheet.create({ - tabWrapper: { - flex: 1, - backgroundColor: colors.background.default, - }, - controlButtonOuterWrapper: { - flexDirection: 'row', - width: '100%', - justifyContent: 'space-between', - alignItems: 'center', - marginVertical: 8, - paddingHorizontal: 16, - }, - controlButton: { - backgroundColor: colors.background.default, - borderColor: colors.border.muted, - borderWidth: 1, - borderRadius: 8, - maxWidth: '80%', - paddingHorizontal: 12, - }, - networkManagerWrapper: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - gap: 4, - }, - titleText: { - color: colors.text.default, - }, - }); -}; +// Lazily loaded so the redesigned Activity screen and its dependencies are not +// evaluated when `tmcuActivityRedesignEnabled` is off, keeping the legacy path +// fully isolated. +const ActivityScreen = React.lazy( + () => + // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog + import('../ActivityScreen/ActivityScreen'), +); -const ActivityView = () => { +const LegacyActivityView = () => { const { colors } = useTheme(); const tw = useTailwind(); - const { styles } = useStyles(createStyles); + const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); @@ -325,4 +300,18 @@ const ActivityView = () => { ); }; +const ActivityView = () => { + const isActivityRedesignEnabled = useSelector( + selectIsActivityRedesignEnabled, + ); + + return isActivityRedesignEnabled ? ( + + + + ) : ( + + ); +}; + export default ActivityView; diff --git a/app/components/Views/ActivityView/index.test.tsx b/app/components/Views/ActivityView/index.test.tsx index 0925538bf3a9..5b68eb3456cf 100644 --- a/app/components/Views/ActivityView/index.test.tsx +++ b/app/components/Views/ActivityView/index.test.tsx @@ -4,7 +4,7 @@ import { BackHandler } from 'react-native'; import { backgroundState } from '../../../util/test/initial-root-state'; import renderWithProvider from '../../../util/test/renderWithProvider'; import { createStackNavigator } from '@react-navigation/stack'; -import { cleanup, fireEvent } from '@testing-library/react-native'; +import { cleanup, fireEvent, waitFor } from '@testing-library/react-native'; // eslint-disable-next-line import-x/no-namespace import * as networkManagerUtils from '../../UI/NetworkManager'; import { useCurrentNetworkInfo } from '../../hooks/useCurrentNetworkInfo'; diff --git a/app/components/Views/ActivityView/selectors/featureFlags/index.ts b/app/components/Views/ActivityView/selectors/featureFlags/index.ts new file mode 100644 index 000000000000..f0f1cfee92fe --- /dev/null +++ b/app/components/Views/ActivityView/selectors/featureFlags/index.ts @@ -0,0 +1,8 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '../../../../../selectors/featureFlagController'; + +export const selectIsActivityRedesignEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags): boolean => + remoteFeatureFlags.tmcuActivityRedesignEnabled === true, +); diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.test.tsx index a7aa8048a366..1c95d28c1561 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.test.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.test.tsx @@ -30,7 +30,7 @@ describe('TopTraderCard', () => { , ); expect(screen.getByText('sniperliquid')).toBeOnTheScreen(); - expect(screen.getByText('+$963K')).toBeOnTheScreen(); + expect(screen.getByText('+$963.1K')).toBeOnTheScreen(); expect(screen.getByText(/30D/)).toBeOnTheScreen(); }); @@ -186,6 +186,6 @@ describe('TopTraderCard', () => { onFollowPress={mockOnFollowPress} />, ); - expect(screen.getByText('-$500')).toBeOnTheScreen(); + expect(screen.getByText('-$500.00')).toBeOnTheScreen(); }); }); diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.tsx index 149820ea666f..3b1e9d67c3a7 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.tsx @@ -17,7 +17,8 @@ import React from 'react'; import { Image, TouchableOpacity } from 'react-native'; import { strings } from '../../../../../../../locales/i18n'; import type { TopTrader } from '../types'; -import { formatPnl } from '../utils/formatPnl'; +// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog +import { formatSignedAbbreviatedUsd } from '../../../../SocialLeaderboard/utils/formatters'; import { hasRealAvatar } from '../utils/avatarFallback'; export interface TopTraderCardProps { @@ -55,7 +56,7 @@ const TopTraderCard: React.FC = ({ }) => { const tw = useTailwind(); - const pnlText = formatPnl(trader.pnlValue); + const pnlText = formatSignedAbbreviatedUsd(trader.pnlValue); const isPnlPositive = trader.pnlValue >= 0; return ( diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.test.tsx index af7c54d01d6f..ee347a5a6183 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.test.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.test.tsx @@ -34,7 +34,7 @@ describe('TraderRow', () => { expect(screen.getByText('1')).toBeOnTheScreen(); expect(screen.getByText('sniperliquid')).toBeOnTheScreen(); expect(screen.getByText('+43.0%')).toBeOnTheScreen(); - expect(screen.getByText('+$963K')).toBeOnTheScreen(); + expect(screen.getByText('+$963.1K')).toBeOnTheScreen(); }); it('renders avatar image when avatarUri is present', () => { @@ -132,7 +132,7 @@ describe('TraderRow', () => { , ); expect(screen.getByText('-15.3%')).toBeOnTheScreen(); - expect(screen.getByText('-$500')).toBeOnTheScreen(); + expect(screen.getByText('-$500.00')).toBeOnTheScreen(); }); it('uses custom testID when provided', () => { diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.tsx index a17969340012..293b87af262b 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.tsx +++ b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.tsx @@ -20,7 +20,8 @@ import { Image, TouchableOpacity } from 'react-native'; import { strings } from '../../../../../../../locales/i18n'; import { TopRankAvatar, TopRankIndicator } from '../topRank'; import type { TopTrader } from '../types'; -import { formatPnl } from '../utils/formatPnl'; +// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog +import { formatSignedAbbreviatedUsd } from '../../../../SocialLeaderboard/utils/formatters'; import { hasRealAvatar } from '../utils/avatarFallback'; const AVATAR_SIZE = 40; @@ -56,7 +57,7 @@ const TraderRow: React.FC = ({ const roiSign = trader.percentageChange >= 0 ? '+' : ''; const roiText = `${roiSign}${trader.percentageChange.toFixed(1)}%`; - const pnlText = formatPnl(trader.pnlValue); + const pnlText = formatSignedAbbreviatedUsd(trader.pnlValue); const isPnlPositive = trader.pnlValue >= 0; const isRoiPositive = trader.percentageChange >= 0; diff --git a/app/components/Views/Homepage/Sections/TopTraders/utils/formatPnl.test.ts b/app/components/Views/Homepage/Sections/TopTraders/utils/formatPnl.test.ts deleted file mode 100644 index 1fdb5f7498d6..000000000000 --- a/app/components/Views/Homepage/Sections/TopTraders/utils/formatPnl.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { formatPnl } from './formatPnl'; - -describe('formatPnl', () => { - describe('positive values', () => { - it('formats a sub-thousand value with no comma', () => { - expect(formatPnl(500)).toBe('+$500'); - }); - - it('formats a sub-thousand value with rounding', () => { - expect(formatPnl(499.7)).toBe('+$500'); - }); - - it('formats a thousands value with K suffix', () => { - expect(formatPnl(963_000)).toBe('+$963K'); - }); - - it('formats a thousands value that needs a comma with K suffix', () => { - expect(formatPnl(1_200_000)).toBe('+$1,200K'); - }); - - it('formats a millions value with K suffix and comma', () => { - expect(formatPnl(1_500_000)).toBe('+$1,500K'); - }); - - it('formats zero as positive', () => { - expect(formatPnl(0)).toBe('+$0'); - }); - }); - - describe('negative values', () => { - it('formats a sub-thousand negative value', () => { - expect(formatPnl(-500)).toBe('-$500'); - }); - - it('formats a negative thousands value with K suffix', () => { - expect(formatPnl(-963_000)).toBe('-$963K'); - }); - - it('formats a negative value that needs a comma', () => { - expect(formatPnl(-1_200_000)).toBe('-$1,200K'); - }); - }); - - describe('boundary values', () => { - it('formats exactly 1,000', () => { - expect(formatPnl(1_000)).toBe('+$1K'); - }); - - it('formats just below 1,000', () => { - expect(formatPnl(999)).toBe('+$999'); - }); - - it('formats 999.5 as +$1K (rounds up to 1000 before branching)', () => { - expect(formatPnl(999.5)).toBe('+$1K'); - }); - - it('formats -999.7 as -$1K (rounds up to 1000 before branching)', () => { - expect(formatPnl(-999.7)).toBe('-$1K'); - }); - - it('formats exactly 1,000,000', () => { - expect(formatPnl(1_000_000)).toBe('+$1,000K'); - }); - - it('rounds fractional values correctly', () => { - expect(formatPnl(1_499)).toBe('+$1K'); - expect(formatPnl(1_500)).toBe('+$2K'); - }); - }); -}); diff --git a/app/components/Views/Homepage/Sections/TopTraders/utils/formatPnl.ts b/app/components/Views/Homepage/Sections/TopTraders/utils/formatPnl.ts deleted file mode 100644 index 683545e56027..000000000000 --- a/app/components/Views/Homepage/Sections/TopTraders/utils/formatPnl.ts +++ /dev/null @@ -1,21 +0,0 @@ -// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog -import { addThousandsSeparator } from '../../../../SocialLeaderboard/utils/numberFormatting'; - -/** - * Format a raw PnL number into a compact display string. - * - * Uses manual rounding instead of `toLocaleString` options because - * React Native's Hermes engine does not honour `maximumFractionDigits`. - * - * @param value - Raw USD PnL value. - * @returns Formatted string like "+$963K" or "-$1,200". - */ -export function formatPnl(value: number): string { - const rounded = Math.round(Math.abs(value)); - const sign = value >= 0 ? '+' : '-'; - - if (rounded >= 1_000) { - return `${sign}$${addThousandsSeparator(String(Math.round(rounded / 1_000)))}K`; - } - return `${sign}$${addThousandsSeparator(String(rounded))}`; -} diff --git a/app/components/Views/Settings/GeneralSettings/index.js b/app/components/Views/Settings/GeneralSettings/index.js index 9b19763994ac..1f645ecc3e71 100644 --- a/app/components/Views/Settings/GeneralSettings/index.js +++ b/app/components/Views/Settings/GeneralSettings/index.js @@ -48,6 +48,9 @@ import { colors as staticColors } from '../../../../styles/common'; import { enablePushNotifications } from '../../../../actions/notification/helpers'; import { selectIsMetaMaskPushNotificationsEnabled } from '../../../../selectors/notifications'; +export const GENERAL_SETTINGS_CURRENCY_SELECTOR = + 'general-settings-currency-selector'; + const diameter = 40; const spacing = 8; @@ -228,8 +231,13 @@ class Settings extends PureComponent { }; selectCurrency = async (currency) => { - const { CurrencyRateController } = Engine.context; + const { CurrencyRateController, AssetsController } = Engine.context; CurrencyRateController.setCurrentCurrency(currency); + // When the `assetsUnifyState` flag is enabled, the UI reads the active + // currency from AssetsController.selectedCurrency rather than from + // CurrencyRateController, so it must be updated here too. Otherwise the + // selection silently no-ops and the displayed currency stays unchanged. + AssetsController?.setSelectedCurrency?.(currency); updateUserTraitsWithCurrentCurrency(currency); }; @@ -358,6 +366,7 @@ class Settings extends PureComponent { ({ + context: { + CurrencyRateController: { + setCurrentCurrency: (...args: unknown[]) => + mockSetCurrentCurrency(...args), + }, + AssetsController: { + setSelectedCurrency: (...args: unknown[]) => + mockSetSelectedCurrency(...args), + }, + }, +})); + jest.mock('../../../../core/Analytics'); jest.mock('../../../../util/analytics/analytics', () => ({ @@ -102,6 +119,27 @@ describe('GeneralSettings', () => { }); }); +describe('GeneralSettings currency selection', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('updates both CurrencyRateController and AssetsController when a currency is selected', () => { + const { getByTestId, getByText } = renderComponent(); + + fireEvent.press(getByTestId(GENERAL_SETTINGS_CURRENCY_SELECTOR)); + fireEvent.press(getByText('EUR - Euro')); + + expect(mockSetCurrentCurrency).toHaveBeenCalledWith('eur'); + expect(mockSetSelectedCurrency).toHaveBeenCalledWith('eur'); + }); +}); + describe('updateUserTraitsWithCurrentCurrency', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx index 86459102772d..90019a90f12b 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx @@ -72,6 +72,7 @@ const TraderPositionView = () => { position: positionParam, positionId, source: sourceParam, + notificationSubtype, } = route.params; const { track } = useSocialLeaderboardAnalytics(); @@ -217,8 +218,17 @@ const TraderPositionView = () => { track(MetaMetricsEvents.SOCIAL_FOLLOW_TRADING_TOKEN_SCREEN_VIEWED, { ...followTradingTokenContext, [SocialLeaderboardEventProperties.SOURCE]: followTradingTokenSource, + ...(notificationSubtype !== undefined && { + [SocialLeaderboardEventProperties.NOTIFICATION_SUBTYPE]: + notificationSubtype, + }), }); - }, [followTradingTokenContext, followTradingTokenSource, track]); + }, [ + followTradingTokenContext, + followTradingTokenSource, + notificationSubtype, + track, + ]); // Keep a stable ref to the latest context so the dismissed-cleanup effect // can read the current value without listing it as a dependency. diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmount.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmount.tsx index 05bc4c635904..9267cd7fc2d9 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmount.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyAmount.tsx @@ -8,7 +8,7 @@ import { useQuickBuyContext } from './useQuickBuyContext'; const QuickBuyAmount: React.FC = () => { const { amountDisplayMode, - usdAmount, + fiatAmountLabel, target, tradeMode, hasSourcePrice, @@ -39,7 +39,7 @@ const QuickBuyAmount: React.FC = () => { return ( { , ); expect(screen.getByText('$50')).toBeOnTheScreen(); }); - it('shows $0 placeholder when usdAmount is empty', () => { - render(); + it('renders a non-USD preformatted fiat label as primary in fiat mode', () => { + render( + , + ); + expect(screen.getByText('50 €')).toBeOnTheScreen(); + }); + + it('shows the zero placeholder label when no amount is entered', () => { + render(); expect(screen.getByText('$0')).toBeOnTheScreen(); }); @@ -72,7 +83,11 @@ describe('QuickBuyAmountSection', () => { it('replaces the secondary label with an ActivityIndicator when isQuoteLoading', () => { render( - , + , ); expect(screen.getByTestId('quick-buy-amount-area')).toBeOnTheScreen(); // Secondary label is replaced by spinner — crypto label should NOT be present diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx index 650325299d7c..69dbf955db83 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx @@ -18,7 +18,8 @@ const styles = StyleSheet.create({ interface QuickBuyAmountSectionProps { amountDisplayMode: QuickBuyAmountDisplayMode; - usdAmount: string; + /** Entered amount preformatted in the user's display currency (e.g. "$20", "20 €"). */ + fiatAmountLabel: string; destSymbol: string; /** Estimated amount received in the dest token from the quote. */ estimatedReceiveAmount: string | undefined; @@ -44,7 +45,7 @@ interface QuickBuyAmountSectionProps { const QuickBuyAmountSection: React.FC = ({ amountDisplayMode, - usdAmount, + fiatAmountLabel, destSymbol, estimatedReceiveAmount, isQuoteLoading, @@ -52,7 +53,6 @@ const QuickBuyAmountSection: React.FC = ({ sourceCryptoAmount, sourceSymbol, }) => { - const fiatAmountLabel = usdAmount ? `$${usdAmount}` : '$0'; const cryptoAmountLabel = estimatedReceiveAmount ? `${formatTokenAmount(parseFloat(estimatedReceiveAmount))} ${destSymbol}` : `0 ${destSymbol}`; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.test.ts index 532ee9c4c23b..b7a567962498 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.test.ts @@ -27,7 +27,10 @@ const baseDeps = (overrides: Record = {}): TokenBalanceDeps => accountsByChainId: {}, tokenBalances: {}, tokenMarketData: {}, - currencyRates: { ETH: { usdConversionRate: 2000 } }, + // `conversionRate` is the native-token -> user-currency rate that the + // canonical `calcTokenFiatRate` reads. + currencyRates: { ETH: { conversionRate: 2000, usdConversionRate: 2000 } }, + currentCurrency: 'USD', allNetworkConfigs: { '0x1': { nativeCurrency: 'ETH' } }, ...overrides, }) as unknown as TokenBalanceDeps; @@ -50,7 +53,7 @@ describe('enrichTokenBalance', () => { expect(result).toEqual({ balance: '1.0', - balanceFiat: '$2000.00', + balanceFiat: '$2,000.00', tokenFiatAmount: 2000, currencyExchangeRate: 2000, }); @@ -79,7 +82,7 @@ describe('enrichTokenBalance', () => { ...extra, }); - it('prices an ERC-20 from market data times the usd conversion rate', () => { + it('prices an ERC-20 from market data times the native conversion rate', () => { const deps = withUsdcBalance({ tokenMarketData: { '0x1': { [toChecksumAddress(USDC)]: { price: 0.0005 } }, @@ -87,12 +90,43 @@ describe('enrichTokenBalance', () => { }); const result = enrichTokenBalance( - token({ address: USDC, chainId: '0x1', symbol: 'USDC', decimals: 6 }), + token({ + address: toChecksumAddress(USDC), + chainId: '0x1', + symbol: 'USDC', + decimals: 6, + }), + deps, + ); + + expect(result).toMatchObject({ + balance: '250.0', + balanceFiat: '$250.00', + currencyExchangeRate: 1, + tokenFiatAmount: 250, + }); + }); + + it('prices a lowercase-address ERC-20 against checksum-keyed market data', () => { + const deps = withUsdcBalance({ + tokenMarketData: { + '0x1': { [toChecksumAddress(USDC)]: { price: 0.0005 } }, + }, + }); + + const result = enrichTokenBalance( + token({ + address: USDC.toLowerCase(), + chainId: '0x1', + symbol: 'USDC', + decimals: 6, + }), deps, ); expect(result).toMatchObject({ balance: '250.0', + balanceFiat: '$250.00', currencyExchangeRate: 1, tokenFiatAmount: 250, }); @@ -177,7 +211,7 @@ describe('enrichTokenBalance', () => { expect(result).toEqual({ balance: '12.5', - balanceFiat: '$2500.00', + balanceFiat: '$2,500.00', tokenFiatAmount: 2500, currencyExchangeRate: 200, }); @@ -279,7 +313,7 @@ describe('enrichTokenBalance', () => { expect(result).toEqual({ balance: '0.5', - balanceFiat: '$50000.00', + balanceFiat: '$50,000.00', tokenFiatAmount: 50000, currencyExchangeRate: 100000, }); @@ -327,70 +361,4 @@ describe('enrichTokenBalance', () => { expect(result).toBeNull(); }); }); - - describe('fiatCurrency', () => { - const solAssetId = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'; - const solanaScope = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; - const solanaAccount = { id: 'solana-account-id' }; - - it('formats the fiat string with the provided currency symbol (numeric value stays USD)', () => { - const deps = baseDeps({ - fiatCurrency: 'eur', - solanaAccount, - multichainBalances: { - [solanaAccount.id]: { [solAssetId]: { amount: '12.5' } }, - }, - multichainRates: { [solAssetId]: { rate: '200' } }, - }); - - const result = enrichTokenBalance( - token({ - address: solAssetId, - chainId: solanaScope, - symbol: 'SOL', - decimals: 9, - }), - deps, - ); - - expect(result).toEqual({ - balance: '12.5', - balanceFiat: '€2500', - tokenFiatAmount: 2500, - currencyExchangeRate: 200, - }); - }); - - it('defaults to USD when no fiatCurrency is provided', () => { - const deps = baseDeps({ - solanaAccount, - multichainBalances: { - [solanaAccount.id]: { [solAssetId]: { amount: '12.5' } }, - }, - multichainRates: { [solAssetId]: { rate: '200' } }, - }); - - const result = enrichTokenBalance( - token({ address: solAssetId, chainId: solanaScope, symbol: 'SOL' }), - deps, - ); - - expect(result?.balanceFiat).toBe('$2500.00'); - }); - - it('uses the provided currency symbol for a lenient zero enrichment', () => { - const result = enrichTokenBalance( - token({ address: solAssetId, chainId: solanaScope, symbol: 'SOL' }), - baseDeps({ fiatCurrency: 'eur' }), - { includeZeroBalance: true }, - ); - - expect(result).toEqual({ - balance: '0', - balanceFiat: '€0', - tokenFiatAmount: 0, - currencyExchangeRate: undefined, - }); - }); - }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.ts index 0ec57510b615..65f1f8a034cd 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.ts @@ -2,29 +2,31 @@ import type { Hex } from '@metamask/utils'; import { formatUnits } from 'ethers/lib/utils'; import { isNonEvmChainId, - isSolanaChainId, isNativeAddress, + isSolanaChainId, } from '@metamask/bridge-controller'; import { BtcScope, TrxScope } from '@metamask/keyring-api'; import type { BridgeToken } from '../../../../../../UI/Bridge/types'; -import { addCurrencySymbol } from '../../../../../../../util/number/bigint'; +import { formatCurrency } from '../../../../../../UI/Bridge/utils/currencyUtils'; +import { calcTokenFiatRate } from '../../../../../../UI/Bridge/utils/exchange-rates'; +import { safeToChecksumAddress } from '../../../../../../../util/address'; import type { selectTokenMarketData } from '../../../../../../../selectors/tokenRatesController'; import type { selectCurrencyRates } from '../../../../../../../selectors/currencyRateController'; import { hasNonZeroHexBalance, getCachedNativeBalance, getCachedErc20Balance, - getTokenPrice, type TokenBalances, type AccountsByChainId, } from './tokenBalanceUtils'; /** * The priced balance fields shared by every QuickBuy token row. `balance` is a - * non-truncated decimal amount, `balanceFiat` the formatted fiat string (left - * `undefined` when the token's USD price can't be resolved, so the row renders - * a dash), and `currencyExchangeRate` the USD-per-token rate the controller - * uses to convert the entered USD amount into a token amount. + * non-truncated decimal amount, `balanceFiat` the formatted fiat string in the + * user's display currency (left `undefined` when the token's price can't be + * resolved, so the row renders a dash), and `currencyExchangeRate` the + * user-currency-per-token rate the controller uses to convert the entered fiat + * amount into a token amount. */ export interface TokenBalanceEnrichment { balance: string; @@ -44,12 +46,9 @@ export interface TokenBalanceDeps { tokenBalances: TokenBalances; tokenMarketData: ReturnType; currencyRates: ReturnType; + /** The user's selected display currency (e.g. "usd", "eur"). */ + currentCurrency: string; allNetworkConfigs?: Record; - /** - * Currency code used to format the fiat display string (e.g. `'usd'`, - * `'eur'`). Defaults to USD when omitted. - */ - fiatCurrency?: Parameters[1]; solanaAccount?: { id: string }; tronAccount?: { id: string }; bitcoinAccount?: { id: string }; @@ -63,7 +62,7 @@ export interface TokenBalanceDeps { export interface EnrichTokenBalanceOptions { /** * When `true`, tokens are still returned instead of being dropped: unheld - * tokens come back as balance "0" / $0.00 fiat, and held-but-unpriceable + * tokens come back as balance "0" / zero fiat, and held-but-unpriceable * tokens keep their real balance with an undefined fiat (rendered as a dash). * Used by the "Receive" picker, which lists every stable regardless of * whether the user holds it. @@ -71,29 +70,18 @@ export interface EnrichTokenBalanceOptions { includeZeroBalance?: boolean; } -/** - * QuickBuy prices everything in USD: `currencyExchangeRate` is a USD-per-token - * rate and the amount-entry flow is USD-first (see `QuickBuyAmountSection`). - * The fiat fields are therefore always USD-denominated, so they must be - * formatted as USD — formatting a USD value with the user's selected currency - * (e.g. "€2000.00" for a $2000 value) would be wrong. - */ -const USD_CURRENCY = 'usd' as Parameters[1]; - -const zeroEnrichment = ( - fiatCurrency: Parameters[1] = USD_CURRENCY, -): TokenBalanceEnrichment => ({ +const zeroEnrichment = (currentCurrency: string): TokenBalanceEnrichment => ({ balance: '0', - balanceFiat: addCurrencySymbol('0.00', fiatCurrency), + balanceFiat: formatCurrency(0, currentCurrency), tokenFiatAmount: 0, currencyExchangeRate: undefined, }); /** - * Lenient result for a token the user *does* hold but whose USD price can't be + * Lenient result for a token the user *does* hold but whose price can't be * resolved: keeps the real on-chain `balance` so the Receive picker still shows * the holding, while leaving fiat `undefined` (the row renders a dash rather - * than a misleading $0.00) and the rate undefined. + * than a misleading zero) and the rate undefined. */ const unpricedEnrichment = (balance: string): TokenBalanceEnrichment => ({ balance, @@ -106,37 +94,29 @@ const priced = ( balance: string, exchangeRate: number, balanceNum: number, - fiatCurrency: Parameters[1] = USD_CURRENCY, + currentCurrency: string, ): TokenBalanceEnrichment => { const tokenFiatAmount = balanceNum * exchangeRate; return { balance, - balanceFiat: addCurrencySymbol(tokenFiatAmount.toFixed(2), fiatCurrency), + balanceFiat: formatCurrency(tokenFiatAmount, currentCurrency), tokenFiatAmount, currencyExchangeRate: exchangeRate, }; }; -const enrichEvmTokenBalance = ( +/** + * Reads the user's cached on-chain balance of an EVM token, returning a + * non-truncated decimal string, or `null` when the user holds none (or the + * balance can't be parsed). Pricing is handled separately by the canonical + * `calcTokenFiatRate`. + */ +const readEvmBalance = ( candidate: BridgeToken, deps: TokenBalanceDeps, - options: EnrichTokenBalanceOptions, -): TokenBalanceEnrichment | null => { - const { includeZeroBalance } = options; - const { - accountAddress, - accountsByChainId, - tokenBalances, - tokenMarketData, - currencyRates, - allNetworkConfigs, - fiatCurrency, - } = deps; - - const dropOrZero = () => - includeZeroBalance ? zeroEnrichment(fiatCurrency) : null; - - if (!accountAddress) return dropOrZero(); +): string | null => { + const { accountAddress, accountsByChainId, tokenBalances } = deps; + if (!accountAddress) return null; const chainId = candidate.chainId as Hex; const isNative = isNativeAddress(candidate.address); @@ -149,45 +129,13 @@ const enrichEvmTokenBalance = ( candidate.address, ); - if (!hasNonZeroHexBalance(rawBalance)) return dropOrZero(); + if (!hasNonZeroHexBalance(rawBalance)) return null; - let displayBalance: string; try { - displayBalance = formatUnits(rawBalance, candidate.decimals); + return formatUnits(rawBalance, candidate.decimals); } catch { - return dropOrZero(); - } - - const balanceNum = parseFloat(displayBalance); - if (isNaN(balanceNum) || balanceNum <= 0) return dropOrZero(); - - const nativeTicker = allNetworkConfigs?.[chainId]?.nativeCurrency; - const nativeConversionRate = nativeTicker - ? (currencyRates?.[nativeTicker]?.usdConversionRate ?? 0) - : 0; - - let exchangeRate: number; - if (isNative) { - exchangeRate = nativeConversionRate; - } else { - const tokenPrice = getTokenPrice( - tokenMarketData, - chainId, - candidate.address, - ); - exchangeRate = - tokenPrice !== undefined ? tokenPrice * nativeConversionRate : 0; + return null; } - - if (!(exchangeRate > 0)) { - // No resolvable USD price. Strict callers (Pay with) drop the token because - // the fiat-first amount entry needs a real price; lenient callers (Receive) - // keep the real held balance with a zero fiat value so the holding still - // shows up. - return includeZeroBalance ? unpricedEnrichment(displayBalance) : null; - } - - return priced(displayBalance, exchangeRate, balanceNum, fiatCurrency); }; /** @@ -205,51 +153,89 @@ const getNonEvmAccount = ( return undefined; }; -const enrichNonEvmTokenBalance = ( +/** + * Reads the user's non-EVM (Solana, Tron, Bitcoin) balance of a token from the + * multichain balances controller, returning a decimal string or `null`. + */ +const readNonEvmBalance = ( candidate: BridgeToken, deps: TokenBalanceDeps, - options: EnrichTokenBalanceOptions, -): TokenBalanceEnrichment | null => { - const { includeZeroBalance } = options; - const { multichainBalances, multichainRates, fiatCurrency } = deps; - - const dropOrZero = () => - includeZeroBalance ? zeroEnrichment(fiatCurrency) : null; - +): string | null => { const nonEvmAccount = getNonEvmAccount(candidate.chainId, deps); - if (!nonEvmAccount) return dropOrZero(); + if (!nonEvmAccount) return null; const amountStr = - multichainBalances?.[nonEvmAccount.id]?.[candidate.address]?.amount; - if (!amountStr) return dropOrZero(); - - const balanceNum = parseFloat(amountStr); - if (isNaN(balanceNum) || balanceNum <= 0) return dropOrZero(); - - const rateStr = multichainRates?.[candidate.address]?.rate; - const rateNum = rateStr ? parseFloat(rateStr) : NaN; + deps.multichainBalances?.[nonEvmAccount.id]?.[candidate.address]?.amount; + return amountStr ?? null; +}; - if (isNaN(rateNum) || rateNum <= 0) { - // Held but no resolvable rate: keep the real balance when lenient (Receive), - // drop it when strict (Pay with). - return includeZeroBalance ? unpricedEnrichment(amountStr) : null; +/** + * Returns a copy of the candidate whose EVM ERC-20 address is checksummed so it + * matches the keys `calcTokenFiatRate` uses to look up `tokenMarketData` (which + * is keyed by checksummed addresses). Pay-with candidates carry lowercase + * addresses, so without this normalization held ERC-20s with valid prices + * resolve to no rate and get dropped from the strict (Pay with) picker. + * Non-EVM (CAIP asset id) and native addresses are left untouched. + */ +const toPricedCandidate = (candidate: BridgeToken): BridgeToken => { + if ( + isNonEvmChainId(candidate.chainId) || + isNativeAddress(candidate.address) + ) { + return candidate; } - return priced(amountStr, rateNum, balanceNum, fiatCurrency); + const checksummedAddress = safeToChecksumAddress(candidate.address); + return checksummedAddress + ? { ...candidate, address: checksummedAddress } + : candidate; }; /** * Prices a single token candidate from cached Redux balances, returning the - * shared balance fields (or `null` when the token should be omitted). Handles - * EVM natives, EVM ERC-20s, and non-EVM assets (Solana, Tron, Bitcoin) via the - * multichain balance/rate controllers, keeping the USD exchange-rate semantics - * QuickBuy's amount math depends on. + * shared balance fields (or `null` when the token should be omitted). Balance + * reading is QuickBuy-specific (EVM natives / ERC-20s via cached balances, + * non-EVM assets via the multichain controllers); the fiat rate is delegated to + * the canonical `calcTokenFiatRate` so QuickBuy shares the wallet-wide + * user-currency exchange-rate semantics (no bespoke USD math). */ export const enrichTokenBalance = ( candidate: BridgeToken, deps: TokenBalanceDeps, options: EnrichTokenBalanceOptions = {}, -): TokenBalanceEnrichment | null => - isNonEvmChainId(candidate.chainId) - ? enrichNonEvmTokenBalance(candidate, deps, options) - : enrichEvmTokenBalance(candidate, deps, options); +): TokenBalanceEnrichment | null => { + const { includeZeroBalance } = options; + const dropOrZero = () => + includeZeroBalance ? zeroEnrichment(deps.currentCurrency) : null; + + const balance = isNonEvmChainId(candidate.chainId) + ? readNonEvmBalance(candidate, deps) + : readEvmBalance(candidate, deps); + + if (balance === null) return dropOrZero(); + + const balanceNum = parseFloat(balance); + if (isNaN(balanceNum) || balanceNum <= 0) return dropOrZero(); + + const exchangeRate = calcTokenFiatRate({ + token: toPricedCandidate(candidate), + evmMultiChainMarketData: deps.tokenMarketData, + networkConfigurationsByChainId: (deps.allNetworkConfigs ?? {}) as Record< + Hex, + { nativeCurrency: string } + >, + evmMultiChainCurrencyRates: deps.currencyRates, + nonEvmMultichainAssetRates: deps.multichainRates as Parameters< + typeof calcTokenFiatRate + >[0]['nonEvmMultichainAssetRates'], + }); + + if (!exchangeRate || exchangeRate <= 0) { + // No resolvable price. Strict callers (Pay with) drop the token because the + // fiat-first amount entry needs a real price; lenient callers (Receive) + // keep the real held balance with an undefined fiat so the holding shows up. + return includeZeroBalance ? unpricedEnrichment(balance) : null; + } + + return priced(balance, exchangeRate, balanceNum, deps.currentCurrency); +}; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePayWithTokens.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePayWithTokens.test.ts index 643f7abb0eaf..a526061ad4f3 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePayWithTokens.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePayWithTokens.test.ts @@ -47,6 +47,7 @@ jest.mock('../../../../../../../selectors/tokenRatesController', () => ({ jest.mock('../../../../../../../selectors/currencyRateController', () => ({ selectCurrencyRates: jest.fn(), + selectCurrentCurrency: jest.fn(() => 'USD'), })); jest.mock('../../../../../../../core/redux/slices/bridge', () => ({ diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePayWithTokens.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePayWithTokens.ts index bbcfe1d858d5..97e31b312d55 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePayWithTokens.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePayWithTokens.ts @@ -10,7 +10,10 @@ import { selectAccountsByChainId } from '../../../../../../../selectors/accountT import { selectSelectedInternalAccountByScope } from '../../../../../../../selectors/multichainAccounts/accounts'; import { selectTokensBalances } from '../../../../../../../selectors/tokenBalancesController'; import { selectTokenMarketData } from '../../../../../../../selectors/tokenRatesController'; -import { selectCurrencyRates } from '../../../../../../../selectors/currencyRateController'; +import { + selectCurrencyRates, + selectCurrentCurrency, +} from '../../../../../../../selectors/currencyRateController'; import { selectMultichainBalances, selectMultichainAssetsRates, @@ -47,6 +50,7 @@ export const usePayWithTokens = (): { const tokenBalances = useSelector(selectTokensBalances); const tokenMarketData = useSelector(selectTokenMarketData); const currencyRates = useSelector(selectCurrencyRates); + const currentCurrency = useSelector(selectCurrentCurrency); const solanaAccount = accountByScope(SolScope.Mainnet); const tronAccount = accountByScope(TrxScope.Mainnet); @@ -74,6 +78,7 @@ export const usePayWithTokens = (): { tokenBalances, tokenMarketData, currencyRates, + currentCurrency, allNetworkConfigs, solanaAccount: solanaAccount ?? undefined, tronAccount: tronAccount ?? undefined, @@ -116,6 +121,7 @@ export const usePayWithTokens = (): { tokenBalances, tokenMarketData, currencyRates, + currentCurrency, allNetworkConfigs, solanaAccount, tronAccount, diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePositionTokenBalance.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePositionTokenBalance.test.ts index e40ae252d968..0c40eda7c235 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePositionTokenBalance.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePositionTokenBalance.test.ts @@ -64,13 +64,16 @@ const solanaAccount = { id: 'solana-account-id' }; const tronAccount = { id: 'tron-account-id' }; const bitcoinAccount = { id: 'bitcoin-account-id' }; -const FAKE_STATE = { - engine: { - backgroundState: { - NetworkController: { networkConfigurationsByChainId: {} }, +const makeState = ( + networkConfigs: Record = {}, +) => + ({ + engine: { + backgroundState: { + NetworkController: { networkConfigurationsByChainId: networkConfigs }, + }, }, - }, -} as never; + }) as never; const token = (address: string, chainId: string, symbol: string): BridgeToken => ({ @@ -99,6 +102,11 @@ const setup = ({ accounts = {}, multichainBalances = {}, multichainRates = {}, + accountsByChainId = {}, + tokenBalances = {}, + tokenMarketData = {}, + currencyRates = {}, + networkConfigs = {}, }: { currency?: string; accounts?: Record; @@ -107,11 +115,20 @@ const setup = ({ Record | undefined >; multichainRates?: Record; + accountsByChainId?: Record; + tokenBalances?: Record; + tokenMarketData?: Record; + currencyRates?: Record; + networkConfigs?: Record; }) => { - (selectAccountsByChainId as unknown as jest.Mock).mockReturnValue({}); - (selectTokensBalances as unknown as jest.Mock).mockReturnValue({}); - (selectTokenMarketData as unknown as jest.Mock).mockReturnValue({}); - (selectCurrencyRates as unknown as jest.Mock).mockReturnValue({}); + (selectAccountsByChainId as unknown as jest.Mock).mockReturnValue( + accountsByChainId, + ); + (selectTokensBalances as unknown as jest.Mock).mockReturnValue(tokenBalances); + (selectTokenMarketData as unknown as jest.Mock).mockReturnValue( + tokenMarketData, + ); + (selectCurrencyRates as unknown as jest.Mock).mockReturnValue(currencyRates); (selectCurrentCurrency as unknown as jest.Mock).mockReturnValue(currency); (selectMultichainBalances as unknown as jest.Mock).mockReturnValue( multichainBalances, @@ -123,7 +140,7 @@ const setup = ({ (scope: string) => accounts[scope] ?? undefined, ); mockUseSelector.mockImplementation((selector: (state: never) => unknown) => - selector(FAKE_STATE), + selector(makeState(networkConfigs)), ); }; @@ -141,9 +158,9 @@ describe('usePositionTokenBalance', () => { }); describe('Solana', () => { - it('prices the held balance and formats fiat with the user currency symbol', () => { + it('prices the held balance and formats fiat in the user display currency', () => { setup({ - currency: 'eur', + currency: 'usd', accounts: { [SOL_SCOPE]: solanaAccount }, multichainBalances: { [solanaAccount.id]: { [SOL_ASSET]: { amount: '12.5' } }, @@ -160,7 +177,7 @@ describe('usePositionTokenBalance', () => { expect(result.current).toMatchObject({ balance: '12.5', - balanceFiat: '€2500', + balanceFiat: '$2,500.00', tokenFiatAmount: 2500, currencyExchangeRate: 200, }); @@ -296,7 +313,7 @@ describe('usePositionTokenBalance', () => { expect(result.current).toMatchObject({ balance: '0.5', - balanceFiat: '$50000.00', + balanceFiat: '$50,000.00', tokenFiatAmount: 50000, currencyExchangeRate: 100000, }); @@ -335,4 +352,42 @@ describe('usePositionTokenBalance', () => { // No on-chain balance configured for the EVM account -> undefined. expect(result.current).toBeUndefined(); }); + + it('prices a held EVM ERC-20 with a lowercase address via checksum-keyed market data', () => { + // `tokenMarketData` is keyed by checksummed address, but position tokens can + // arrive lowercase; without normalization the price lookup misses and the + // token drops to the unpriced path (regression guard). + const lowercaseUsdc = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksumUsdc = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + setup({ + accounts: { [EVM_SCOPE]: { id: 'evm', address: '0xAccount' } }, + // 1 token (1e18) held, keyed by the lowercase token address. + tokenBalances: { + '0xAccount': { '0x1': { [lowercaseUsdc]: '0xde0b6b3a7640000' } }, + }, + // Price is only resolvable under the checksummed key. + tokenMarketData: { '0x1': { [checksumUsdc]: { price: 0.5 } } }, + currencyRates: { ETH: { conversionRate: 2000 } }, + networkConfigs: { '0x1': { nativeCurrency: 'ETH' } }, + }); + + const evmToken = { + address: lowercaseUsdc, + chainId: '0x1', + symbol: 'USDC', + name: 'USDC', + decimals: 18, + } as BridgeToken; + + const { result } = renderHook(() => + usePositionTokenBalance(targetOn('0x1', lowercaseUsdc), evmToken), + ); + + expect(result.current).toMatchObject({ + balance: '1.0', + balanceFiat: '$1,000.00', + tokenFiatAmount: 1000, + currencyExchangeRate: 1000, + }); + }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePositionTokenBalance.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePositionTokenBalance.ts index 9eb730377717..2e97e609edb3 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePositionTokenBalance.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/usePositionTokenBalance.ts @@ -22,7 +22,9 @@ import { selectMultichainBalances, selectMultichainAssetsRates, } from '../../../../../../../selectors/multichain/multichain'; -import { addCurrencySymbol } from '../../../../../../../util/number/bigint'; +import { safeToChecksumAddress } from '../../../../../../../util/address'; +import { formatCurrency } from '../../../../../../UI/Bridge/utils/currencyUtils'; +import { calcTokenFiatRate } from '../../../../../../UI/Bridge/utils/exchange-rates'; import { EVM_SCOPE } from '../../../../../../UI/Earn/constants/networks'; import type { QuickBuyTarget } from '../types'; import { enrichTokenBalance } from './enrichTokenBalance'; @@ -30,7 +32,6 @@ import { hasNonZeroHexBalance, getCachedNativeBalance, getCachedErc20Balance, - getTokenPrice, } from './tokenBalanceUtils'; /** @@ -44,8 +45,10 @@ import { * * Mirrors the behaviour of the Bridge's `useTokensWithBalance` / * `calculateEvmBalances`: a held-but-unpriceable token is still surfaced (with - * `$0.00` fiat and an undefined `currencyExchangeRate`), so downstream flows - * can let the user act on the token amount instead of hiding it entirely. + * a zero fiat value in the user's display currency and an undefined + * `currencyExchangeRate`), so downstream flows can let the user act on the + * token amount instead of hiding it entirely. `currencyExchangeRate` is a + * user-currency-per-token rate (consistent across EVM and non-EVM). */ export const usePositionTokenBalance = ( target: QuickBuyTarget | null, @@ -88,24 +91,72 @@ export const usePositionTokenBalance = ( // the entire hook for production users. const caipChainId = target.chain as CaipChainId; - const fiatCurrency = currentCurrency as Parameters< - typeof addCurrencySymbol - >[1]; - const zeroFiat = addCurrencySymbol('0.00', fiatCurrency); + const zeroFiat = formatCurrency(0, currentCurrency); + + // `calcTokenFiatRate` looks up `tokenMarketData` by the raw `token.address` + // (keyed by checksummed addresses). + const pricedDestToken = + isNonEvmChainId(destToken.chainId) || isNativeAddress(destToken.address) + ? destToken + : { + ...destToken, + address: + safeToChecksumAddress(destToken.address) ?? destToken.address, + }; + + // The fiat rate is delegated to the canonical `calcTokenFiatRate`, so the + // position token shares the wallet-wide user-currency exchange-rate + // semantics (EVM native/ERC-20 and non-EVM are all handled there). It + // returns a user-currency-per-token rate, or `undefined` when no price is + // resolvable — in which case we still surface the held balance with a zero + // fiat value, matching the wallet-wide behaviour (see `calculateEvmBalances`). + const exchangeRate = calcTokenFiatRate({ + token: pricedDestToken, + evmMultiChainMarketData: tokenMarketData, + networkConfigurationsByChainId: (allNetworkConfigs ?? {}) as Record< + Hex, + { nativeCurrency: string } + >, + evmMultiChainCurrencyRates: currencyRates, + nonEvmMultichainAssetRates: multichainRates as Parameters< + typeof calcTokenFiatRate + >[0]['nonEvmMultichainAssetRates'], + }); + const hasRate = exchangeRate !== undefined && exchangeRate > 0; + + const buildResult = ( + balanceStr: string, + balanceNum: number, + ): BridgeToken => { + const fiatValue = hasRate ? balanceNum * (exchangeRate as number) : 0; + return { + ...destToken, + balance: balanceStr, + balanceFiat: hasRate + ? formatCurrency(fiatValue, currentCurrency) + : zeroFiat, + tokenFiatAmount: fiatValue, + currencyExchangeRate: hasRate ? exchangeRate : undefined, + }; + }; // ─── Non-EVM branch (Solana, Tron, Bitcoin) ─────────────────────── // `enrichTokenBalance` prices all non-EVM assets generically from the // chain-agnostic multichain balance/rate controllers, so it replaces the // bespoke per-chain handling here and keeps the EVM branch's // `formatChainIdToHex` (which throws for non-EVM CAIP ids) out of reach. + // It prices via the canonical `calcTokenFiatRate` in the user's display + // currency (`currentCurrency`), so Tron/Bitcoin positions are sellable and + // shown in the same currency as the EVM branch. // // Lenient mode (`includeZeroBalance`) so a held-but-unpriceable token // (real balance, no resolvable rate) stays sellable — matching the EVM // branch below and the wallet-wide `calculateEvmBalances` behaviour. The // lenient path also returns a zero-balance enrichment for tokens the user // doesn't hold, so we drop those (balance ≤ 0) to preserve the strict - // "no balance → not sellable" contract. Unpriced holdings render `$0.00` - // (via `zeroFiat`) rather than a dash, consistent with the EVM branch. + // "no balance → not sellable" contract. Unpriced holdings render the + // user-currency zero (via `zeroFiat`) rather than a dash, consistent with + // the EVM branch. if (isNonEvmChainId(caipChainId)) { const enrichment = enrichTokenBalance( destToken, @@ -115,8 +166,8 @@ export const usePositionTokenBalance = ( tokenBalances, tokenMarketData, currencyRates, + currentCurrency, allNetworkConfigs, - fiatCurrency, solanaAccount: solanaAccount ?? undefined, tronAccount: tronAccount ?? undefined, bitcoinAccount: bitcoinAccount ?? undefined, @@ -170,47 +221,7 @@ export const usePositionTokenBalance = ( const balanceNum = parseFloat(displayBalance); if (isNaN(balanceNum) || balanceNum <= 0) return undefined; - const networkConfig = allNetworkConfigs?.[hexChainId]; - const nativeTicker = networkConfig?.nativeCurrency; - const nativeConversionRate = nativeTicker - ? (currencyRates?.[nativeTicker]?.usdConversionRate ?? 0) - : 0; - - // Resolve the source token's price in user fiat. If anything's missing we - // still return the token with its real balance — downstream flows will - // operate on token amounts and render $0.00 for fiat, matching the - // wallet-wide behaviour (see `calculateEvmBalances`). - let exchangeRate: number | undefined; - if (nativeConversionRate > 0) { - if (isNativeAddress(target.tokenAddress)) { - exchangeRate = nativeConversionRate; - } else { - const tokenPrice = getTokenPrice( - tokenMarketData, - hexChainId, - target.tokenAddress, - ); - if (tokenPrice !== undefined) { - exchangeRate = tokenPrice * nativeConversionRate; - } - } - } - if (exchangeRate !== undefined && exchangeRate <= 0) { - exchangeRate = undefined; - } - - const fiatValue = - exchangeRate !== undefined ? balanceNum * exchangeRate : 0; - return { - ...destToken, - balance: displayBalance, - balanceFiat: - exchangeRate !== undefined - ? addCurrencySymbol(fiatValue.toFixed(2), fiatCurrency) - : zeroFiat, - tokenFiatAmount: fiatValue, - currencyExchangeRate: exchangeRate, - }; + return buildResult(displayBalance, balanceNum); }, [ target, destToken, diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts index 56abf6a43e5d..1bbe609093bd 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts @@ -10,7 +10,11 @@ import { } from 'react'; import { TextInput } from 'react-native'; import { useDispatch, useSelector } from 'react-redux'; -import { selectCurrentCurrency } from '../../../../../../../selectors/currencyRateController'; +import { + selectCurrencyRates, + selectCurrentCurrency, +} from '../../../../../../../selectors/currencyRateController'; +import { selectNetworkConfigurations } from '../../../../../../../selectors/networkController'; import { ImpactMoment, playErrorNotification, @@ -27,7 +31,9 @@ import { formatCurrency, formatMinimumReceived, } from '../../../../../../UI/Bridge/utils/currencyUtils'; +import { FIAT_INPUT_DECIMALS } from '../../../../../../UI/Bridge/utils/sourceAmountInputMode'; import { isGaslessQuote } from '../../../../../../UI/Bridge/utils/isGaslessQuote'; +import { calcUsdAmountFromFiat } from '../../../../../../UI/Bridge/utils/exchange-rates'; import { isSameAsset, selectDefaultSourceToken, @@ -149,16 +155,19 @@ export interface UseQuickBuyControllerResult { currentCurrency: string; // amount amountDisplayMode: QuickBuyAmountDisplayMode; - usdAmount: string; + /** Raw entered amount in the user's display currency (unformatted, e.g. "20"). */ + fiatAmount: string; + /** Entered amount preformatted in the user's display currency (e.g. "$20", "20 €"). */ + fiatAmountLabel: string; /** Token-units amount entered in the unpriced sell path (otherwise ''). */ sourceAmountTokens: string; - /** Source token amount in token units (derived from USD for priced, or directly entered for unpriced). */ + /** Source token amount in token units (derived from the fiat amount for priced, or directly entered for unpriced). */ sourceTokenAmount: string | undefined; /** True when the source token has a usable fiat exchange rate. */ hasSourcePrice: boolean; sliderPercent: number; - maxSpendUsd: number; - /** True when neither USD nor token-balance gates allow slider interaction. */ + maxSpendFiat: number; + /** True when neither fiat nor token-balance gates allow slider interaction. */ isSliderDisabled: boolean; formattedExchangeRate: string | undefined; metamaskFeePercent: number; @@ -177,7 +186,7 @@ export interface UseQuickBuyControllerResult { formattedMinimumReceivedFiat: string | undefined; formattedPriceImpact: string; formattedRate: string | undefined; - totalAmountUsd: string; + totalAmountFiat: string; // quote state isQuoteLoading: boolean; /** @@ -246,7 +255,7 @@ export function useQuickBuyController( } = useQuickBuyAnalytics(traderAddress, caip19, analyticsContext); const [tradeMode, setTradeMode] = useState('buy'); - const [usdAmount, setUsdAmount] = useState(''); + const [fiatAmount, setFiatAmount] = useState(''); const [sourceAmountTokens, setSourceAmountTokens] = useState(''); // True when the user has committed the slider to 100% ("sell all"). In this // mode `sourceTokenAmount` spends the exact on-chain balance rather than a @@ -257,22 +266,24 @@ export function useQuickBuyController( // Drives quote re-fetching. Updated only when the user commits a value // (slider drag end, tap, or text input) — NOT on every drag tick. This // prevents spamming quote requests while the thumb is moving. - const [quotedUsdAmount, setQuotedUsdAmount] = useState(''); + const [quotedFiatAmount, setQuotedFiatAmount] = useState(''); // Bumped whenever the user commits an amount in a single discrete gesture // (slider release) so the quotes hook fetches immediately instead of waiting // out the typing debounce. Typed input intentionally does NOT bump this — it // stays debounced to avoid a request per keystroke. const [immediateFetchToken, setImmediateFetchToken] = useState(0); // Fiat-first: every input path (slider, hidden TextInput, amount-area press) - // edits the USD amount, so the primary label must default to fiat as well. - // The user can swap to crypto display via the toggle once a quote is available. + // edits the user-currency amount, so the primary label must default to fiat + // as well. The user can swap to crypto display via the toggle once a quote is + // available. const [amountDisplayMode, setAmountDisplayMode] = useState('fiat'); const [sliderPercent, setSliderPercent] = useState(0); const lastSliderPercentRef = useRef(0); - // Deduplicates consecutive handleSliderDragEnd calls with the same USD amount - // (can happen when Tap + Pan both fire onEnd for a pure tap gesture). - const lastCommittedUsdRef = useRef(''); + // Deduplicates consecutive handleSliderDragEnd calls with the same + // user-currency amount (can happen when Tap + Pan both fire onEnd for a pure + // tap gesture). + const lastCommittedFiatRef = useRef(''); const [selectedQuoteRequestId, setSelectedQuoteRequestId] = useState< string | undefined >(undefined); @@ -287,6 +298,8 @@ export function useQuickBuyController( const isNonEvmSourced = useSelector(selectIsNonEvmSourced); const bridgeFeatureFlags = useSelector(selectBridgeFeatureFlags); const currentCurrency = useSelector(selectCurrentCurrency); + const currencyRates = useSelector(selectCurrencyRates); + const networkConfigurations = useSelector(selectNetworkConfigurations); const selectedAddress = useSelector( selectSelectedInternalAccountFormattedAddress, ); @@ -448,6 +461,26 @@ export function useQuickBuyController( tradeMode === 'buy' ? positionTokenFromSetup : selectedDestStable; const sourceChainId = sourceToken?.chainId as Hex | undefined; + // The entered amount is in the user's display currency, but the + // `amount_usd` analytics property is contractually USD. Convert via the + // pure fiat->USD ratio (usdConversionRate / conversionRate) derived from the + // source chain's native-currency rates. Returns 0 when rates are unavailable + // so analytics never reports a user-currency value as USD. + const toAmountUsd = useCallback( + (fiat: number): number => { + if (!Number.isFinite(fiat) || fiat <= 0) return 0; + return ( + calcUsdAmountFromFiat({ + tokenFiatValue: fiat, + chainId: sourceToken?.chainId, + networkConfigurationsByChainId: networkConfigurations, + evmMultiChainCurrencyRates: currencyRates, + }) ?? 0 + ); + }, + [sourceToken?.chainId, networkConfigurations, currencyRates], + ); + // BridgeController.fetchQuotes does not start gas fee polling, so estimates // for the source chain may be missing when selectBridgeQuotesBase enriches // the quote — producing a $0 network fee. Poll explicitly for the source @@ -556,12 +589,14 @@ export function useQuickBuyController( return latestSourceBalance.displayBalance; } if (hasSourcePrice) { - if (!quotedUsdAmount || !sourceToken?.currencyExchangeRate) { + if (!quotedFiatAmount || !sourceToken?.currencyExchangeRate) { return undefined; } - const usd = parseFloat(quotedUsdAmount); - if (isNaN(usd) || usd <= 0) return undefined; - return (usd / sourceToken.currencyExchangeRate).toString(); + // `currencyExchangeRate` is user-currency-per-token and `quotedFiatAmount` + // is in the user's display currency, so fiat / rate yields token units. + const fiat = parseFloat(quotedFiatAmount); + if (isNaN(fiat) || fiat <= 0) return undefined; + return (fiat / sourceToken.currencyExchangeRate).toString(); } // Unpriced path: source amount is entered directly in token units. if (!sourceAmountTokens) return undefined; @@ -572,7 +607,7 @@ export function useQuickBuyController( hasSourcePrice, isMaxSourceAmount, latestSourceBalance?.displayBalance, - quotedUsdAmount, + quotedFiatAmount, sourceAmountTokens, sourceToken?.currencyExchangeRate, ]); @@ -586,28 +621,34 @@ export function useQuickBuyController( }, [sourceTokenAmount, dispatch]); // Used for analytics passed to useQuickBuyQuotes. Must derive from - // quotedUsdAmount (not usdAmount) so that mid-drag display updates don't + // quotedFiatAmount (not fiatAmount) so that mid-drag display updates don't // recreate quotesAnalyticsContext and trigger spurious quote re-fetches. - const quotedUsdAmountNumber = useMemo(() => { - const v = Number(quotedUsdAmount); + const quotedFiatAmountNumber = useMemo(() => { + const v = Number(quotedFiatAmount); return Number.isFinite(v) ? v : 0; - }, [quotedUsdAmount]); - // Derives from usdAmount for handleConfirm (the confirm button is disabled - // when usdAmount !== quotedUsdAmount, so by the time confirm is pressed they + }, [quotedFiatAmount]); + // Derives from fiatAmount for handleConfirm (the confirm button is disabled + // when fiatAmount !== quotedFiatAmount, so by the time confirm is pressed they // are always equal — keeping separate avoids recreating handleConfirm on // every drag tick). - const usdAmountNumber = useMemo(() => { - const v = Number(usdAmount); + const fiatAmountNumber = useMemo(() => { + const v = Number(fiatAmount); return Number.isFinite(v) ? v : 0; - }, [usdAmount]); + }, [fiatAmount]); + // `amount_usd` analytics is contractually USD; the committed amount is in the + // user's display currency, so convert before it leaves for analytics. + const quotedAmountUsd = useMemo( + () => toAmountUsd(quotedFiatAmountNumber), + [toAmountUsd, quotedFiatAmountNumber], + ); const quotesAnalyticsContext = useMemo( () => ({ traderAddress, caip19, - amountUsd: quotedUsdAmountNumber, + amountUsd: quotedAmountUsd, source: analyticsContext?.source, }), - [traderAddress, caip19, quotedUsdAmountNumber, analyticsContext?.source], + [traderAddress, caip19, quotedAmountUsd, analyticsContext?.source], ); const { @@ -653,7 +694,7 @@ export function useQuickBuyController( const formattedNetworkFee = useFormattedNetworkFee(activeQuote ?? null); - const networkFeeRawUsd = useMemo(() => { + const networkFeeFiat = useMemo(() => { if (!activeQuote) return null; if (isGaslessQuote(activeQuote.quote)) { const v = activeQuote.includedTxFees?.valueInCurrency; @@ -732,14 +773,17 @@ export function useQuickBuyController( [activeQuote, bridgeFeatureFlags], ); - const totalAmountUsd = useMemo(() => { - const inputNum = parseFloat(usdAmount); - if (!usdAmount || isNaN(inputNum)) return '$0'; - if (activeQuote && networkFeeRawUsd !== null) { - return `$${(inputNum + networkFeeRawUsd).toFixed(2)}`; + const totalAmountFiat = useMemo(() => { + const inputNum = parseFloat(fiatAmount); + const zero = formatCurrency(0, currentCurrency); + if (!fiatAmount || isNaN(inputNum)) return zero; + // Both the entered amount and the quote's network fee (`valueInCurrency`) + // are already in the user's display currency. + if (activeQuote && networkFeeFiat !== null) { + return formatCurrency(inputNum + networkFeeFiat, currentCurrency); } - return '$0'; - }, [usdAmount, activeQuote, networkFeeRawUsd]); + return zero; + }, [fiatAmount, activeQuote, networkFeeFiat, currentCurrency]); const hasInsufficientBalance = useIsInsufficientBalance({ amount: sourceTokenAmount, @@ -757,7 +801,7 @@ export function useQuickBuyController( const hasDestinationPicker = isEvmNonEvmBridge || isNonEvmNonEvmBridge; const isDestinationAddressMissing = hasDestinationPicker && !destAddress; - const sourceBalanceFiatUsd = useMemo(() => { + const sourceBalanceFiatValue = useMemo(() => { if ( !latestSourceBalance?.displayBalance || !liveSourceCurrencyExchangeRate @@ -771,8 +815,8 @@ export function useQuickBuyController( }, [latestSourceBalance?.displayBalance, liveSourceCurrencyExchangeRate]); const sourceBalanceFiat = useMemo( - () => formatCurrency(sourceBalanceFiatUsd, currentCurrency), - [sourceBalanceFiatUsd, currentCurrency], + () => formatCurrency(sourceBalanceFiatValue, currentCurrency), + [sourceBalanceFiatValue, currentCurrency], ); const sourceBalanceDisplay = useMemo(() => { @@ -794,7 +838,7 @@ export function useQuickBuyController( // pass it straight through rather than re-deriving it from a token amount. const destBalanceFiat = liveSelectedDestBalance?.balanceFiat; - const maxSpendUsd = sourceBalanceFiatUsd; + const maxSpendFiat = sourceBalanceFiatValue; // Token-amount-based gate: used for sources we can't price. Mirrors how the // Bridge / asset list keep unpriceable tokens usable as long as the user @@ -807,7 +851,7 @@ export function useQuickBuyController( }, [latestSourceBalance?.displayBalance]); const isSliderDisabled = hasSourcePrice - ? maxSpendUsd <= 0 + ? maxSpendFiat <= 0 : maxSpendTokens <= 0; // For the toolbar rate pill in buy mode the dest token from `useQuickBuySetup` @@ -841,7 +885,7 @@ export function useQuickBuyController( }, [onClose]); // Updates the display state (thumb position + fiat label) on every 1% tick - // during a drag. Does NOT commit to quotedUsdAmount or fire analytics — that + // during a drag. Does NOT commit to quotedFiatAmount or fire analytics — that // is deferred to handleSliderDragEnd so we only re-fetch quotes once per // gesture, not on every pixel of movement. const handleSliderChange = useCallback( @@ -871,31 +915,33 @@ export function useQuickBuyController( } // ── Priced path: update display state only (quote commits on drag end). ── - if (maxSpendUsd <= 0) { - setUsdAmount(''); + if (maxSpendFiat <= 0) { + setFiatAmount(''); return; } - const nextUsd = - rounded === 0 ? '' : ((maxSpendUsd * rounded) / 100).toFixed(2); - setUsdAmount(nextUsd); + const nextFiat = + rounded === 0 + ? '' + : ((maxSpendFiat * rounded) / 100).toFixed(FIAT_INPUT_DECIMALS); + setFiatAmount(nextFiat); lastInputMethodRef.current = SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.SLIDER; }, - [hasSourcePrice, maxSpendTokens, maxSpendUsd, lastInputMethodRef], + [hasSourcePrice, maxSpendTokens, maxSpendFiat, lastInputMethodRef], ); // Called once when the user lifts their finger (pan end) or taps the track. - // Commits the final value to quotedUsdAmount (triggering a quote re-fetch) + // Commits the final value to quotedFiatAmount (triggering a quote re-fetch) // and fires analytics exactly once per interaction. const handleSliderDragEnd = useCallback( (percent: number) => { const rounded = Math.round(percent); - const nextUsd = - rounded === 0 || maxSpendUsd <= 0 + const nextFiat = + rounded === 0 || maxSpendFiat <= 0 ? '' - : ((maxSpendUsd * rounded) / 100).toFixed(2); + : ((maxSpendFiat * rounded) / 100).toFixed(FIAT_INPUT_DECIMALS); - // Flag max BEFORE the dedup guard. lastCommittedUsdRef is also written by + // Flag max BEFORE the dedup guard. lastCommittedFiatRef is also written by // typed input and the price-migration effect, so releasing the slider at // 100% can match the ref and return early (e.g. user typed the exact max, // then slid to 100% to "sell all"). If setIsMaxSourceAmount ran after the @@ -905,28 +951,28 @@ export function useQuickBuyController( setIsMaxSourceAmount(rounded >= 100); // Deduplicate: Tap + Pan can both fire onEnd for a pure tap gesture. - if (nextUsd === lastCommittedUsdRef.current) { + if (nextFiat === lastCommittedFiatRef.current) { return; } - lastCommittedUsdRef.current = nextUsd; + lastCommittedFiatRef.current = nextFiat; // Guarantee display state matches the committed value. The last onUpdate // tick during a Pan may have landed on a different % than where the - // finger actually lifted — if so, usdAmount would be stale relative to - // quotedUsdAmount, keeping isAmountUncommitted true and the Buy button + // finger actually lifted — if so, fiatAmount would be stale relative to + // quotedFiatAmount, keeping isAmountUncommitted true and the Buy button // permanently disabled. Syncing both states here is the authoritative fix. setSliderPercent(rounded); lastSliderPercentRef.current = rounded; - setUsdAmount(nextUsd); + setFiatAmount(nextFiat); - setQuotedUsdAmount(nextUsd); - const numericUsd = Number(nextUsd); - if (rounded > 0 && Number.isFinite(numericUsd) && numericUsd > 0) { + setQuotedFiatAmount(nextFiat); + const numericFiat = Number(nextFiat); + if (rounded > 0 && Number.isFinite(numericFiat) && numericFiat > 0) { // Slider release is a single committed value — fetch the quote // immediately rather than waiting out the typing debounce. setImmediateFetchToken((token) => token + 1); trackAmountSelected( - numericUsd, + toAmountUsd(numericFiat), SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.SLIDER, tradeMode === 'buy' ? sourceToken?.symbol : undefined, rounded, @@ -935,10 +981,11 @@ export function useQuickBuyController( } }, [ - maxSpendUsd, + maxSpendFiat, sourceToken?.symbol, destToken?.symbol, tradeMode, + toAmountUsd, trackAmountSelected, ], ); @@ -960,13 +1007,13 @@ export function useQuickBuyController( }, []); const resetAmountState = useCallback(() => { - setUsdAmount(''); - setQuotedUsdAmount(''); + setFiatAmount(''); + setQuotedFiatAmount(''); setSourceAmountTokens(''); setIsMaxSourceAmount(false); setSliderPercent(0); lastSliderPercentRef.current = 0; - lastCommittedUsdRef.current = ''; + lastCommittedFiatRef.current = ''; lastTrackedAmountRef.current = ''; lastInputMethodRef.current = SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.SLIDER; @@ -988,8 +1035,8 @@ export function useQuickBuyController( // If the source token's price becomes available (or disappears) while the // sheet is open — e.g. a new market-data fetch lands — re-align the display // mode so the headline reflects what we can honestly show. If price arrives - // after the user already entered a token amount, convert it to USD so the - // amount is not lost. + // after the user already entered a token amount, convert it to the user's + // display currency so the amount is not lost. const prevHasSourcePriceRef = useRef(hasSourcePrice); useEffect(() => { if (prevHasSourcePriceRef.current === hasSourcePrice) return; @@ -998,7 +1045,8 @@ export function useQuickBuyController( if (!hasSourcePrice) { setAmountDisplayMode('crypto'); } else if (!prev) { - // Price just became available — migrate any entered token amount to USD. + // Price just became available — migrate any entered token amount to the + // user's display currency (`currencyExchangeRate` is user-currency-per-token). setAmountDisplayMode('fiat'); const tokens = parseFloat(sourceAmountTokens); if ( @@ -1006,10 +1054,12 @@ export function useQuickBuyController( tokens > 0 && liveSourceCurrencyExchangeRate ) { - const usd = (tokens * liveSourceCurrencyExchangeRate).toFixed(2); - setUsdAmount(usd); - setQuotedUsdAmount(usd); - lastCommittedUsdRef.current = usd; + const fiat = (tokens * liveSourceCurrencyExchangeRate).toFixed( + FIAT_INPUT_DECIMALS, + ); + setFiatAmount(fiat); + setQuotedFiatAmount(fiat); + lastCommittedFiatRef.current = fiat; } } }, [hasSourcePrice, sourceAmountTokens, liveSourceCurrencyExchangeRate]); @@ -1039,16 +1089,17 @@ export function useQuickBuyController( const normalized = cleaned.startsWith('.') ? `0${cleaned}` : cleaned; const parts = normalized.split('.'); if (parts.length > 2) return; - // Priced (fiat) input: cap to 2 decimals. Unpriced (token) input: allow - // up to the token's decimals so the user can spend small balances. + // Priced (fiat) input: cap to two decimals, matching Bridge fiat mode + // (`FIAT_INPUT_DECIMALS`). Unpriced (token) input: allow up to the token's + // decimals so the user can spend small balances. const maxFractionDigits = hasSourcePrice - ? 2 + ? FIAT_INPUT_DECIMALS : (sourceToken?.decimals ?? 18); if (parts.length === 2 && parts[1].length > maxFractionDigits) return; if (hasSourcePrice) { - setUsdAmount(normalized); - setQuotedUsdAmount(normalized); - lastCommittedUsdRef.current = normalized; + setFiatAmount(normalized); + setQuotedFiatAmount(normalized); + lastCommittedFiatRef.current = normalized; } else { setSourceAmountTokens(normalized); } @@ -1063,13 +1114,16 @@ export function useQuickBuyController( // stops typing for 500ms, so we don't emit on every keystroke. useEffect(() => { if (lastInputMethodRef.current !== 'custom_input') return; - if (!usdAmount) return; - const numeric = Number(usdAmount); - if (!Number.isFinite(numeric) || numeric <= 0) return; - if (lastTrackedAmountRef.current === String(numeric)) return; + if (!fiatAmount) return; + const numericFiat = Number(fiatAmount); + if (!Number.isFinite(numericFiat) || numericFiat <= 0) return; + // `amount_usd` is contractually USD; convert the entered display-currency + // amount and dedupe against the same USD value the tracker records. + const amountUsd = toAmountUsd(numericFiat); + if (lastTrackedAmountRef.current === String(amountUsd)) return; const handle = setTimeout(() => { trackAmountSelected( - numeric, + amountUsd, SocialLeaderboardEventValues.AMOUNT_SELECTION_METHOD.CUSTOM_INPUT, tradeMode === 'buy' ? sourceToken?.symbol : undefined, undefined, @@ -1078,10 +1132,11 @@ export function useQuickBuyController( }, 500); return () => clearTimeout(handle); }, [ - usdAmount, + fiatAmount, sourceToken?.symbol, destToken?.symbol, tradeMode, + toAmountUsd, trackAmountSelected, lastInputMethodRef, lastTrackedAmountRef, @@ -1090,7 +1145,10 @@ export function useQuickBuyController( const handleConfirm = useCallback(async () => { if (!activeQuote || !walletAddress) return; - const amountUsd = usdAmountNumber > 0 ? usdAmountNumber : undefined; + // `amount_usd` is contractually USD; the entered amount is in the user's + // display currency, so convert it here. + const amountUsdValue = toAmountUsd(fiatAmountNumber); + const amountUsd = amountUsdValue > 0 ? amountUsdValue : undefined; const amountTokenRaw = activeQuote.toTokenAmount?.amount; const amountToken = amountTokenRaw != null && isNumberValue(amountTokenRaw) @@ -1155,7 +1213,7 @@ export function useQuickBuyController( tokenSymbol: target.tokenSymbol, counterTokenSymbol: (tradeMode === 'buy' ? sourceToken?.symbol : destToken?.symbol) ?? '', - fiatAmountLabel: formatCurrency(usdAmountNumber, currentCurrency), + fiatAmountLabel: formatCurrency(fiatAmountNumber, currentCurrency), rate: formattedRate, isNonEvmSwap, }; @@ -1271,7 +1329,8 @@ export function useQuickBuyController( isNonEvmNonEvmBridge, sourceToken?.chainId, destToken?.chainId, - usdAmountNumber, + fiatAmountNumber, + toAmountUsd, currentCurrency, formattedRate, traderAddress, @@ -1286,13 +1345,21 @@ export function useQuickBuyController( submitStartedAtRef, ]); + // Preformatted headline value in the user's display currency (correct symbol + // placement + separators for any locale/currency). The view renders this + // string directly instead of concatenating a hardcoded "$". + const fiatAmountLabel = useMemo( + () => formatCurrency(Number(fiatAmount) || 0, currentCurrency), + [fiatAmount, currentCurrency], + ); + const hasError = Boolean(quoteFetchError || isNoQuotesAvailable); const hasValidAmount = hasSourcePrice - ? Boolean(usdAmount && Number(usdAmount) > 0) + ? Boolean(fiatAmount && Number(fiatAmount) > 0) : Boolean(sourceAmountTokens && Number(sourceAmountTokens) > 0); // True while the slider is mid-drag: the user has moved the thumb but has not // yet committed (released). - const isAmountUncommitted = usdAmount !== quotedUsdAmount; + const isAmountUncommitted = fiatAmount !== quotedFiatAmount; const hasQuoteRequestableAmount = useMemo(() => { const hasNonZeroInputAmount = Boolean( sourceTokenAmount && Number(sourceTokenAmount) !== 0, @@ -1449,12 +1516,13 @@ export function useQuickBuyController( selectedDestStable, currentCurrency, amountDisplayMode, - usdAmount, + fiatAmount, + fiatAmountLabel, sourceAmountTokens, sourceTokenAmount, hasSourcePrice, sliderPercent, - maxSpendUsd, + maxSpendFiat, isSliderDisabled, formattedExchangeRate, metamaskFeePercent, @@ -1468,7 +1536,7 @@ export function useQuickBuyController( formattedMinimumReceivedFiat, formattedPriceImpact, formattedRate, - totalAmountUsd, + totalAmountFiat, isQuoteLoading, isBlockingQuoteLoad, isSubmittingTx, diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.test.ts index 0c6d80c2444e..b19baf3a40f9 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.test.ts @@ -33,6 +33,7 @@ jest.mock('../../../../../../../selectors/tokenRatesController', () => ({ jest.mock('../../../../../../../selectors/currencyRateController', () => ({ selectCurrencyRates: jest.fn(() => ({})), + selectCurrentCurrency: jest.fn(() => 'USD'), })); jest.mock('../../../../../../../selectors/multichain/multichain', () => ({ diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.ts index 54f18fa9c125..dc38830d29f7 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.ts @@ -9,7 +9,10 @@ import { selectAccountsByChainId } from '../../../../../../../selectors/accountT import { selectSelectedInternalAccountByScope } from '../../../../../../../selectors/multichainAccounts/accounts'; import { selectTokensBalances } from '../../../../../../../selectors/tokenBalancesController'; import { selectTokenMarketData } from '../../../../../../../selectors/tokenRatesController'; -import { selectCurrencyRates } from '../../../../../../../selectors/currencyRateController'; +import { + selectCurrencyRates, + selectCurrentCurrency, +} from '../../../../../../../selectors/currencyRateController'; import { selectMultichainBalances, selectMultichainAssetsRates, @@ -168,6 +171,7 @@ export const useReceiveTokens = ( const tokenBalances = useSelector(selectTokensBalances); const tokenMarketData = useSelector(selectTokenMarketData); const currencyRates = useSelector(selectCurrencyRates); + const currentCurrency = useSelector(selectCurrentCurrency); const multichainBalances = useSelector(selectMultichainBalances); const multichainRates = useSelector(selectMultichainAssetsRates); const allNetworkConfigs = useSelector( @@ -187,6 +191,7 @@ export const useReceiveTokens = ( tokenBalances, tokenMarketData, currencyRates, + currentCurrency, allNetworkConfigs, solanaAccount: solanaAccount ?? undefined, tronAccount: tronAccount ?? undefined, @@ -210,6 +215,7 @@ export const useReceiveTokens = ( tokenBalances, tokenMarketData, currencyRates, + currentCurrency, allNetworkConfigs, solanaAccount, tronAccount, diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts index 6ca75673243d..1557ddf19564 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts @@ -16,7 +16,11 @@ import { } from '../../../../../../core/redux/slices/bridge'; import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController'; import { selectSourceWalletAddress } from '../../../../../../selectors/bridge'; -import { selectCurrentCurrency } from '../../../../../../selectors/currencyRateController'; +import { + selectCurrentCurrency, + selectCurrencyRates, +} from '../../../../../../selectors/currencyRateController'; +import { selectNetworkConfigurations } from '../../../../../../selectors/networkController'; import { selectShouldUseSmartTransaction } from '../../../../../../selectors/smartTransactionsController'; import { ImpactMoment, @@ -205,6 +209,11 @@ jest.mock('../../../../../../selectors/accountsController', () => ({ jest.mock('../../../../../../selectors/currencyRateController', () => ({ selectCurrentCurrency: jest.fn(), + selectCurrencyRates: jest.fn(), +})); + +jest.mock('../../../../../../selectors/networkController', () => ({ + selectNetworkConfigurations: jest.fn(), })); jest.mock('../../../../../../util/address', () => ({ @@ -360,6 +369,15 @@ const setupDefaultMocks = () => { selectSelectedInternalAccountFormattedAddress as unknown as jest.Mock ).mockReturnValue('0xWALLET'); (selectCurrentCurrency as unknown as jest.Mock).mockReturnValue('USD'); + // Native-currency rates + network configs power the fiat->USD conversion for + // `amount_usd` analytics. conversionRate === usdConversionRate keeps the USD + // case a 1:1 conversion (entered USD amount == amount_usd). + (selectCurrencyRates as unknown as jest.Mock).mockReturnValue({ + ETH: { conversionRate: 2000, usdConversionRate: 2000 }, + }); + (selectNetworkConfigurations as unknown as jest.Mock).mockReturnValue({ + '0x1': { nativeCurrency: 'ETH' }, + }); (usePriceImpactViewData as jest.Mock).mockReturnValue({ textColor: TextColor.TextAlternative, icon: undefined, @@ -442,7 +460,7 @@ describe('useQuickBuyController', () => { result.current.handleAmountChange('20'); }); - expect(result.current.usdAmount).toBe('20'); + expect(result.current.fiatAmount).toBe('20'); }); it('normalizes a leading decimal without digits', () => { @@ -454,7 +472,7 @@ describe('useQuickBuyController', () => { result.current.handleAmountChange('.'); }); - expect(result.current.usdAmount).toBe('0.'); + expect(result.current.fiatAmount).toBe('0.'); expect(result.current.hasValidAmount).toBe(false); }); @@ -467,7 +485,7 @@ describe('useQuickBuyController', () => { result.current.handleAmountChange('.5'); }); - expect(result.current.usdAmount).toBe('0.5'); + expect(result.current.fiatAmount).toBe('0.5'); expect(result.current.hasValidAmount).toBe(true); }); @@ -495,13 +513,13 @@ describe('useQuickBuyController', () => { result.current.handleAmountChange('25'); }); - expect(result.current.usdAmount).toBe('25'); + expect(result.current.fiatAmount).toBe('25'); expect(result.current.sliderPercent).toBe(0); }); }); describe('handleSliderChange', () => { - it('updates display state (sliderPercent, usdAmount) on every 1% tick', () => { + it('updates display state (sliderPercent, fiatAmount) on every 1% tick', () => { (useLatestBalance as jest.Mock).mockReturnValue({ displayBalance: '100', atomicBalance: '100000000', @@ -520,7 +538,7 @@ describe('useQuickBuyController', () => { }); expect(result.current.sliderPercent).toBe(50); - expect(Number(result.current.usdAmount)).toBeGreaterThan(0); + expect(Number(result.current.fiatAmount)).toBeGreaterThan(0); }); it('does not fire analytics during drag — only updates display', () => { @@ -599,6 +617,72 @@ describe('useQuickBuyController', () => { }); }); + describe('user-currency (non-USD)', () => { + it('formats the headline in the user currency while emitting amount_usd in USD', () => { + // EUR display currency; native ETH worth €1,000 / $1,200 → USD = EUR * 1.2. + (selectCurrentCurrency as unknown as jest.Mock).mockReturnValue('EUR'); + (selectCurrencyRates as unknown as jest.Mock).mockReturnValue({ + ETH: { conversionRate: 1000, usdConversionRate: 1200 }, + }); + (useLatestBalance as jest.Mock).mockReturnValue({ + displayBalance: '100', + atomicBalance: '100000000', + }); + const sourceWithRate = createSourceToken({ currencyExchangeRate: 1 }); + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [sourceWithRate], + }); + + const { result } = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + + act(() => { + result.current.handleAmountChange('20'); + }); + + // Headline is localized to the user's display currency, not hardcoded USD. + expect(result.current.fiatAmount).toBe('20'); + expect(result.current.fiatAmountLabel).toBe('€20.00'); + + // Committing via the slider emits analytics in USD (20 EUR * 1.2 = 24 USD). + act(() => { + result.current.handleSliderDragEnd(20); + }); + + expect(mockTrackAmountSelected).toHaveBeenCalledTimes(1); + expect(mockTrackAmountSelected.mock.calls[0][0]).toBeCloseTo(24); + }); + + it('uses two-decimal fiat state for JPY (same as Bridge fiat input)', () => { + (selectCurrentCurrency as unknown as jest.Mock).mockReturnValue('JPY'); + (selectCurrencyRates as unknown as jest.Mock).mockReturnValue({ + ETH: { conversionRate: 1000, usdConversionRate: 1000 }, + }); + (useLatestBalance as jest.Mock).mockReturnValue({ + displayBalance: '100', + atomicBalance: '100000000', + }); + const sourceWithRate = createSourceToken({ currencyExchangeRate: 1 }); + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [sourceWithRate], + }); + + const { result } = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + + act(() => { + result.current.handleSliderChange(33); + }); + + // 33% of a ¥100 cap = ¥33.00 in state (FIAT_INPUT_DECIMALS); headline + // still formats via Intl as whole yen. + expect(result.current.fiatAmount).toBe('33.00'); + expect(result.current.fiatAmountLabel).toBe('¥33'); + }); + }); + describe('max ("sell all") source amount', () => { // A near-$1 stablecoin whose rate is fractionally below 1. The fiat // round-trip (balance → cent-rounded USD → tokens) reconstructs an amount @@ -714,7 +798,7 @@ describe('useQuickBuyController', () => { result.current.handleAmountChange('25'); }); - expect(result.current.usdAmount).toBe('25'); + expect(result.current.fiatAmount).toBe('25'); expect(result.current.sliderPercent).toBe(0); act(() => { @@ -722,7 +806,7 @@ describe('useQuickBuyController', () => { }); expect(result.current.selectedSourceToken).toEqual(usdt); - expect(result.current.usdAmount).toBe(''); + expect(result.current.fiatAmount).toBe(''); expect(result.current.sliderPercent).toBe(0); }); }); @@ -854,7 +938,7 @@ describe('useQuickBuyController', () => { }); describe('isConfirmDisabled', () => { - it('is disabled when usdAmount is empty', () => { + it('is disabled when fiatAmount is empty', () => { const { result } = renderHook(() => useQuickBuyController(createTarget(), jest.fn()), ); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx index 1b3cef22b696..c74672bc7433 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx @@ -816,7 +816,7 @@ describe('TraderProfileView', () => { renderWithProvider(); // Sum is 100,000 — hyperliquid contribution ignored - expect(screen.getByText('+$100,000.00')).toBeOnTheScreen(); + expect(screen.getByText('+$100K')).toBeOnTheScreen(); // And the trader's global pnl30d (999,999) is NOT what we display expect(screen.queryByText('+$999,999.00')).not.toBeOnTheScreen(); }); @@ -840,7 +840,7 @@ describe('TraderProfileView', () => { renderWithProvider(); // -1000 + -2500 + 500 = -3000 - expect(screen.getByText('-$3,000.00')).toBeOnTheScreen(); + expect(screen.getByText('-$3K')).toBeOnTheScreen(); }); it('treats a missing chain entry as 0', () => { @@ -856,7 +856,7 @@ describe('TraderProfileView', () => { renderWithProvider(); - expect(screen.getByText('+$7,500.00')).toBeOnTheScreen(); + expect(screen.getByText('+$7.5K')).toBeOnTheScreen(); }); it('falls back to the global stats.pnl30d when perChainPnl is empty', () => { @@ -872,7 +872,7 @@ describe('TraderProfileView', () => { renderWithProvider(); - expect(screen.getByText('+$20,610.00')).toBeOnTheScreen(); + expect(screen.getByText('+$20.6K')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.test.tsx index 0171ffea66bf..e5f54464e0ea 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.test.tsx @@ -39,9 +39,15 @@ describe('StatsRow', () => { expect(screen.getByText('0%')).toBeOnTheScreen(); }); - it('renders formatted PnL when pnl30d is non-null and positive', () => { + it('abbreviates positive PnL with K suffix', () => { renderWithProvider(); - expect(screen.getByText('+$20,610.00')).toBeOnTheScreen(); + expect(screen.getByText('+$20.6K')).toBeOnTheScreen(); + }); + + it('abbreviates large positive PnL with M suffix', () => { + const stats = { ...baseStats, pnl30d: 1_170_000 }; + renderWithProvider(); + expect(screen.getByText('+$1.2M')).toBeOnTheScreen(); }); it('renders dash when pnl30d is null', () => { @@ -51,19 +57,19 @@ describe('StatsRow', () => { expect(dashes.length).toBeGreaterThanOrEqual(2); }); - it('renders negative PnL correctly', () => { + it('abbreviates negative PnL with K suffix', () => { const stats = { ...baseStats, pnl30d: -5000 }; renderWithProvider(); - expect(screen.getByText('-$5,000.00')).toBeOnTheScreen(); + expect(screen.getByText('-$5K')).toBeOnTheScreen(); }); - it('renders PnL for small values without K suffix', () => { + it('renders sub-$1K PnL with two decimal places (no abbreviation)', () => { const stats = { ...baseStats, pnl30d: 500 }; renderWithProvider(); expect(screen.getByText('+$500.00')).toBeOnTheScreen(); }); - it('rounds decimal PnL values to two places', () => { + it('rounds sub-$1K PnL to two decimal places', () => { const stats = { ...baseStats, pnl30d: 500.236 }; renderWithProvider(); expect(screen.getByText('+$500.24')).toBeOnTheScreen(); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.tsx index 3918fbb0456e..9cdb532ddea4 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.tsx @@ -10,9 +10,9 @@ import { BoxAlignItems, BoxJustifyContent, } from '@metamask/design-system-react-native'; -import I18n, { strings } from '../../../../../../locales/i18n'; +import { strings } from '../../../../../../locales/i18n'; import type { TraderStats } from '@metamask/social-controllers'; -import { formatWithThreshold } from '../../../../../util/assets'; +import { formatSignedAbbreviatedUsd } from '../../utils/formatters'; import { TraderProfileViewSelectorsIDs } from '../TraderProfileView.testIds'; export interface StatsRowProps { @@ -42,18 +42,6 @@ function formatHoldTime(minutes: number): string { }); } -function formatPnlWithCents(value: number): string { - const sign = value >= 0 ? '+' : '-'; - const formatted = formatWithThreshold(Math.abs(value), 0, I18n.locale, { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - - return `${sign}${formatted}`; -} - const StatsRow: React.FC = ({ stats, holdTimeMinutes }) => { const winRate = stats.winRate30d != null @@ -62,8 +50,7 @@ const StatsRow: React.FC = ({ stats, holdTimeMinutes }) => { const isWinRatePositive = (stats.winRate30d ?? 0) > 0; const hasPnl = stats.pnl30d != null; - const pnl = - stats.pnl30d != null ? formatPnlWithCents(stats.pnl30d) : '\u2014'; + const pnl = formatSignedAbbreviatedUsd(stats.pnl30d); const isPnlPositive = stats.pnl30d != null && stats.pnl30d >= 0; return ( diff --git a/app/components/Views/SocialLeaderboard/analytics/socialLeaderboardEvents.ts b/app/components/Views/SocialLeaderboard/analytics/socialLeaderboardEvents.ts index ac684e53028d..ccd77bf2b0b4 100644 --- a/app/components/Views/SocialLeaderboard/analytics/socialLeaderboardEvents.ts +++ b/app/components/Views/SocialLeaderboard/analytics/socialLeaderboardEvents.ts @@ -23,6 +23,7 @@ export const SocialLeaderboardEventProperties = { LATENCY_MS: 'latency_ms', MARKET_CAP: 'market_cap', NOTIFICATION_TYPE: 'notification_type', + NOTIFICATION_SUBTYPE: 'notification_subtype', PAY_WITH_TOKEN: 'pay_with_token', RECEIVE_TOKEN: 'receive_token', PRESET_VALUE: 'preset_value', diff --git a/app/components/Views/SocialLeaderboard/utils/formatters.test.ts b/app/components/Views/SocialLeaderboard/utils/formatters.test.ts index 3f62f32d03f8..af2e656642f9 100644 --- a/app/components/Views/SocialLeaderboard/utils/formatters.test.ts +++ b/app/components/Views/SocialLeaderboard/utils/formatters.test.ts @@ -1,6 +1,7 @@ import { formatUsd, formatSignedUsd, + formatSignedAbbreviatedUsd, formatTokenAmount, formatPercent, formatTradeDate, @@ -51,6 +52,44 @@ describe('formatSignedUsd', () => { }); }); +describe('formatSignedAbbreviatedUsd', () => { + it.each([ + [123, '+$123.00'], + [999, '+$999.00'], + [1_000, '+$1K'], + [5_123, '+$5.1K'], + [5_000, '+$5K'], + [20_610, '+$20.6K'], + [117_166, '+$117.2K'], + [999_999, '+$1M'], + [1_170_000, '+$1.2M'], + [3_200_000_000, '+$3.2B'], + [1.5e12, '+$1.5T'], + ])('formats %d as %s', (input, expected) => { + expect(formatSignedAbbreviatedUsd(input)).toBe(expected); + }); + + it('prefixes negative values with -', () => { + expect(formatSignedAbbreviatedUsd(-5_000)).toBe('-$5K'); + expect(formatSignedAbbreviatedUsd(-1_170_000)).toBe('-$1.2M'); + expect(formatSignedAbbreviatedUsd(-500.24)).toBe('-$500.24'); + }); + + it('renders zero without a sign', () => { + expect(formatSignedAbbreviatedUsd(0)).toBe('$0.00'); + }); + + it('shows two decimal places for sub-$1K values', () => { + expect(formatSignedAbbreviatedUsd(500.236)).toBe('+$500.24'); + expect(formatSignedAbbreviatedUsd(0.5)).toBe('+$0.50'); + }); + + it('returns an em dash for null and undefined', () => { + expect(formatSignedAbbreviatedUsd(null)).toBe('\u2014'); + expect(formatSignedAbbreviatedUsd(undefined)).toBe('\u2014'); + }); +}); + describe('formatTokenAmount', () => { it('abbreviates billions (e.g. 1.5B)', () => { expect(formatTokenAmount(1500000000)).toBe('1.50B'); diff --git a/app/components/Views/SocialLeaderboard/utils/formatters.ts b/app/components/Views/SocialLeaderboard/utils/formatters.ts index d71fc727d8d6..75b6d2978722 100644 --- a/app/components/Views/SocialLeaderboard/utils/formatters.ts +++ b/app/components/Views/SocialLeaderboard/utils/formatters.ts @@ -33,6 +33,50 @@ export function formatSignedUsd(value: number | null | undefined): string { return sign + formatPerpsFiat(Math.abs(value), { stripTrailingZeros: false }); } +// Ordered largest → smallest. Walk down and promote when rounding pushes a +// value past the bucket boundary (e.g. `999_999` rounds to `1000K`, which +// we want as `$1M`, not `$1000K`). +const CURRENCY_BUCKETS: readonly (readonly [number, string])[] = [ + [1e12, 'T'], + [1e9, 'B'], + [1e6, 'M'], + [1e3, 'K'], +]; + +function renderBucket(value: number, suffix: string): string { + const str = value % 1 === 0 ? value.toFixed(0) : value.toFixed(1); + return `$${str}${suffix}`; +} + +function shortenAbsCurrency(abs: number): string { + if (abs < 1e3) return `$${abs.toFixed(2)}`; + for (let i = 0; i < CURRENCY_BUCKETS.length; i++) { + const [divisor, suffix] = CURRENCY_BUCKETS[i]; + if (abs < divisor) continue; + const value = Math.round((abs / divisor) * 10) / 10; + if (value >= 1_000 && i > 0) { + const [nextDivisor, nextSuffix] = CURRENCY_BUCKETS[i - 1]; + const promoted = Math.round((abs / nextDivisor) * 10) / 10; + return renderBucket(promoted, nextSuffix); + } + return renderBucket(value, suffix); + } + return `$${abs.toFixed(2)}`; +} + +/** + * Signed USD with K/M/B/T abbreviation for ≥$1K values. Used for compact + * PnL displays like the 30D Return headline (`+$117.2K`, `+$1.2M`, `-$500`). + */ +export function formatSignedAbbreviatedUsd( + value: number | null | undefined, +): string { + if (value == null) return EM_DASH; + if (value === 0) return shortenAbsCurrency(0); + const sign = value > 0 ? '+' : '-'; + return sign + shortenAbsCurrency(Math.abs(value)); +} + /** * Formats a raw token quantity for display in list rows. * - Values >= 1,000 are abbreviated with K/M/B/T suffixes (e.g. 216.65M). diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.test.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.test.tsx index df656307e9de..c70337d06f2a 100644 --- a/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.test.tsx +++ b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.test.tsx @@ -90,16 +90,16 @@ const Skeleton = () => sk; const STOCKS_MARKETS = [makeMarket('AAPL'), makeMarket('GOOGL')]; const COMMODITY_MARKETS = [makeMarket('GOLD')]; -const DEFAULT_TABS = [ - { key: 'stocks', name: 'Stocks', items: STOCKS_MARKETS }, - { key: 'commodities', name: 'Commodities', items: COMMODITY_MARKETS }, +const DEFAULT_TABS: PerpsToggleBlockProps['tabs'] = [ + { key: 'stock', name: 'Stocks', items: STOCKS_MARKETS }, + { key: 'commodity', name: 'Commodities', items: COMMODITY_MARKETS }, ]; const DEFAULT_PROPS: PerpsToggleBlockProps = { title: 'Stocks & Commodities', tabs: DEFAULT_TABS, isLoading: false, - defaultPillKey: 'stocks', + defaultPillKey: 'stock', onViewAll: jest.fn(), sortOptionId: 'volume', tabName: 'Macro', @@ -137,8 +137,8 @@ describe('PerpsToggleBlock', () => { it('renders pill buttons for each tab', () => { const { getByTestId } = renderBlock(); - expect(getByTestId('test-toggle-pill-stocks')).toBeTruthy(); - expect(getByTestId('test-toggle-pill-commodities')).toBeTruthy(); + expect(getByTestId('test-toggle-pill-stock')).toBeTruthy(); + expect(getByTestId('test-toggle-pill-commodity')).toBeTruthy(); }); }); @@ -153,25 +153,25 @@ describe('PerpsToggleBlock', () => { }); describe('pill switching', () => { - it('shows the commodities items after selecting the commodities pill', () => { + it('shows the commodity items after selecting the commodity pill', () => { const { getByTestId, queryByTestId } = renderBlock(); act(() => { - fireEvent.press(getByTestId('test-toggle-pill-commodities')); + fireEvent.press(getByTestId('test-toggle-pill-commodity')); }); expect(getByTestId('perps-row-GOLD')).toBeTruthy(); expect(queryByTestId('perps-row-AAPL')).toBeNull(); }); - it('switching back to stocks shows stocks items again', () => { + it('switching back to stock shows stock items again', () => { const { getByTestId } = renderBlock(); act(() => { - fireEvent.press(getByTestId('test-toggle-pill-commodities')); + fireEvent.press(getByTestId('test-toggle-pill-commodity')); }); act(() => { - fireEvent.press(getByTestId('test-toggle-pill-stocks')); + fireEvent.press(getByTestId('test-toggle-pill-stock')); }); expect(getByTestId('perps-row-AAPL')).toBeTruthy(); @@ -186,7 +186,7 @@ describe('PerpsToggleBlock', () => { fireEvent.press(getByTestId('section-header-view-all-test')); expect(onViewAll).toHaveBeenCalledTimes(1); - expect(onViewAll).toHaveBeenCalledWith('stocks', 'volume'); + expect(onViewAll).toHaveBeenCalledWith('stock', 'volume'); }); it('calls onViewAll with the newly active pill key after switching pills', () => { @@ -194,12 +194,12 @@ describe('PerpsToggleBlock', () => { const { getByTestId } = renderBlock({ ...DEFAULT_PROPS, onViewAll }); act(() => { - fireEvent.press(getByTestId('test-toggle-pill-commodities')); + fireEvent.press(getByTestId('test-toggle-pill-commodity')); }); fireEvent.press(getByTestId('section-header-view-all-test')); - expect(onViewAll).toHaveBeenCalledWith('commodities', 'volume'); + expect(onViewAll).toHaveBeenCalledWith('commodity', 'volume'); }); it('forwards the sortOptionId prop unchanged regardless of active pill', () => { @@ -211,11 +211,11 @@ describe('PerpsToggleBlock', () => { }); act(() => { - fireEvent.press(getByTestId('test-toggle-pill-commodities')); + fireEvent.press(getByTestId('test-toggle-pill-commodity')); }); fireEvent.press(getByTestId('section-header-view-all-test')); - expect(onViewAll).toHaveBeenCalledWith('commodities', 'priceChange'); + expect(onViewAll).toHaveBeenCalledWith('commodity', 'priceChange'); }); it('calls onViewAll only once per press', () => { @@ -229,6 +229,73 @@ describe('PerpsToggleBlock', () => { }); }); + describe('empty-tab filtering', () => { + it('hides a pill whose items array is empty once loaded', () => { + const tabs: PerpsToggleBlockProps['tabs'] = [ + { key: 'stock', name: 'Stocks', items: STOCKS_MARKETS }, + { key: 'commodity', name: 'Commodities', items: [] }, + ]; + const { getByTestId, queryByTestId } = renderBlock({ + ...DEFAULT_PROPS, + tabs, + isLoading: false, + }); + + expect(getByTestId('test-toggle-pill-stock')).toBeTruthy(); + expect(queryByTestId('test-toggle-pill-commodity')).toBeNull(); + }); + + it('shows all pills while loading even if items are empty', () => { + const tabs: PerpsToggleBlockProps['tabs'] = [ + { key: 'stock', name: 'Stocks', items: [] }, + { key: 'commodity', name: 'Commodities', items: [] }, + ]; + const { getByTestId } = renderBlock({ + ...DEFAULT_PROPS, + tabs, + isLoading: true, + }); + + expect(getByTestId('test-toggle-pill-stock')).toBeTruthy(); + expect(getByTestId('test-toggle-pill-commodity')).toBeTruthy(); + }); + + it('defaults to the first pill that has items', () => { + const tabs: PerpsToggleBlockProps['tabs'] = [ + { key: 'stock', name: 'Stocks', items: [] }, + { key: 'commodity', name: 'Commodities', items: COMMODITY_MARKETS }, + ]; + const { getByTestId } = renderBlock({ + ...DEFAULT_PROPS, + tabs, + defaultPillKey: 'stock', + isLoading: false, + }); + + // commodity pill is active (stock was empty and hidden) + expect(getByTestId('perps-row-GOLD')).toBeTruthy(); + }); + + it('calls onViewAll with the first visible category when the default pill is empty', () => { + const onViewAll = jest.fn(); + const tabs: PerpsToggleBlockProps['tabs'] = [ + { key: 'stock', name: 'Stocks', items: [] }, + { key: 'commodity', name: 'Commodities', items: COMMODITY_MARKETS }, + ]; + const { getByTestId } = renderBlock({ + ...DEFAULT_PROPS, + tabs, + defaultPillKey: 'stock', + isLoading: false, + onViewAll, + }); + + fireEvent.press(getByTestId('section-header-view-all-test')); + + expect(onViewAll).toHaveBeenCalledWith('commodity', 'volume'); + }); + }); + describe('analytics', () => { it('calls trackExploreInteracted with correct context when a row is pressed', () => { const mockTrack = trackExploreInteracted as jest.Mock; diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx index bed6dd344a35..e221094067ca 100644 --- a/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx +++ b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx @@ -1,7 +1,11 @@ -import React, { useCallback, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Box } from '@metamask/design-system-react-native'; import type { ListRenderItem } from '@shopify/flash-list'; -import type { PerpsMarketData, SortOptionId } from '@metamask/perps-controller'; +import { + type MarketTypeFilter, + type PerpsMarketData, + type SortOptionId, +} from '@metamask/perps-controller'; import PerpsRowItem from './PerpsRowItem'; import PerpsRowSkeleton from '../../../../UI/Perps/components/PerpsRowSkeleton'; import PillToggleCardList, { @@ -14,14 +18,17 @@ import { trackExploreInteracted, } from '../../search/analytics'; +/** Valid perps category filter keys — all `MarketTypeFilter` values except `'all'`. */ +export type PerpsFilterKey = Exclude; + const PerpsRowSingleSkeleton: React.FC = () => ; export interface PerpsToggleBlockProps { title: string; tabs: PillToggleCardListTab[]; isLoading: boolean; - defaultPillKey: string; - onViewAll: (filter: string, sortOptionId: SortOptionId) => void; + defaultPillKey: PerpsFilterKey; + onViewAll: (filter: PerpsFilterKey, sortOptionId: SortOptionId) => void; sortOptionId: SortOptionId; /** Analytics context */ tabName: ExploreTabName; @@ -51,7 +58,27 @@ const PerpsToggleBlock: React.FC = ({ testIdPrefix, listTestId, }) => { - const activePillKey = useRef(defaultPillKey); + const visibleTabs = useMemo( + () => (isLoading ? tabs : tabs.filter((t) => t.items.length > 0)), + [isLoading, tabs], + ); + + const firstVisibleKey = (visibleTabs[0]?.key ?? + defaultPillKey) as PerpsFilterKey; + const [activePillKey, setActivePillKey] = + useState(firstVisibleKey); + + useEffect(() => { + setActivePillKey((current) => + visibleTabs.some((tab) => tab.key === current) + ? current + : firstVisibleKey, + ); + }, [firstVisibleKey, visibleTabs]); + + const handlePillChange = useCallback((key: string) => { + setActivePillKey(key as PerpsFilterKey); + }, []); const renderItem: ListRenderItem = useCallback( ({ item, index }) => ( @@ -76,20 +103,20 @@ const PerpsToggleBlock: React.FC = ({ onViewAll(activePillKey.current, sortOptionId)} + onViewAll={() => onViewAll(activePillKey, sortOptionId)} testID={headerTestID} tabName={tabName} sectionName={sectionName} /> - tabs={tabs} + key={visibleTabs.map((tab) => tab.key).join(',')} + tabs={visibleTabs} isLoading={isLoading} renderItem={renderItem} Skeleton={PerpsRowSingleSkeleton} idPrefix={idPrefix} - onPillChange={(key) => { - activePillKey.current = key; - }} + defaultPillKey={firstVisibleKey} + onPillChange={handlePillChange} testIdPrefix={testIdPrefix} listTestId={listTestId} /> diff --git a/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts index 9b9d3e60ad9a..dccbc81486ea 100644 --- a/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts +++ b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts @@ -1,6 +1,7 @@ import { NavigationProp } from '@react-navigation/native'; import { PERPS_EVENT_VALUE, + type MarketTypeFilter, type SortDirection, type SortOptionId, } from '@metamask/perps-controller'; @@ -18,7 +19,7 @@ interface NavigateToPerpsMarketListOptions { /** Navigate to the perps market list, optionally pre-filtering by market type and pre-sorting by a sort option. */ export const navigateToPerpsMarketList = ( navigation: NavigationProp, - filter: string = 'all', + filter: MarketTypeFilter = 'all', sortOptionId?: SortOptionId, { source = PERPS_EVENT_VALUE.SOURCE.EXPLORE, diff --git a/app/components/Views/TrendingView/tabs/MacroTab.test.tsx b/app/components/Views/TrendingView/tabs/MacroTab.test.tsx new file mode 100644 index 000000000000..31725963c6e0 --- /dev/null +++ b/app/components/Views/TrendingView/tabs/MacroTab.test.tsx @@ -0,0 +1,220 @@ +/** + * MacroTab — unit tests + * + * Covers: + * 1. Renders perps section when data is available. + * 2. Returns null for the perps block when data is empty and not loading. + * 3. Hides the perps section when the perps feature flag is off. + * 4. Calls navigateToPerpsMarketList with the active filter and sort option when "View All" is pressed. + */ + +jest.mock('@shopify/flash-list', () => { + const RN = jest.requireActual('react-native'); + return { FlashList: RN.FlatList }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => { + const twFn = () => ({}); + twFn.style = () => ({}); + return { useTailwind: () => twFn }; +}); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('../../../UI/Perps', () => ({ + selectPerpsEnabledFlag: jest.fn(() => true), + PerpsSectionProvider: ({ children }: { children: React.ReactNode }) => + children, +})); + +jest.mock('../../../UI/Predict', () => ({ + selectPredictEnabledFlag: jest.fn(() => false), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), +})); + +jest.mock('../search/analytics', () => ({ + trackExploreInteracted: jest.fn(), + trackExploreSectionSeeAll: jest.fn(), +})); + +jest.mock('../feeds/perps/usePerpsFeed'); +jest.mock('../feeds/predictions/usePredictionsFeed'); + +jest.mock('../feeds/predictions/PredictionsCarouselSection', () => { + const { View } = jest.requireActual('react-native'); + return function MockPredictionsCarouselSection() { + return ; + }; +}); + +jest.mock('../feeds/perps/PerpsSectionProvider', () => { + const { Fragment } = jest.requireActual('react'); + return function MockPerpsSectionProvider({ + children, + }: { + children: React.ReactNode; + }) { + return {children}; + }; +}); + +const mockNavigateToPerpsMarketList = jest.fn(); +jest.mock('../feeds/perps/perpsNavigation', () => ({ + navigateToPerpsMarketList: (...args: unknown[]) => + mockNavigateToPerpsMarketList(...args), +})); + +jest.mock('../feeds/perps/PerpsToggleBlock', () => { + const { TouchableOpacity, Text: RNText } = jest.requireActual('react-native'); + const MockPerpsToggleBlock = ({ + onViewAll, + sortOptionId, + defaultPillKey, + headerTestID, + }: { + title: string; + onViewAll: (filter: string, sort: string) => void; + sortOptionId: string; + defaultPillKey: string; + headerTestID: string; + isLoading: boolean; + }) => ( + onViewAll(defaultPillKey, sortOptionId)} + > + perps-block + + ); + MockPerpsToggleBlock.displayName = 'MockPerpsToggleBlock'; + return MockPerpsToggleBlock; +}); + +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; +import type { PerpsMarketData } from '@metamask/perps-controller'; +import { selectPerpsEnabledFlag } from '../../../UI/Perps'; +import { usePerpsFeed } from '../feeds/perps/usePerpsFeed'; +import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; +import MacroTab from './MacroTab'; + +const mockUsePerpsFeed = jest.mocked(usePerpsFeed); +const mockUsePredictionsFeed = jest.mocked(usePredictionsFeed); + +const mockNavigation = { navigate: jest.fn() }; + +const makeMarket = (symbol: string, type: string): PerpsMarketData => + ({ + symbol, + name: symbol, + marketType: type, + price: '$1.00', + change24h: '+1%', + change24hPercent: '1', + volume: '$100M', + maxLeverage: '10x', + isHip3: false, + }) as PerpsMarketData; + +const makeFeedItem = (market: PerpsMarketData) => ({ + market, + isWatchlisted: false, +}); + +const DEFAULT_PERPS_FEED = { + data: [ + makeFeedItem(makeMarket('AAPL', 'stock')), + makeFeedItem(makeMarket('MSFT', 'pre-ipo')), + makeFeedItem(makeMarket('GOLD', 'commodity')), + ], + isLoading: false, + defaultSortOptionId: 'volume' as const, + refetch: jest.fn(), +}; + +const DEFAULT_REFRESH = { trigger: 0, silentRefresh: false }; + +const renderTab = () => + render( + , + ); + +describe('MacroTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue(mockNavigation); + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectPerpsEnabledFlag) return true; + return false; + }); + mockUsePerpsFeed.mockReturnValue(DEFAULT_PERPS_FEED); + mockUsePredictionsFeed.mockReturnValue({ + data: [], + isLoading: false, + refetch: jest.fn(), + }); + }); + + it('renders the perps toggle block when data is available', () => { + const { getByTestId } = renderTab(); + expect(getByTestId('mock-perps-toggle-block')).toBeTruthy(); + }); + + it('does not render the perps block when data is empty and not loading', () => { + mockUsePerpsFeed.mockReturnValue({ + ...DEFAULT_PERPS_FEED, + data: [], + isLoading: false, + }); + + const { queryByTestId } = renderTab(); + expect(queryByTestId('mock-perps-toggle-block')).toBeNull(); + }); + + it('renders the perps block while loading even with no data', () => { + mockUsePerpsFeed.mockReturnValue({ + ...DEFAULT_PERPS_FEED, + data: [], + isLoading: true, + }); + + const { getByTestId } = renderTab(); + expect(getByTestId('mock-perps-toggle-block')).toBeTruthy(); + }); + + it('does not render the perps section when perps feature flag is off', () => { + (useSelector as jest.Mock).mockReturnValue(false); + + const { queryByTestId } = renderTab(); + expect(queryByTestId('mock-perps-toggle-block')).toBeNull(); + }); + + it('calls navigateToPerpsMarketList with correct filter when View All is pressed', () => { + const { getByTestId } = renderTab(); + + act(() => { + fireEvent.press( + getByTestId('section-header-view-all-macro_stocks_commodity_perps'), + ); + }); + + expect(mockNavigateToPerpsMarketList).toHaveBeenCalledWith( + mockNavigation, + 'stock', + 'volume', + ); + }); +}); diff --git a/app/components/Views/TrendingView/tabs/MacroTab.tsx b/app/components/Views/TrendingView/tabs/MacroTab.tsx index f254933b1ce9..74e8e957decc 100644 --- a/app/components/Views/TrendingView/tabs/MacroTab.tsx +++ b/app/components/Views/TrendingView/tabs/MacroTab.tsx @@ -1,16 +1,22 @@ import React, { useMemo } from 'react'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; -import type { PerpsMarketData, SortOptionId } from '@metamask/perps-controller'; -import { isEquityAsset } from '../../../UI/Perps/utils/marketHours'; +import { + applyMarketFilters, + type PerpsMarketData, + type SortOptionId, +} from '@metamask/perps-controller'; import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; import type { AppNavigationProp } from '../../../../core/NavigationService/types'; import { selectPerpsEnabledFlag } from '../../../UI/Perps'; import { selectPredictEnabledFlag } from '../../../UI/Predict'; +import { normalizeFilterKey } from '../../../UI/Perps/utils/marketCategoryMapping'; import { strings } from '../../../../../locales/i18n'; import { usePerpsFeed } from '../feeds/perps/usePerpsFeed'; import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; -import PerpsToggleBlock from '../feeds/perps/PerpsToggleBlock'; +import PerpsToggleBlock, { + type PerpsFilterKey, +} from '../feeds/perps/PerpsToggleBlock'; import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarouselSection'; @@ -19,9 +25,18 @@ import ExploreScroll from '../components/ExploreScroll'; import type { PillToggleCardListTab } from '../components/PillToggleCardList'; import type { TabProps } from '../hooks/useExploreRefresh'; +/** Perps category pills for the Macro tab, in perps display order. */ +const MACRO_PERPS_CATEGORIES: PerpsFilterKey[] = [ + 'stock', + 'pre-ipo', + 'commodity', + 'index', + 'etf', +]; + interface MacroPerpsBlockProps { refresh: TabProps['refresh']; - onViewAll: (filter: string, sortOptionId: SortOptionId) => void; + onViewAll: (filter: PerpsFilterKey, sortOptionId: SortOptionId) => void; } const MacroPerpsBlock: React.FC = ({ @@ -29,30 +44,20 @@ const MacroPerpsBlock: React.FC = ({ onViewAll, }) => { const perps = usePerpsFeed({ variant: 'macro', refresh }); + const markets = useMemo(() => perps.data.map((d) => d.market), [perps.data]); - const tabs = useMemo[]>(() => { - const byType = (type: PerpsMarketData['marketType']) => - perps.data - .filter((d) => d.market.marketType === type) - .slice(0, 3) - .map((d) => d.market); - const stockLikeItems = perps.data - .filter((d) => isEquityAsset(d.market.marketType)) - .slice(0, 3) - .map((d) => d.market); - return [ - { - key: 'stocks', - name: strings('trending.macro_pill_stocks'), - items: stockLikeItems, - }, - { - key: 'commodities', - name: strings('trending.macro_pill_commodities'), - items: byType('commodity'), - }, - ]; - }, [perps.data]); + const tabs = useMemo[]>( + () => + MACRO_PERPS_CATEGORIES.map((category) => ({ + key: category, + name: strings(`perps.home.tabs.${normalizeFilterKey(category)}`), + items: applyMarketFilters(markets, { + categories: [category], + limit: 3, + }), + })), + [markets], + ); if (!perps.isLoading && perps.data.length === 0) return null; @@ -61,7 +66,7 @@ const MacroPerpsBlock: React.FC = ({ title={strings('trending.macro_stocks_commodity_perps')} tabs={tabs} isLoading={perps.isLoading} - defaultPillKey="stocks" + defaultPillKey={MACRO_PERPS_CATEGORIES[0]} onViewAll={onViewAll} sortOptionId={perps.defaultSortOptionId} tabName="Macro" diff --git a/app/components/Views/TrendingView/tabs/RwasTab.test.tsx b/app/components/Views/TrendingView/tabs/RwasTab.test.tsx new file mode 100644 index 000000000000..bc942d9447b2 --- /dev/null +++ b/app/components/Views/TrendingView/tabs/RwasTab.test.tsx @@ -0,0 +1,306 @@ +/** + * RwasTab — unit tests + * + * Covers: + * 1. Renders the perps toggle block when data is available. + * 2. Returns null for the perps block when data is empty and not loading. + * 3. Hides the perps section when the perps feature flag is off. + * 4. Calls navigateToPerpsMarketList with the correct filter when "View All" is pressed. + * 5. Renders the stocks section when stocks data is available. + */ + +jest.mock('@shopify/flash-list', () => { + const RN = jest.requireActual('react-native'); + return { FlashList: RN.FlatList }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => { + const twFn = () => ({}); + twFn.style = () => ({}); + return { useTailwind: () => twFn }; +}); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), +})); + +jest.mock('../search/analytics', () => ({ + trackExploreInteracted: jest.fn(), + trackExploreSectionSeeAll: jest.fn(), +})); + +jest.mock('../../../UI/Perps', () => ({ + selectPerpsEnabledFlag: jest.fn(() => true), +})); + +jest.mock('../../../UI/Predict', () => ({ + selectPredictEnabledFlag: jest.fn(() => false), +})); + +jest.mock('../feeds/perps/usePerpsFeed'); +jest.mock('../feeds/stocks/useStocksFeed', () => ({ + useStocksFeed: jest.fn(), + STOCKS_FEED_PREVIEW_PAGE_SIZE: 3, +})); +jest.mock('../feeds/predictions/usePredictionsFeed'); + +jest.mock('../feeds/predictions/PredictionsCarouselSection', () => { + const { View } = jest.requireActual('react-native'); + return function MockPredictionsCarouselSection() { + return ; + }; +}); + +jest.mock('../feeds/perps/PerpsSectionProvider', () => { + const { Fragment } = jest.requireActual('react'); + return function MockPerpsSectionProvider({ + children, + }: { + children: React.ReactNode; + }) { + return {children}; + }; +}); + +const mockNavigateToPerpsMarketList = jest.fn(); +jest.mock('../feeds/perps/perpsNavigation', () => ({ + navigateToPerpsMarketList: (...args: unknown[]) => + mockNavigateToPerpsMarketList(...args), +})); + +jest.mock('../feeds/tokens/TokenRowItem', () => { + const { View } = jest.requireActual('react-native'); + return { + TokenRowItem: function MockTokenRowItem() { + return ; + }, + }; +}); + +jest.mock( + '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton', + () => { + const { View } = jest.requireActual('react-native'); + return function MockTrendingTokensSkeleton() { + return ; + }; + }, +); + +jest.mock('../../../UI/Trending/components/TrendingTokenRowItem/utils', () => ({ + getCaipChainIdFromAssetId: jest.fn(() => 'eip155:1'), +})); + +jest.mock('../feeds/perps/PerpsToggleBlock', () => { + const { TouchableOpacity, Text: RNText } = jest.requireActual('react-native'); + const MockPerpsToggleBlock = ({ + onViewAll, + sortOptionId, + defaultPillKey, + headerTestID, + }: { + title: string; + onViewAll: (filter: string, sort: string) => void; + sortOptionId: string; + defaultPillKey: string; + headerTestID: string; + isLoading: boolean; + }) => ( + onViewAll(defaultPillKey, sortOptionId)} + > + perps-block + + ); + MockPerpsToggleBlock.displayName = 'MockPerpsToggleBlock'; + return MockPerpsToggleBlock; +}); + +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; +import type { PerpsMarketData } from '@metamask/perps-controller'; +import { selectPerpsEnabledFlag } from '../../../UI/Perps'; +import { usePerpsFeed } from '../feeds/perps/usePerpsFeed'; +import { useStocksFeed } from '../feeds/stocks/useStocksFeed'; +import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; +import RwasTab from './RwasTab'; + +const mockUsePerpsFeed = jest.mocked(usePerpsFeed); +const mockUseStocksFeed = jest.mocked(useStocksFeed); +const mockUsePredictionsFeed = jest.mocked(usePredictionsFeed); + +const mockNavigation = { navigate: jest.fn() }; + +const makeMarket = (symbol: string, type: string): PerpsMarketData => + ({ + symbol, + name: symbol, + marketType: type, + price: '$1.00', + change24h: '+1%', + change24hPercent: '1', + volume: '$100M', + maxLeverage: '10x', + isHip3: false, + }) as PerpsMarketData; + +const makeFeedItem = (market: PerpsMarketData) => ({ + market, + isWatchlisted: false, +}); + +const DEFAULT_PERPS_FEED = { + data: [ + makeFeedItem(makeMarket('GOLD', 'commodity')), + makeFeedItem(makeMarket('AAPL', 'stock')), + makeFeedItem(makeMarket('EURUSD', 'forex')), + ], + isLoading: false, + defaultSortOptionId: 'priceChange' as const, + refetch: jest.fn(), +}; + +const DEFAULT_REFRESH = { trigger: 0, silentRefresh: false }; + +const renderTab = () => + render( + , + ); + +describe('RwasTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue(mockNavigation); + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectPerpsEnabledFlag) return true; + return false; + }); + mockUsePerpsFeed.mockReturnValue(DEFAULT_PERPS_FEED); + mockUseStocksFeed.mockReturnValue({ + data: [], + isLoading: false, + refetch: jest.fn(), + }); + mockUsePredictionsFeed.mockReturnValue({ + data: [], + isLoading: false, + refetch: jest.fn(), + }); + }); + + it('renders the perps toggle block when data is available', () => { + const { getByTestId } = renderTab(); + expect(getByTestId('mock-perps-toggle-block')).toBeTruthy(); + }); + + it('does not render the perps block when data is empty and not loading', () => { + mockUsePerpsFeed.mockReturnValue({ + ...DEFAULT_PERPS_FEED, + data: [], + isLoading: false, + }); + + const { queryByTestId } = renderTab(); + expect(queryByTestId('mock-perps-toggle-block')).toBeNull(); + }); + + it('renders the perps block while loading even with no data', () => { + mockUsePerpsFeed.mockReturnValue({ + ...DEFAULT_PERPS_FEED, + data: [], + isLoading: true, + }); + + const { getByTestId } = renderTab(); + expect(getByTestId('mock-perps-toggle-block')).toBeTruthy(); + }); + + it('does not render the perps section when perps feature flag is off', () => { + (useSelector as jest.Mock).mockReturnValue(false); + + const { queryByTestId } = renderTab(); + expect(queryByTestId('mock-perps-toggle-block')).toBeNull(); + }); + + it('calls navigateToPerpsMarketList with correct filter when View All is pressed', () => { + const { getByTestId } = renderTab(); + + act(() => { + fireEvent.press(getByTestId('section-header-view-all-rwa_perps')); + }); + + expect(mockNavigateToPerpsMarketList).toHaveBeenCalledWith( + mockNavigation, + 'stock', + 'priceChange', + ); + }); + + it('renders stocks section when stocks data is present', () => { + mockUseStocksFeed.mockReturnValue({ + data: [ + { + symbol: 'AAPL', + name: 'Apple', + assetId: 'eip155:1/erc20:0xabc', + decimals: 18, + price: '150', + aggregatedUsdVolume: 1000, + marketCap: 2000, + }, + ], + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId } = renderTab(); + expect(getByTestId('section-header-view-all-stocks')).toBeTruthy(); + }); + + it('hides stocks section when stocks data is empty and not loading', () => { + mockUseStocksFeed.mockReturnValue({ + data: [], + isLoading: false, + refetch: jest.fn(), + }); + + const { queryByTestId } = renderTab(); + expect(queryByTestId('section-header-view-all-stocks')).toBeNull(); + }); + + it('navigates to RWA tokens full view when stocks View All is pressed', () => { + mockUseStocksFeed.mockReturnValue({ + data: [ + { + symbol: 'AAPL', + name: 'Apple', + assetId: 'eip155:1/erc20:0xabc', + decimals: 18, + price: '150', + aggregatedUsdVolume: 1000, + marketCap: 2000, + }, + ], + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId } = renderTab(); + fireEvent.press(getByTestId('section-header-view-all-stocks')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('RWATokensFullView'); + }); +}); diff --git a/app/components/Views/TrendingView/tabs/RwasTab.tsx b/app/components/Views/TrendingView/tabs/RwasTab.tsx index 79b067807f7a..d5dde08ffc3a 100644 --- a/app/components/Views/TrendingView/tabs/RwasTab.tsx +++ b/app/components/Views/TrendingView/tabs/RwasTab.tsx @@ -4,8 +4,11 @@ import { useSelector } from 'react-redux'; import { Box } from '@metamask/design-system-react-native'; import type { ListRenderItem } from '@shopify/flash-list'; import type { TrendingAsset } from '@metamask/assets-controllers'; -import type { PerpsMarketData, SortOptionId } from '@metamask/perps-controller'; -import { isEquityAsset } from '../../../UI/Perps/utils/marketHours'; +import { + applyMarketFilters, + type PerpsMarketData, + type SortOptionId, +} from '@metamask/perps-controller'; import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; import type { AppNavigationProp } from '../../../../core/NavigationService/types'; import { selectPerpsEnabledFlag } from '../../../UI/Perps'; @@ -22,7 +25,10 @@ import { import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils'; import { usePerpsFeed } from '../feeds/perps/usePerpsFeed'; import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; -import PerpsToggleBlock from '../feeds/perps/PerpsToggleBlock'; +import PerpsToggleBlock, { + type PerpsFilterKey, +} from '../feeds/perps/PerpsToggleBlock'; +import { normalizeFilterKey } from '../../../UI/Perps/utils/marketCategoryMapping'; import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarouselSection'; @@ -35,9 +41,18 @@ import type { TabProps } from '../hooks/useExploreRefresh'; import { trackExploreInteracted } from '../search/analytics'; import { TrendingViewSelectorsIDs } from '../TrendingView.testIds'; +/** Perps category pills for the RWAs tab, in perps display order. */ +const RWA_PERPS_CATEGORIES: PerpsFilterKey[] = [ + 'stock', + 'pre-ipo', + 'forex', + 'index', + 'etf', +]; + interface RwaPerpsBlockProps { refresh: TabProps['refresh']; - onViewAll: (filter: string, sortOptionId: SortOptionId) => void; + onViewAll: (filter: PerpsFilterKey, sortOptionId: SortOptionId) => void; } const RwaPerpsBlock: React.FC = ({ @@ -45,35 +60,20 @@ const RwaPerpsBlock: React.FC = ({ onViewAll, }) => { const perps = usePerpsFeed({ variant: 'rwa', refresh }); + const markets = useMemo(() => perps.data.map((d) => d.market), [perps.data]); - const tabs = useMemo[]>(() => { - const byType = (type: PerpsMarketData['marketType']) => - perps.data - .filter((d) => d.market.marketType === type) - .slice(0, 3) - .map((d) => d.market); - const stockLikeItems = perps.data - .filter((d) => isEquityAsset(d.market.marketType)) - .slice(0, 3) - .map((d) => d.market); - return [ - { - key: 'commodities', - name: strings('trending.rwa_pill_commodities'), - items: byType('commodity'), - }, - { - key: 'stocks', - name: strings('trending.rwa_pill_stocks'), - items: stockLikeItems, - }, - { - key: 'forex', - name: strings('trending.rwa_pill_forex'), - items: byType('forex'), - }, - ]; - }, [perps.data]); + const tabs = useMemo[]>( + () => + RWA_PERPS_CATEGORIES.map((category) => ({ + key: category, + name: strings(`perps.home.tabs.${normalizeFilterKey(category)}`), + items: applyMarketFilters(markets, { + categories: [category], + limit: 3, + }), + })), + [markets], + ); if (!perps.isLoading && perps.data.length === 0) return null; @@ -82,7 +82,7 @@ const RwaPerpsBlock: React.FC = ({ title={strings('trending.rwa_perps_section')} tabs={tabs} isLoading={perps.isLoading} - defaultPillKey="commodities" + defaultPillKey={RWA_PERPS_CATEGORIES[0]} onViewAll={onViewAll} sortOptionId={perps.defaultSortOptionId} tabName="RWAs" diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index 61eb0c3b8e60..569940b275a8 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -86,6 +86,11 @@ jest.mock('../../UI/Money/selectors/featureFlags', () => ({ selectMoneyEnableMoneyAccountFlag: jest.fn(() => mockMoneyAccountEnabled), })); +const mockMoneyAccountGeoEligible = true; +jest.mock('../../UI/Money/selectors/eligibility', () => ({ + selectIsMoneyAccountGeoEligible: jest.fn(() => mockMoneyAccountGeoEligible), +})); + // Mock MoneyBalanceCard so the integration test does not depend on its hooks/contexts. jest.mock('../../UI/Money/components/MoneyBalanceCard', () => { const ReactMock = jest.requireActual('react'); diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 7869a39bd4d7..0a0a10b97eae 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -57,6 +57,7 @@ import PickerAccount from '../../../component-library/components/Pickers/PickerA import AddressCopy from '../../UI/AddressCopy'; import CardButton from '../../UI/Card/components/CardButton'; import { selectMoneyEnableMoneyAccountFlag } from '../../UI/Money/selectors/featureFlags'; +import { selectIsMoneyAccountGeoEligible } from '../../UI/Money/selectors/eligibility'; import MoneyBalanceCard from '../../UI/Money/components/MoneyBalanceCard'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog import { createAccountSelectorNavDetails } from '../AccountSelector'; @@ -410,6 +411,11 @@ const Wallet = ({ const selectedInternalAccount = useSelector(selectSelectedInternalAccount); const isMoneyAccountEnabled = useSelector(selectMoneyEnableMoneyAccountFlag); + const isMoneyAccountGeoEligible = useSelector( + selectIsMoneyAccountGeoEligible, + ); + const isMoneyAccountVisible = + isMoneyAccountEnabled && isMoneyAccountGeoEligible; /** * Provider configuration for the current selected network @@ -1038,8 +1044,10 @@ const Wallet = ({ {bannerContent} {walletHomeMainAssetDetailsActions} - {homeGrowthBannerContent} - {isMoneyAccountEnabled && } + {/* Hide growth banners when money account is enabled but user is geo-blocked */} + {(!isMoneyAccountEnabled || isMoneyAccountGeoEligible) && + homeGrowthBannerContent} + {isMoneyAccountVisible && } ); @@ -1050,8 +1058,10 @@ const Wallet = ({ {walletHomeMainAssetDetailsActions} - {homeGrowthBannerContent} - {isMoneyAccountEnabled && } + {/* Hide growth banners when money account is enabled but user is geo-blocked */} + {(!isMoneyAccountEnabled || isMoneyAccountGeoEligible) && + homeGrowthBannerContent} + {isMoneyAccountVisible && } ); @@ -1103,7 +1113,7 @@ const Wallet = ({ style={styles.headerActionButtonsContainer} accessible={false} > - {isMoneyAccountEnabled && ( + {isMoneyAccountVisible && ( { expect(result.current.amountFiat).toBe('8642.46'); }); + it('uses predict balance for predictWithdraw even when payment override is MoneyAccount', async () => { + usePredictBalanceMock.mockReturnValue({ data: 4321.23 } as never); + useMoneyAccountBalanceMock.mockReturnValue({ + totalFiatRaw: '750.50', + } as ReturnType); + + const { result } = runHook({ + transactionMeta: { + type: TransactionType.predictWithdraw, + id: transactionIdMock, + chainId: '0x1' as Hex, + txParams: { from: '0xabc' }, + } as unknown as Partial, + stateOverrides: { + engine: { + backgroundState: { + TransactionPayController: { + transactionData: { + [transactionIdMock]: { + paymentOverride: PaymentOverride.MoneyAccount, + }, + }, + }, + }, + }, + }, + }); + + await act(async () => { + result.current.updatePendingAmountPercentage(50); + }); + + // 4321.23 (predict balance) × 2 (fiat rate) × 0.50 = 4321.23 + expect(result.current.amountFiat).toBe('4321.23'); + }); + + it('uses full predict balance for predictWithdraw max even when payment override is MoneyAccount', async () => { + usePredictBalanceMock.mockReturnValue({ data: 4321.23 } as never); + useMoneyAccountBalanceMock.mockReturnValue({ + totalFiatRaw: '750.50', + } as ReturnType); + + const { result } = runHook({ + transactionMeta: { + type: TransactionType.predictWithdraw, + id: transactionIdMock, + chainId: '0x1' as Hex, + txParams: { from: '0xabc' }, + } as unknown as Partial, + stateOverrides: { + engine: { + backgroundState: { + TransactionPayController: { + transactionData: { + [transactionIdMock]: { + paymentOverride: PaymentOverride.MoneyAccount, + }, + }, + }, + }, + }, + }, + }); + + await act(async () => { + result.current.updatePendingAmountPercentage(100); + }); + + // 4321.23 (predict balance) × 2 (fiat rate) = 8642.46 + expect(result.current.amountFiat).toBe('8642.46'); + }); + it('to percentage of perps available balance', async () => { (Engine.context as Record).PerpsController = { state: { diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts index 6c9888ce39c7..fc12b75f5f6f 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts @@ -300,11 +300,13 @@ function useTokenBalance(tokenUsdRate: number) { return withdrawableMusd.multipliedBy(tokenUsdRate).toNumber(); } + if (hasTransactionType(transactionMeta, [TransactionType.predictWithdraw])) { + return predictBalanceUsd; + } + if (paymentOverride === PaymentOverride.MoneyAccount) { return totalFiatRaw ? parseFloat(totalFiatRaw) : 0; } - return hasTransactionType(transactionMeta, [TransactionType.predictWithdraw]) - ? predictBalanceUsd - : payTokenBalanceUsd; + return payTokenBalanceUsd; } diff --git a/app/components/hooks/useTokenHistoricalPrices.test.ts b/app/components/hooks/useTokenHistoricalPrices.test.ts new file mode 100644 index 000000000000..6af5a92ab723 --- /dev/null +++ b/app/components/hooks/useTokenHistoricalPrices.test.ts @@ -0,0 +1,106 @@ +import { + hasInsufficientTimeCoverage, + type TimePeriod, + type TokenPrice, +} from './useTokenHistoricalPrices'; + +const HOUR_MS = 3_600_000; + +function makeTimeSeries( + startMs: number, + count: number, + intervalMs: number, +): TokenPrice[] { + return Array.from({ length: count }, (_, i) => [ + String(startMs + i * intervalMs), + 100 + i, + ]) as TokenPrice[]; +} + +describe('hasInsufficientTimeCoverage', () => { + const now = Date.now(); + + it('returns false for "all" time period (no expected duration)', () => { + const prices = makeTimeSeries(now, 10, HOUR_MS); + expect(hasInsufficientTimeCoverage(prices, 'all')).toBe(false); + }); + + it('returns false when data has fewer than 2 points', () => { + const single: TokenPrice[] = [[String(now), 100]]; + expect(hasInsufficientTimeCoverage(single, '1d')).toBe(false); + expect(hasInsufficientTimeCoverage([], '1d')).toBe(false); + }); + + it('returns false when 1d data covers 24 hours', () => { + const prices = makeTimeSeries(now, 289, 5 * 60_000); + expect(hasInsufficientTimeCoverage(prices, '1d')).toBe(false); + }); + + it('returns true when 1d data covers only 10 hours', () => { + const tenHours = 10 * HOUR_MS; + const prices = makeTimeSeries(now, 120, tenHours / 119); + expect(hasInsufficientTimeCoverage(prices, '1d')).toBe(true); + }); + + it('returns true when 1d data covers exactly 50% of expected duration', () => { + const halfDay = 12 * HOUR_MS; + const prices = makeTimeSeries(now, 50, halfDay / 49); + expect(hasInsufficientTimeCoverage(prices, '1d')).toBe(true); + }); + + it('returns true when 1d data covers 80% of expected duration', () => { + const eightyPct = 0.8 * 24 * HOUR_MS; + const prices = makeTimeSeries(now, 50, eightyPct / 49); + expect(hasInsufficientTimeCoverage(prices, '1d')).toBe(true); + }); + + it('returns true when 1d data covers 22 hours (~91.7%)', () => { + const twentyTwoHours = 22 * HOUR_MS; + const prices = makeTimeSeries(now, 50, twentyTwoHours / 49); + expect(hasInsufficientTimeCoverage(prices, '1d')).toBe(true); + }); + + it('returns false when 1d data covers 23 hours (~95.8%)', () => { + const twentyThreeHours = 23 * HOUR_MS; + const prices = makeTimeSeries(now, 50, twentyThreeHours / 49); + expect(hasInsufficientTimeCoverage(prices, '1d')).toBe(false); + }); + + it('returns false when 1w data covers 7 days', () => { + const sevenDays = 7 * 24 * HOUR_MS; + const prices = makeTimeSeries(now, 100, sevenDays / 99); + expect(hasInsufficientTimeCoverage(prices, '1w')).toBe(false); + }); + + it('returns true when 1w data covers only 3 days', () => { + const threeDays = 3 * 24 * HOUR_MS; + const prices = makeTimeSeries(now, 100, threeDays / 99); + expect(hasInsufficientTimeCoverage(prices, '1w')).toBe(true); + }); + + it('handles 7d alias the same as 1w', () => { + const threeDays = 3 * 24 * HOUR_MS; + const prices = makeTimeSeries(now, 100, threeDays / 99); + expect(hasInsufficientTimeCoverage(prices, '7d')).toBe(true); + }); + + it('returns false for 1m with sufficient coverage', () => { + const thirtyDays = 30 * 24 * HOUR_MS; + const prices = makeTimeSeries(now, 100, thirtyDays / 99); + expect(hasInsufficientTimeCoverage(prices, '1m')).toBe(false); + }); + + it('returns true for 1m with only 10 days of data', () => { + const tenDays = 10 * 24 * HOUR_MS; + const prices = makeTimeSeries(now, 100, tenDays / 99); + expect(hasInsufficientTimeCoverage(prices, '1m')).toBe(true); + }); + + it.each(['3m', '1y', '3y'])( + 'validates coverage for %s time period', + (period) => { + const prices = makeTimeSeries(now, 10, HOUR_MS); + expect(hasInsufficientTimeCoverage(prices, period)).toBe(true); + }, + ); +}); diff --git a/app/components/hooks/useTokenHistoricalPrices.ts b/app/components/hooks/useTokenHistoricalPrices.ts index d74e5e7a8501..e1ca6d649560 100644 --- a/app/components/hooks/useTokenHistoricalPrices.ts +++ b/app/components/hooks/useTokenHistoricalPrices.ts @@ -11,6 +11,50 @@ export type TokenPrice = [string, number]; const placeholderPrices = Array(289).fill(['0', 0] as TokenPrice); +const HOURS = 3_600_000; +const DAYS = 24 * HOURS; + +/** + * Expected durations (in ms) for each time period. + * `null` means no coverage check (e.g. "all" has no fixed expected span). + */ +const EXPECTED_DURATION_MS: Record = { + '1d': 1 * DAYS, + '1w': 7 * DAYS, + '7d': 7 * DAYS, + '1m': 30 * DAYS, + '3m': 90 * DAYS, + '1y': 365 * DAYS, + '3y': 3 * 365 * DAYS, + all: null, +}; + +/** + * Minimum fraction of the requested time period that the returned data must + * cover. Below this threshold we show the "no data" overlay instead of + * rendering a misleading chart. 0.95 = data must span at least 95% of the + * expected duration (e.g. ~22.8 h for a 1D request). + */ +const MIN_COVERAGE_RATIO = 0.95; + +/** + * Returns true when the historical-prices data covers less than + * {@link MIN_COVERAGE_RATIO} of the requested time period. + */ +export function hasInsufficientTimeCoverage( + prices: TokenPrice[], + timePeriod: TimePeriod, +): boolean { + const expectedMs = EXPECTED_DURATION_MS[timePeriod]; + if (expectedMs === null || prices.length < 2) return false; + + const firstTs = Number(prices[0][0]); + const lastTs = Number(prices[prices.length - 1][0]); + const actualSpanMs = Math.abs(lastTs - firstTs); + + return actualSpanMs < expectedMs * MIN_COVERAGE_RATIO; +} + const useTokenHistoricalPrices = ({ asset, address, @@ -31,16 +75,19 @@ const useTokenHistoricalPrices = ({ data: TokenPrice[] | undefined; isLoading: boolean; error: Error | undefined; + hasInsufficientCoverage: boolean; } => { const resultChainId = formatChainIdToCaip(asset.chainId as Hex); const isNonEvmAsset = resultChainId === asset.chainId; const [prices, setPrices] = useState(placeholderPrices); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(); + const [insufficientCoverage, setInsufficientCoverage] = useState(false); useEffect(() => { const fetchPrices = async () => { setIsLoading(true); + setInsufficientCoverage(false); try { const baseUri = 'https://price.api.cx.metamask.io/v3'; @@ -91,10 +138,17 @@ const useTokenHistoricalPrices = ({ endTrace({ name: TraceName.FetchHistoricalPrices }); if (response.status === 204) { setPrices([]); + setInsufficientCoverage(true); return; } const data: { prices: TokenPrice[] } = await response.json(); - setPrices(data.prices as TokenPrice[]); + const sortedPrices = [...data.prices].sort( + (a, b) => Number(a[0]) - Number(b[0]), + ); + setPrices(sortedPrices as TokenPrice[]); + setInsufficientCoverage( + hasInsufficientTimeCoverage(sortedPrices, timePeriod), + ); } catch (e: unknown) { setError(e as Error); } finally { @@ -114,7 +168,12 @@ const useTokenHistoricalPrices = ({ asset.chainId, ]); - return { data: prices, isLoading, error }; + return { + data: prices, + isLoading, + error, + hasInsufficientCoverage: insufficientCoverage, + }; }; export default useTokenHistoricalPrices; diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleSocialTraderPositionUrl.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleSocialTraderPositionUrl.test.ts index fce66259a259..ce841e6c640c 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleSocialTraderPositionUrl.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleSocialTraderPositionUrl.test.ts @@ -33,10 +33,10 @@ describe('handleSocialTraderPositionUrl', () => { jest.clearAllMocks(); }); - it('navigates to TraderPositionView with positionId', () => { + it('navigates to TraderPositionView with positionId and forwards notificationSubtype', () => { handleSocialTraderPositionUrl({ actionPath: - '?positionId=92d9001b-8b64-4b13-9c1b-ba9292a6099a&traderId=trader-1&deduplication_id=dedup-1¬ification_event=follow_newtrade_buy', + '?positionId=92d9001b-8b64-4b13-9c1b-ba9292a6099a&traderId=trader-1&deduplication_id=dedup-1¬ification_subtype=follow_newtrade_buy', }); expect(mockNavigate).toHaveBeenCalledTimes(1); @@ -46,11 +46,12 @@ describe('handleSocialTraderPositionUrl', () => { positionId: '92d9001b-8b64-4b13-9c1b-ba9292a6099a', traderId: 'trader-1', source: 'notification', + notificationSubtype: 'follow_newtrade_buy', }, ); }); - it('navigates with only positionId and traderId route params', () => { + it('navigates with only positionId and traderId when no subtype is present', () => { handleSocialTraderPositionUrl({ actionPath: '?positionId=position-1&traderId=trader-1', }); @@ -62,14 +63,15 @@ describe('handleSocialTraderPositionUrl', () => { positionId: 'position-1', traderId: 'trader-1', source: 'notification', + notificationSubtype: undefined, }, ); }); - it('decodes encoded positionId and traderId values', () => { + it('decodes encoded positionId, traderId, and subtype values', () => { handleSocialTraderPositionUrl({ actionPath: - '?positionId=position%20id%2Fwith%20reserved%3Fchars&traderId=trader%20id%2Fwith%20reserved%3Fchars&deduplication_id=dedup%20id%2Fwith%20reserved%3Fchars¬ification_event=follow%20newtrade%2Fbuy', + '?positionId=position%20id%2Fwith%20reserved%3Fchars&traderId=trader%20id%2Fwith%20reserved%3Fchars&deduplication_id=dedup%20id%2Fwith%20reserved%3Fchars¬ification_subtype=follow%20newtrade%2Fbuy', }); expect(mockNavigate).toHaveBeenCalledWith( @@ -78,6 +80,7 @@ describe('handleSocialTraderPositionUrl', () => { positionId: 'position id/with reserved?chars', traderId: 'trader id/with reserved?chars', source: 'notification', + notificationSubtype: 'follow newtrade/buy', }, ); expect(DevLogger.log).toHaveBeenCalledWith( @@ -86,11 +89,27 @@ describe('handleSocialTraderPositionUrl', () => { positionId: 'position id/with reserved?chars', traderId: 'trader id/with reserved?chars', deduplicationId: 'dedup id/with reserved?chars', - notificationEvent: 'follow newtrade/buy', + notificationSubtype: 'follow newtrade/buy', }, ); }); + it.each([ + 'follow_newtrade_buy', + 'follow_newtrade_sell', + 'follow_newtrade_perp_long', + 'follow_newtrade_perp_short', + ])('forwards subtype %s through to the destination screen', (subtype) => { + handleSocialTraderPositionUrl({ + actionPath: `?positionId=position-1&traderId=trader-1¬ification_subtype=${subtype}`, + }); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.SOCIAL_LEADERBOARD.POSITION, + expect.objectContaining({ notificationSubtype: subtype }), + ); + }); + it('falls back to social leaderboard when traderId is missing', () => { handleSocialTraderPositionUrl({ actionPath: '?positionId=92d9001b-8b64-4b13-9c1b-ba9292a6099a', @@ -174,6 +193,7 @@ describe('handleSocialTraderPositionUrl', () => { positionId: 'position-1', traderId: 'trader-1', source: 'notification', + notificationSubtype: undefined, }, ); expect(DevLogger.log).toHaveBeenCalledWith( diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts index 87d9d5562c7f..62405e04aac1 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts @@ -1258,14 +1258,14 @@ describe('handleUniversalLink', () => { describe('ACTIONS.SOCIAL_TRADER_POSITION', () => { it('calls _handleSocialTraderPosition when action is SOCIAL_TRADER_POSITION', async () => { - const positionUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.SOCIAL_TRADER_POSITION}?positionId=position-1&traderId=trader-1&deduplication_id=dedup-1¬ification_event=follow_newtrade_buy`; + const positionUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.SOCIAL_TRADER_POSITION}?positionId=position-1&traderId=trader-1&deduplication_id=dedup-1¬ification_subtype=follow_newtrade_buy`; const positionUrlObj = { ...urlObj, hostname: AppConstants.MM_UNIVERSAL_LINK_HOST, href: positionUrl, pathname: `/${ACTIONS.SOCIAL_TRADER_POSITION}`, search: - '?positionId=position-1&traderId=trader-1&deduplication_id=dedup-1¬ification_event=follow_newtrade_buy', + '?positionId=position-1&traderId=trader-1&deduplication_id=dedup-1¬ification_subtype=follow_newtrade_buy', }; await handleUniversalLink({ @@ -1280,7 +1280,7 @@ describe('handleUniversalLink', () => { expect(mockHandleDeepLinkModalDisplay).not.toHaveBeenCalled(); expect(handleSocialTraderPositionUrl).toHaveBeenCalledWith({ actionPath: - '?positionId=position-1&traderId=trader-1&deduplication_id=dedup-1¬ification_event=follow_newtrade_buy', + '?positionId=position-1&traderId=trader-1&deduplication_id=dedup-1¬ification_subtype=follow_newtrade_buy', }); expect(handled).toHaveBeenCalled(); }); diff --git a/app/core/DeeplinkManager/handlers/legacy/handleSocialTraderPositionUrl.ts b/app/core/DeeplinkManager/handlers/legacy/handleSocialTraderPositionUrl.ts index 091427925539..e825dc913ff6 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handleSocialTraderPositionUrl.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handleSocialTraderPositionUrl.ts @@ -15,7 +15,7 @@ interface SocialTraderPositionNavigationParams { positionId?: string; traderId?: string; deduplicationId?: string; - notificationEvent?: string; + notificationSubtype?: string; } const parseSocialTraderPositionNavigationParams = ( @@ -29,7 +29,8 @@ const parseSocialTraderPositionNavigationParams = ( positionId: urlParams.get('positionId')?.trim() || undefined, traderId: urlParams.get('traderId')?.trim() || undefined, deduplicationId: urlParams.get('deduplication_id')?.trim() || undefined, - notificationEvent: urlParams.get('notification_event')?.trim() || undefined, + notificationSubtype: + urlParams.get('notification_subtype')?.trim() || undefined, }; }; @@ -41,7 +42,7 @@ const navigateToFallback = () => { * Handles notification-approved TraderPosition deeplinks. * * Supported URL format: - * - https://link.metamask.io/social-trader-position?positionId=&traderId=&deduplication_id=¬ification_event= + * - https://link.metamask.io/social-trader-position?positionId=&traderId=&deduplication_id=¬ification_subtype= */ export const handleSocialTraderPositionUrl = ({ actionPath, @@ -52,11 +53,11 @@ export const handleSocialTraderPositionUrl = ({ ); try { - const { positionId, traderId, deduplicationId, notificationEvent } = + const { positionId, traderId, deduplicationId, notificationSubtype } = parseSocialTraderPositionNavigationParams(actionPath); DevLogger.log( '[handleSocialTraderPositionUrl] Parsed navigation parameters:', - { positionId, traderId, deduplicationId, notificationEvent }, + { positionId, traderId, deduplicationId, notificationSubtype }, ); if (!positionId || !traderId) { @@ -85,13 +86,13 @@ export const handleSocialTraderPositionUrl = ({ ); } - if (notificationEvent !== undefined) { + if (notificationSubtype !== undefined) { const event = AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.SOCIAL_FOLLOW_TRADING_NOTIFICATION_CLICKED, ) .addProperties({ - [SocialLeaderboardEventProperties.NOTIFICATION_TYPE]: - notificationEvent, + [SocialLeaderboardEventProperties.NOTIFICATION_SUBTYPE]: + notificationSubtype, }) .build(); analytics.trackEvent(event); @@ -101,6 +102,7 @@ export const handleSocialTraderPositionUrl = ({ positionId, traderId, source: 'notification', + notificationSubtype, }); } catch (error) { DevLogger.log( diff --git a/app/core/Engine/controllers/card-controller/provider-types.ts b/app/core/Engine/controllers/card-controller/provider-types.ts index cb5aa9392120..1512b844b5c3 100644 --- a/app/core/Engine/controllers/card-controller/provider-types.ts +++ b/app/core/Engine/controllers/card-controller/provider-types.ts @@ -184,6 +184,8 @@ export interface CardAccountStatus { provisioningEligible: boolean; holderName: string | null; shippingAddress: CardShippingAddress | null; + countryOfResidence: string | null; + usState: string | null; } // -- Alerts & Actions -- diff --git a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts index ae110944931b..168a66002521 100644 --- a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts +++ b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts @@ -309,8 +309,10 @@ describe('BaanxProvider', () => { const account: CardAccountStatus = { verificationStatus: 'VERIFIED', provisioningEligible: true, + countryOfResidence: 'US', holderName: 'Test User', shippingAddress: null, + usState: 'CA', }; const card: CardDetails = { @@ -663,6 +665,48 @@ describe('BaanxProvider', () => { expect(result.card).toBeNull(); }); + it('maps countryOfResidence from the user payload onto account status', async () => { + const get = jest.fn().mockImplementation((path: string) => { + if (path === '/v1/user') { + return Promise.resolve({ + verificationState: 'VERIFIED', + firstName: 'Jane', + countryOfResidence: 'gb', + }); + } + return Promise.resolve(null); + }); + + const result = await buildProvider(get).getCardHomeData('0xabc', tokens); + + expect(result.account).toMatchObject({ + countryOfResidence: 'GB', + usState: null, + verificationStatus: 'VERIFIED', + holderName: 'Jane', + } satisfies Partial); + }); + + it('maps usState from the user payload onto account status', async () => { + const get = jest.fn().mockImplementation((path: string) => { + if (path === '/v1/user') { + return Promise.resolve({ + verificationState: 'VERIFIED', + countryOfResidence: 'US', + usState: 'ca', + }); + } + return Promise.resolve(null); + }); + + const result = await buildProvider(get).getCardHomeData('0xabc', tokens); + + expect(result.account).toMatchObject({ + countryOfResidence: 'US', + usState: 'CA', + } satisfies Partial); + }); + it('propagates a 401 from the wallet details fetch', async () => { const get = jest.fn().mockImplementation((path: string) => { if (path === '/v1/delegation/chain/config') { diff --git a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts index 8ab64624bb76..de34451cf290 100644 --- a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts +++ b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts @@ -1480,6 +1480,10 @@ export class BaanxProvider implements ICardProvider { country: user.countryOfResidence ?? '', } : null, + countryOfResidence: user.countryOfResidence + ? user.countryOfResidence.toUpperCase() + : null, + usState: user.usState ? user.usState.toUpperCase() : null, }; } diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts index 9542b4be5f71..46c66206195c 100644 --- a/app/core/NavigationService/types.ts +++ b/app/core/NavigationService/types.ts @@ -246,6 +246,10 @@ type TraderPositionViewParams = /** Analytics entry-point that opened the position view. Narrowed at the * receiver into the QuickBuy / FollowTradingToken source enums. */ source?: string; + /** Notification subtype forwarded from the social-trader-position + * deeplink (e.g. `follow_newtrade_perp_long`). Attached to the + * destination screen's analytics event for click attribution. */ + notificationSubtype?: string; } | { /** Deep-link path: triggers useTraderPosition to fetch by UUID. */ @@ -260,6 +264,10 @@ type TraderPositionViewParams = /** Analytics entry-point that opened the position view. Narrowed at the * receiver into the QuickBuy / FollowTradingToken source enums. */ source?: string; + /** Notification subtype forwarded from the social-trader-position + * deeplink (e.g. `follow_newtrade_perp_long`). Attached to the + * destination screen's analytics event for click attribution. */ + notificationSubtype?: string; }; /** diff --git a/app/core/redux/slices/bridge/index.ts b/app/core/redux/slices/bridge/index.ts index 9f7c9f2b148a..4869a402c850 100644 --- a/app/core/redux/slices/bridge/index.ts +++ b/app/core/redux/slices/bridge/index.ts @@ -353,7 +353,9 @@ const slice = createSlice({ action.meta.arg.chainId === state.sourceToken.chainId && action.meta.arg.tokenAddress === state.sourceToken.address ) { - state.sourceToken.currencyExchangeRate = action.payload ?? undefined; + const rate = action.payload; + state.sourceToken.currencyExchangeRate = + typeof rate === 'number' ? rate : undefined; } }); builder.addCase(setDestTokenExchangeRate.fulfilled, (state, action) => { @@ -363,7 +365,9 @@ const slice = createSlice({ action.meta.arg.chainId === state.destToken.chainId && action.meta.arg.tokenAddress === state.destToken.address ) { - state.destToken.currencyExchangeRate = action.payload ?? undefined; + const rate = action.payload; + state.destToken.currencyExchangeRate = + typeof rate === 'number' ? rate : undefined; } }); }, diff --git a/app/images/activity-empty-dark.svg b/app/images/activity-empty-dark.svg new file mode 100644 index 000000000000..499c6841b998 --- /dev/null +++ b/app/images/activity-empty-dark.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/images/activity-empty-light.svg b/app/images/activity-empty-light.svg new file mode 100644 index 000000000000..c4288134855e --- /dev/null +++ b/app/images/activity-empty-light.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/selectors/cardController.test.ts b/app/selectors/cardController.test.ts index f2b2113b357b..dbaae6a6d220 100644 --- a/app/selectors/cardController.test.ts +++ b/app/selectors/cardController.test.ts @@ -20,6 +20,9 @@ import { selectCardHasApprovedLineaFunding, selectCardLineaUsdcToken, selectIsMoneyAccountDelegatedForCard, + selectCardCountryOfResidence, + selectCardResidencyRegion, + selectIsCardResidencyBlocked, } from './cardController'; import { selectPrimaryMoneyAccount } from './moneyAccountController'; import type { CardControllerState } from '../core/Engine/controllers/card-controller/types'; @@ -91,6 +94,7 @@ const MONAD_VEDA_FEATURE_FLAG = { const createMockRootState = ( overrides: Partial = {}, cardFeatureFlag?: unknown, + extraRemoteFlags?: Record, ): RootState => ({ engine: { @@ -106,10 +110,15 @@ const createMockRootState = ( moneyAccountCardLinkInProgress: false, ...overrides, }, - ...(cardFeatureFlag !== undefined + ...(cardFeatureFlag !== undefined || extraRemoteFlags !== undefined ? { RemoteFeatureFlagController: { - remoteFeatureFlags: { cardFeature: cardFeatureFlag }, + remoteFeatureFlags: { + ...(cardFeatureFlag !== undefined + ? { cardFeature: cardFeatureFlag } + : {}), + ...extraRemoteFlags, + }, }, } : {}), @@ -1436,3 +1445,253 @@ describe('selectIsMoneyAccountDelegatedForCard', () => { expect(selectIsMoneyAccountDelegatedForCard(state)).toBe(true); }); }); + +describe('selectCardCountryOfResidence', () => { + it('returns null when cardHomeData is null', () => { + const state = createMockRootState({ cardHomeData: null }); + expect(selectCardCountryOfResidence(state)).toBeNull(); + }); + + it('returns null when account is null', () => { + const state = createMockRootState({ + cardHomeData: + mockCardHomeData as unknown as CardControllerState['cardHomeData'], + }); + expect(selectCardCountryOfResidence(state)).toBeNull(); + }); + + it('returns countryOfResidence from account', () => { + const state = createMockRootState({ + cardHomeData: { + ...mockCardHomeData, + account: { + verificationStatus: 'VERIFIED', + provisioningEligible: false, + holderName: 'Test User', + shippingAddress: null, + countryOfResidence: 'GB', + usState: null, + }, + } as unknown as CardControllerState['cardHomeData'], + }); + expect(selectCardCountryOfResidence(state)).toBe('GB'); + }); +}); + +describe('selectCardResidencyRegion', () => { + it('returns US-{STATE} for US cardholders with a known state', () => { + const state = createMockRootState({ + cardHomeData: { + ...mockCardHomeData, + account: { + verificationStatus: 'VERIFIED', + provisioningEligible: false, + holderName: 'Test User', + shippingAddress: null, + countryOfResidence: 'US', + usState: 'CA', + }, + } as unknown as CardControllerState['cardHomeData'], + }); + expect(selectCardResidencyRegion(state)).toBe('US-CA'); + }); +}); + +describe('selectIsCardResidencyBlocked', () => { + const musdBlockedFlags = (blockedRegions: string[]) => ({ + earnMusdConversionGeoBlockedCountries: { blockedRegions }, + }); + + it('returns false when countryOfResidence is null (fail-open)', () => { + const state = createMockRootState({ + cardHomeData: + mockCardHomeData as unknown as CardControllerState['cardHomeData'], + }); + expect(selectIsCardResidencyBlocked(state)).toBe(false); + }); + + it('returns true when countryOfResidence is GB and GB is blocked', () => { + const state = createMockRootState( + { + cardHomeData: { + ...mockCardHomeData, + account: { + verificationStatus: 'VERIFIED', + provisioningEligible: false, + holderName: 'Test User', + shippingAddress: null, + countryOfResidence: 'GB', + usState: null, + }, + } as unknown as CardControllerState['cardHomeData'], + }, + undefined, + musdBlockedFlags(['GB']), + ); + expect(selectIsCardResidencyBlocked(state)).toBe(true); + }); + + it('returns false when countryOfResidence is not in blocked set', () => { + const state = createMockRootState( + { + cardHomeData: { + ...mockCardHomeData, + account: { + verificationStatus: 'VERIFIED', + provisioningEligible: false, + holderName: 'Test User', + shippingAddress: null, + countryOfResidence: 'US', + usState: 'NY', + }, + } as unknown as CardControllerState['cardHomeData'], + }, + undefined, + musdBlockedFlags(['GB']), + ); + expect(selectIsCardResidencyBlocked(state)).toBe(false); + }); + + it('returns true when US-CA is blocked and cardholder is in California', () => { + const state = createMockRootState( + { + cardHomeData: { + ...mockCardHomeData, + account: { + verificationStatus: 'VERIFIED', + provisioningEligible: false, + holderName: 'Test User', + shippingAddress: null, + countryOfResidence: 'US', + usState: 'CA', + }, + } as unknown as CardControllerState['cardHomeData'], + }, + undefined, + musdBlockedFlags(['US-CA']), + ); + expect(selectIsCardResidencyBlocked(state)).toBe(true); + }); + + it('returns false when only US-CA is blocked and cardholder is in New York', () => { + const state = createMockRootState( + { + cardHomeData: { + ...mockCardHomeData, + account: { + verificationStatus: 'VERIFIED', + provisioningEligible: false, + holderName: 'Test User', + shippingAddress: null, + countryOfResidence: 'US', + usState: 'NY', + }, + } as unknown as CardControllerState['cardHomeData'], + }, + undefined, + musdBlockedFlags(['US-CA']), + ); + expect(selectIsCardResidencyBlocked(state)).toBe(false); + }); + + it('returns true when entire US is blocked', () => { + const state = createMockRootState( + { + cardHomeData: { + ...mockCardHomeData, + account: { + verificationStatus: 'VERIFIED', + provisioningEligible: false, + holderName: 'Test User', + shippingAddress: null, + countryOfResidence: 'US', + usState: 'NY', + }, + } as unknown as CardControllerState['cardHomeData'], + }, + undefined, + musdBlockedFlags(['US']), + ); + expect(selectIsCardResidencyBlocked(state)).toBe(true); + }); +}); + +describe('selectCardAvailableTokens residency blocking', () => { + const WALLET_A = '0xwalletA000000000000000000000000000000001'; + const musdBlockedFlags = (blockedRegions: string[]) => ({ + earnMusdConversionGeoBlockedCountries: { blockedRegions }, + }); + + it('suppresses Money Account placeholder entries when residency is blocked', () => { + mockSelectSelectedInternalAccountByScope.mockReturnValue( + jest.fn().mockReturnValue({ address: WALLET_A }), + ); + const homeData = { + ...mockCardHomeData, + fundingAssets: [], + delegationSettings: makeVedaDelegationSettings(), + account: { + verificationStatus: 'VERIFIED', + provisioningEligible: false, + holderName: 'Test User', + shippingAddress: null, + countryOfResidence: 'GB', + usState: null, + }, + } as unknown as CardHomeData; + const state = createMockRootState( + { + cardHomeData: + homeData as unknown as CardControllerState['cardHomeData'], + }, + MONAD_VEDA_FEATURE_FLAG, + musdBlockedFlags(['GB']), + ); + expect(selectCardAvailableTokens(state)).toStrictEqual([]); + }); + + it('keeps active Money Account funding rows when residency is blocked', () => { + mockSelectSelectedInternalAccountByScope.mockReturnValue( + jest.fn().mockReturnValue({ address: WALLET_A }), + ); + const homeData = { + ...mockCardHomeData, + fundingAssets: [ + { + symbol: 'veda', + name: 'Veda', + address: VEDA_ADDRESS, + walletAddress: WALLET_A, + decimals: 6, + chainId: VEDA_CAIP, + spendableBalance: '10', + spendingCap: '100', + priority: 1, + status: FundingAssetStatus.Active, + delegationContract: VEDA_DELEGATION_CONTRACT, + }, + ], + delegationSettings: makeVedaDelegationSettings(), + account: { + verificationStatus: 'VERIFIED', + provisioningEligible: false, + holderName: 'Test User', + shippingAddress: null, + countryOfResidence: 'GB', + usState: null, + }, + } as unknown as CardHomeData; + const state = createMockRootState( + { + cardHomeData: + homeData as unknown as CardControllerState['cardHomeData'], + }, + MONAD_VEDA_FEATURE_FLAG, + musdBlockedFlags(['GB']), + ); + const tokens = selectCardAvailableTokens(state); + expect(tokens).toHaveLength(1); + expect(tokens[0].isMoneyAccountEntry).toBe(true); + expect(tokens[0].fundingStatus).toBe(FundingStatus.Enabled); + }); +}); diff --git a/app/selectors/cardController.ts b/app/selectors/cardController.ts index ecfcbb1a5c9b..a5f3074458c7 100644 --- a/app/selectors/cardController.ts +++ b/app/selectors/cardController.ts @@ -30,6 +30,11 @@ import { isEthAccount } from '../core/Multichain/utils'; import { isMoneyAccountDelegatedForCard } from '../core/Engine/controllers/card-controller/utils/moneyAccountCardToken'; import { selectPrimaryMoneyAccount } from './moneyAccountController'; import { selectCardFeatureFlag } from './featureFlagController/card'; +import { selectMusdConversionBlockedCountries } from '../components/UI/Earn/selectors/featureFlags'; +import { + buildCardResidencyRegion, + isCardResidencyInBlockedRegions, +} from '../components/UI/Card/util/residency'; const LINEA_MAINNET_CAIP_CHAIN_ID = 'eip155:59144'; const CASHBACK_FUNDING_SYMBOL = 'USDC'; @@ -159,6 +164,33 @@ export const selectMoneyAccountVedaTokenConfig = createSelector( getVedaTokenConfig(data?.delegationSettings), ); +export const selectCardCountryOfResidence = createSelector( + selectCardHomeData, + (data): string | null => data?.account?.countryOfResidence ?? null, +); + +export const selectCardUsState = createSelector( + selectCardHomeData, + (data): string | null => data?.account?.usState ?? null, +); + +export const selectCardResidencyRegion = createSelector( + selectCardCountryOfResidence, + selectCardUsState, + (countryOfResidence, usState): string | null => + buildCardResidencyRegion(countryOfResidence, usState), +); + +const selectCardResidencyBlockedRegions = (state: RootState): string[] => + selectMusdConversionBlockedCountries(state); + +export const selectIsCardResidencyBlocked = createSelector( + selectCardResidencyRegion, + selectCardResidencyBlockedRegions, + (residencyRegion, blockedRegions): boolean => + isCardResidencyInBlockedRegions(residencyRegion, blockedRegions), +); + const toFundingTokenWithVedaContext = ( asset: Parameters[0], vedaConfig: VedaTokenConfig | null, @@ -203,7 +235,14 @@ export const selectCardAvailableTokens = createSelector( selectSelectedEvmAccount, selectCardFeatureFlag, selectMoneyAccountVedaTokenConfig, - (data, selectedAccount, cardFeatureFlag, vedaConfig): CardFundingToken[] => { + selectIsCardResidencyBlocked, + ( + data, + selectedAccount, + cardFeatureFlag, + vedaConfig, + isResidencyBlocked, + ): CardFundingToken[] => { const currentAddress = selectedAccount?.address; const currentAddressLower = currentAddress?.toLowerCase(); const fundingAssets = data?.fundingAssets ?? []; @@ -255,7 +294,11 @@ export const selectCardAvailableTokens = createSelector( ...placeholder, walletAddress: currentAddress, isMoneyAccountEntry: isMoneyAccountEntry(placeholder, vedaConfig), - })); + })) + .filter( + (placeholder) => + !isResidencyBlocked || !placeholder.isMoneyAccountEntry, + ); return sortCardFundingTokens([...realEntries, ...placeholders]); }, diff --git a/app/store/migrations/143.test.ts b/app/store/migrations/143.test.ts new file mode 100644 index 000000000000..d097ffcd8bd6 --- /dev/null +++ b/app/store/migrations/143.test.ts @@ -0,0 +1,139 @@ +import { cloneDeep } from 'lodash'; + +import { ensureValidState } from './util'; +import migrate from './143'; + +jest.mock('./util', () => ({ + ensureValidState: jest.fn(), +})); + +const mockedEnsureValidState = jest.mocked(ensureValidState); + +const createTestState = ( + selectedCurrency: string, + currentCurrency: string, +) => ({ + engine: { + backgroundState: { + AssetsController: { + selectedCurrency, + }, + CurrencyRateController: { + currentCurrency, + }, + }, + }, +}); + +describe('Migration 143: Carry over selected fiat currency', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns state unchanged if ensureValidState fails', () => { + const state = { some: 'state' }; + mockedEnsureValidState.mockReturnValue(false); + + const migratedState = migrate(state); + + expect(migratedState).toBe(state); + expect(migratedState).toStrictEqual({ some: 'state' }); + }); + + it('copies currentCurrency into selectedCurrency when they differ', () => { + const oldState = createTestState('usd', 'eur'); + mockedEnsureValidState.mockReturnValue(true); + + const migratedState = migrate(oldState) as typeof oldState; + + expect( + migratedState.engine.backgroundState.AssetsController.selectedCurrency, + ).toBe('eur'); + }); + + it('leaves selectedCurrency untouched when it already matches currentCurrency', () => { + const oldState = createTestState('eur', 'eur'); + mockedEnsureValidState.mockReturnValue(true); + + const migratedState = migrate(oldState) as typeof oldState; + + expect( + migratedState.engine.backgroundState.AssetsController.selectedCurrency, + ).toBe('eur'); + }); + + it.each([ + { + currentCurrency: '', + test: 'empty currentCurrency', + }, + { + currentCurrency: 123 as unknown as string, + test: 'non-string currentCurrency', + }, + ])( + 'does not modify selectedCurrency for invalid currentCurrency - $test', + ({ currentCurrency }) => { + const oldState = createTestState('usd', currentCurrency); + mockedEnsureValidState.mockReturnValue(true); + + const migratedState = migrate(oldState) as typeof oldState; + + expect( + migratedState.engine.backgroundState.AssetsController.selectedCurrency, + ).toBe('usd'); + }, + ); + + it.each([ + { + state: { + engine: { + backgroundState: { + CurrencyRateController: { currentCurrency: 'eur' }, + }, + }, + }, + test: 'missing AssetsController', + }, + { + state: { + engine: { + backgroundState: { + AssetsController: 'invalid', + CurrencyRateController: { currentCurrency: 'eur' }, + }, + }, + }, + test: 'invalid AssetsController state', + }, + { + state: { + engine: { + backgroundState: { + AssetsController: { selectedCurrency: 'usd' }, + }, + }, + }, + test: 'missing CurrencyRateController', + }, + { + state: { + engine: { + backgroundState: { + AssetsController: { selectedCurrency: 'usd' }, + CurrencyRateController: 'invalid', + }, + }, + }, + test: 'invalid CurrencyRateController state', + }, + ])('does not modify state if the state is invalid - $test', ({ state }) => { + const orgState = cloneDeep(state); + mockedEnsureValidState.mockReturnValue(true); + + const migratedState = migrate(state); + + expect(migratedState).toStrictEqual(orgState); + }); +}); diff --git a/app/store/migrations/143.ts b/app/store/migrations/143.ts new file mode 100644 index 000000000000..4b8c34d88cd8 --- /dev/null +++ b/app/store/migrations/143.ts @@ -0,0 +1,52 @@ +import { hasProperty, isObject } from '@metamask/utils'; +import { ensureValidState } from './util'; + +/** + * Migration 143: + * + * Carry over the user's selected fiat currency from + * `CurrencyRateController.currentCurrency` into + * `AssetsController.selectedCurrency`. + * + * Migration 139 initialized `AssetsController.selectedCurrency` to the default + * `'usd'` without copying the user's existing currency. When the + * `assetsUnifyState` feature flag is enabled, the UI reads the active currency + * from `AssetsController.selectedCurrency`, so without this sync existing + * non-USD users would be silently reset to USD. + * + * @param state - The persisted Redux state. + * @returns The migrated state. + */ +const migration = (state: unknown): unknown => { + const migrationVersion = 143; + + if (!ensureValidState(state, migrationVersion)) { + return state; + } + + const { backgroundState } = state.engine; + + if ( + !hasProperty(backgroundState, 'AssetsController') || + !isObject(backgroundState.AssetsController) || + !hasProperty(backgroundState, 'CurrencyRateController') || + !isObject(backgroundState.CurrencyRateController) + ) { + return state; + } + + const { AssetsController, CurrencyRateController } = backgroundState; + const { currentCurrency } = CurrencyRateController; + + if ( + typeof currentCurrency === 'string' && + currentCurrency.length > 0 && + AssetsController.selectedCurrency !== currentCurrency + ) { + AssetsController.selectedCurrency = currentCurrency; + } + + return state; +}; + +export default migration; diff --git a/app/store/migrations/index.ts b/app/store/migrations/index.ts index 200b963fbed5..25bcaf25b8c6 100644 --- a/app/store/migrations/index.ts +++ b/app/store/migrations/index.ts @@ -143,6 +143,7 @@ import migration139 from './139'; import migration140 from './140'; import migration141 from './141'; import migration142 from './142'; +import migration143 from './143'; // Add migrations above this line import { ControllerStorage } from '../persistConfig'; @@ -305,6 +306,7 @@ export const migrationList: MigrationsList = { 140: migration140, 141: migration141, 142: migration142, + 143: migration143, }; // Enable both synchronous and asynchronous migrations diff --git a/app/util/activity-adapters/activity-list-helpers.test.ts b/app/util/activity-adapters/activity-list-helpers.test.ts new file mode 100644 index 000000000000..76cdc3051a5d --- /dev/null +++ b/app/util/activity-adapters/activity-list-helpers.test.ts @@ -0,0 +1,96 @@ +import type { ActivityListItem } from './types'; +import { + activityMatchesAssetId, + groupActivityListItems, + shouldShowPlusSign, +} from './activity-list-helpers'; + +function makeItem(overrides: Partial = {}): ActivityListItem { + return { + chainId: 'eip155:1', + data: { + hash: '0xhash', + token: { + amount: '1', + assetId: 'eip155:1/slip44:60', + direction: 'out', + symbol: 'ETH', + }, + }, + isEarliestNonce: true, + status: 'success', + timestamp: 1, + type: 'send', + ...overrides, + } as unknown as ActivityListItem; +} + +function localDay(timestamp: number) { + const date = new Date(timestamp); + date.setHours(0, 0, 0, 0); + return date.getTime(); +} + +describe('activity list helpers', () => { + it('hides plus signs for spending-cap activity types', () => { + expect(shouldShowPlusSign('approveSpendingCap')).toBe(false); + expect(shouldShowPlusSign('increaseSpendingCap')).toBe(false); + expect(shouldShowPlusSign('revokeSpendingCap')).toBe(false); + expect(shouldShowPlusSign('receive')).toBe(true); + }); + + it('matches token, source token, and destination token asset ids case-insensitively', () => { + const item = makeItem({ + data: { + hash: '0xhash', + sourceToken: { + assetId: 'eip155:1/erc20:0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48', + direction: 'out', + symbol: 'USDC', + }, + destinationToken: { + assetId: 'eip155:1/slip44:60', + direction: 'in', + symbol: 'ETH', + }, + }, + type: 'swap', + }); + + expect( + activityMatchesAssetId( + item, + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ), + ).toBe(true); + expect(activityMatchesAssetId(item, 'eip155:1/slip44:60')).toBe(true); + expect(activityMatchesAssetId(item, 'eip155:137/slip44:60')).toBe(false); + }); + + it('groups pending items before date-grouped historical items', () => { + const pending = makeItem({ + data: { hash: '0xpending' }, + status: 'pending', + timestamp: Date.UTC(2026, 0, 2, 12), + }); + const firstHistorical = makeItem({ + data: { hash: '0xfirst' }, + timestamp: Date.UTC(2026, 0, 2, 11), + }); + const secondHistorical = makeItem({ + data: { hash: '0xsecond' }, + timestamp: Date.UTC(2026, 0, 1, 11), + }); + + expect( + groupActivityListItems([pending, firstHistorical, secondHistorical]), + ).toStrictEqual([ + { type: 'pending-header' }, + { type: 'item', item: pending }, + { type: 'date-header', date: localDay(firstHistorical.timestamp) }, + { type: 'item', item: firstHistorical }, + { type: 'date-header', date: localDay(secondHistorical.timestamp) }, + { type: 'item', item: secondHistorical }, + ]); + }); +}); diff --git a/app/util/activity-adapters/activity-list-helpers.ts b/app/util/activity-adapters/activity-list-helpers.ts new file mode 100644 index 000000000000..9744fde13113 --- /dev/null +++ b/app/util/activity-adapters/activity-list-helpers.ts @@ -0,0 +1,97 @@ +import type { CaipAssetType } from '@metamask/utils'; +import { + mobileActivityAdapterEnvironment, + type ActivityAdapterEnvironment, +} from './adapters/environment'; +import type { ActivityListItem } from './types'; + +const hidePlusSignActivityTypes = new Set([ + 'approveSpendingCap', + 'increaseSpendingCap', + 'revokeSpendingCap', +]); + +export type ActivityListFilter = + | { assetId: CaipAssetType } + | { networks: string[] }; + +export type GroupedActivityListItem = + | { type: 'pending-header' } + | { type: 'date-header'; date: number } + | { type: 'item'; item: ActivityListItem }; + +export function shouldShowPlusSign(activityType: ActivityListItem['type']) { + return !hidePlusSignActivityTypes.has(activityType); +} + +export function activityMatchesAssetId( + item: ActivityListItem, + assetId: CaipAssetType, + environment: ActivityAdapterEnvironment = mobileActivityAdapterEnvironment, +) { + const { data } = item; + const tokenAssetIds = [ + 'token' in data ? data.token?.assetId : undefined, + 'sourceToken' in data ? data.sourceToken?.assetId : undefined, + 'destinationToken' in data ? data.destinationToken?.assetId : undefined, + ]; + + return tokenAssetIds.some( + (tokenAssetId) => + tokenAssetId !== undefined && + environment.equalsIgnoreCase(tokenAssetId, assetId), + ); +} + +function parseDate(timestamp: number) { + const date = new Date(timestamp); + date.setHours(0, 0, 0, 0); + return date.getTime(); +} + +function groupItemsByDate( + items: ActivityListItem[], +): GroupedActivityListItem[] { + let currentDate: number | null = null; + + return items.flatMap((item): GroupedActivityListItem[] => { + const date = parseDate(item.timestamp); + + if (date === currentDate) { + return [{ type: 'item', item }]; + } + + currentDate = date; + return [ + { type: 'date-header', date }, + { type: 'item', item }, + ]; + }); +} + +export function groupActivityListItems( + items: ActivityListItem[], +): GroupedActivityListItem[] { + const pending: ActivityListItem[] = []; + const historical: ActivityListItem[] = []; + + for (const item of items) { + if (item.status === 'pending') { + pending.push(item); + } else { + historical.push(item); + } + } + + const grouped: GroupedActivityListItem[] = []; + + if (pending.length > 0) { + grouped.push({ type: 'pending-header' }); + for (const item of pending) { + grouped.push({ type: 'item', item }); + } + } + + grouped.push(...groupItemsByDate(historical)); + return grouped; +} diff --git a/app/util/activity-adapters/adapters/api-evm-transactions.test.ts b/app/util/activity-adapters/adapters/api-evm-transactions.test.ts new file mode 100644 index 000000000000..c47f699d027e --- /dev/null +++ b/app/util/activity-adapters/adapters/api-evm-transactions.test.ts @@ -0,0 +1,703 @@ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { mapApiEvmTransactions } from './api-evm-transactions'; +import { NATIVE_TOKEN_ADDRESS, toAssetId } from './shims'; + +const subjectAddress = '0x9bed78535d6a03a955f1504aadba974d9a29e292'; +const baseUsdc = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'; +const baseAaveUsdc = '0x4e65fe4dba92790696d040ac24aa414708f5c0ab'; +const baseAavePool = '0xa238dd80c259a72e81d7e4664a9801593f98d1c5'; +const baseRecipientAddress = '0x6fdb1e9d93c1279177b00baaf44524055455e92e'; +const bscContractCallerAddress = '0xf70da97812cb96acdf810712aa562db8dfa3dbef'; +const bscRecipientAddress = '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f'; +const bscUniversalRouter = '0xca11bde05977b3631167028862be2a173976ca11'; +const exchangeRecipient = '0x3913a8aca88c946284abbe7ab2ed671c6603de20'; +const lineaMusd = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; +const lineaSenderAddress = '0xf70da97812cb96acdf810712aa562db8dfa3dbef'; +const metamaskBonusContract = '0x3ef3d8ba38ebe18db133cec108f4d14ce00dd9ae'; +const polygonRecipientAddress = '0x2cd071562a1688b3e9f31be39c92aa140a1acc94'; +const wethContractAddress = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; +const zeroAddress = '0x0000000000000000000000000000000000000000'; + +const withoutRaw = (item: ReturnType) => { + const activity = { ...item }; + delete activity.raw; + return activity; +}; + +describe('mapApiEvmTransactions', () => { + it('maps an ERC-20 transfer sent by the account to a Send activity', () => { + const transaction = { + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 8453, + from: subjectAddress, + to: baseUsdc, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: subjectAddress, + to: baseRecipientAddress, + symbol: 'USDC', + }, + ], + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'send', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1778593067000, + data: { + from: subjectAddress, + hash: undefined, + to: baseRecipientAddress, + token: { + direction: 'out', + symbol: 'USDC', + assetId: toAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('maps an approval without value transfers using known bridge token metadata', () => { + const transaction = { + hash: '0x91f89897197afcc09ad98ec4282366fd7938d8a9609e4fc2a0aa2d070664bc27', + timestamp: '2026-05-27T13:20:27.000Z', + chainId: 8453, + accountId: `eip155:8453:${subjectAddress}`, + methodId: '0x095ea7b3', + value: '0', + to: baseUsdc, + from: subjectAddress, + isError: false, + valueTransfers: [], + logs: [], + transactionProtocol: 'ERC_20', + transactionCategory: 'APPROVE', + transactionType: 'ERC_20_APPROVE', + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'approveSpendingCap', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779888027000, + data: { + hash: '0x91f89897197afcc09ad98ec4282366fd7938d8a9609e4fc2a0aa2d070664bc27', + token: { + direction: 'out', + symbol: 'USDC', + decimals: 6, + assetId: toAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('maps a native value contract call without method data to a Send activity', () => { + const transaction = { + hash: '0x64d2f26c261178252fcad9dbb665cf40337b827a582066553dd6634eaeea9f0a', + timestamp: '2026-05-19T19:27:12.000Z', + chainId: 137, + from: subjectAddress, + to: polygonRecipientAddress, + methodId: null, + transactionCategory: 'CONTRACT_CALL', + transactionType: 'GENERIC_CONTRACT_CALL', + value: '100000000000000000', + valueTransfers: [ + { + from: subjectAddress, + to: polygonRecipientAddress, + amount: '100000000000000000', + decimal: 18, + symbol: 'MATIC', + transferType: 'normal', + }, + ], + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'send', + chainId: 'eip155:137', + status: 'success', + timestamp: 1779218832000, + data: { + from: subjectAddress, + hash: '0x64d2f26c261178252fcad9dbb665cf40337b827a582066553dd6634eaeea9f0a', + to: polygonRecipientAddress, + token: { + amount: '100000000000000000', + assetId: toAssetId(NATIVE_TOKEN_ADDRESS, 'eip155:137'), + decimals: 18, + direction: 'out', + symbol: 'MATIC', + }, + }, + }); + }); + + it('maps an ERC-20 transfer received by the account to a Receive activity', () => { + const transaction = { + timestamp: '2026-05-05T12:15:27.000Z', + chainId: 59144, + from: lineaSenderAddress, + to: lineaMusd, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: lineaSenderAddress, + to: subjectAddress, + symbol: 'mUSD', + }, + ], + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'receive', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1777983327000, + data: { + from: lineaSenderAddress, + hash: undefined, + to: subjectAddress, + token: { + direction: 'in', + symbol: 'mUSD', + assetId: toAssetId(lineaMusd, 'eip155:59144'), + }, + }, + }); + }); + + it('maps an exchange transaction without a received token to an incomplete Swap activity', () => { + const transaction = { + timestamp: '2026-05-05T17:57:53.000Z', + chainId: 59144, + from: subjectAddress, + to: subjectAddress, + transactionCategory: 'EXCHANGE', + valueTransfers: [ + { + from: subjectAddress, + to: exchangeRecipient, + symbol: 'mUSD', + }, + ], + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'swapIncomplete', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1778003873000, + data: { + hash: undefined, + sourceToken: { + direction: 'out', + symbol: 'mUSD', + }, + }, + }); + }); + + it('maps an exchange transaction with an internal ETH receive transfer to a Swap activity with native destination assetId', () => { + const aggregatorAddress = '0x0a2854fbbd9b3ef66f17d47284e7f899b9509330'; + const transaction = { + hash: '0x80b974d5834e1047a78332369de3d4b988f0237ff8a418c9464217e55c542f2f', + timestamp: '2026-05-28T01:03:49.000Z', + chainId: 59144, + methodId: '0xe9ae5c53', + value: '0', + to: subjectAddress, + from: subjectAddress, + isError: false, + transactionCategory: 'EXCHANGE', + valueTransfers: [ + { + from: aggregatorAddress, + to: subjectAddress, + amount: '4894004361763', + decimal: 18, + symbol: 'ETH', + name: 'Ether', + transferType: 'internal', + }, + { + from: subjectAddress, + to: aggregatorAddress, + amount: '10000', + decimal: 6, + contractAddress: lineaMusd, + symbol: 'mUSD', + name: 'MetaMask USD', + transferType: 'erc20', + }, + ], + logs: [], + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'swap', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1779930229000, + data: { + hash: '0x80b974d5834e1047a78332369de3d4b988f0237ff8a418c9464217e55c542f2f', + sourceToken: { + amount: '10000', + decimals: 6, + direction: 'out', + assetId: toAssetId(lineaMusd, 'eip155:59144'), + symbol: 'mUSD', + }, + destinationToken: { + amount: '4894004361763', + decimals: 18, + direction: 'in', + assetId: toAssetId(NATIVE_TOKEN_ADDRESS, 'eip155:59144'), + symbol: 'ETH', + }, + }, + }); + }); + + it('maps an NFT sale with received native value to a Send activity', () => { + const nftRecipientAddress = '0x4f5243ceea96cee1da0fdb89c756d0e999439424'; + const nftBuyerAddress = '0x78c87da124bb36a914ff1c0f2d642f47870c997c'; + const transaction = { + timestamp: '2026-02-23T22:04:23.000Z', + chainId: 1, + from: nftBuyerAddress, + to: subjectAddress, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: subjectAddress, + to: nftRecipientAddress, + amount: 1, + tokenId: '984', + symbol: 'BAE', + transferType: 'erc1155', + }, + { + from: nftBuyerAddress, + to: subjectAddress, + amount: '1000000000000000', + decimal: 18, + symbol: 'ETH', + transferType: 'normal', + }, + ], + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'send', + chainId: 'eip155:1', + status: 'success', + timestamp: 1771884263000, + data: { + from: subjectAddress, + hash: undefined, + to: nftRecipientAddress, + token: { + amount: '1', + direction: 'out', + symbol: 'BAE', + }, + }, + }); + }); + + it('maps an NFT mint transfer to an nftMint activity without assetId', () => { + const nftContractAddress = '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d'; + const transaction = { + hash: '0x25805d4ae16935e6fa92add9dcee97db0127749d4244032a79489098a880210c', + timestamp: '2026-05-13T14:34:23.000Z', + chainId: 59144, + from: zeroAddress, + to: subjectAddress, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: zeroAddress, + to: subjectAddress, + contractAddress: nftContractAddress, + tokenId: '1', + symbol: 'TDN', + transferType: 'erc721', + }, + ], + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'nftMint', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1778682863000, + data: { + hash: '0x25805d4ae16935e6fa92add9dcee97db0127749d4244032a79489098a880210c', + from: zeroAddress, + to: subjectAddress, + token: { + direction: 'in', + symbol: 'TDN', + }, + }, + }); + }); + + it('maps an Aave supply contract call to a Lending deposit activity', () => { + const transaction = { + hash: '0x08d14578168f22001e95503469c63613bd9f3d3f60e81dbbf204fbd21f484bd9', + timestamp: '2026-05-13T03:31:29.000Z', + chainId: 8453, + from: subjectAddress, + to: baseAavePool, + methodId: '0x617ba037', + transactionCategory: 'CONTRACT_CALL', + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: zeroAddress, + to: subjectAddress, + amount: '99999', + decimal: 6, + contractAddress: baseAaveUsdc, + symbol: 'aBasUSDC', + }, + { + from: subjectAddress, + to: baseAaveUsdc, + amount: '100000', + decimal: 6, + contractAddress: baseUsdc, + symbol: 'USDC', + }, + ], + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'lendingDeposit', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1778643089000, + data: { + hash: '0x08d14578168f22001e95503469c63613bd9f3d3f60e81dbbf204fbd21f484bd9', + sourceToken: { + amount: '100000', + decimals: 6, + direction: 'out', + symbol: 'USDC', + assetId: toAssetId(baseUsdc, 'eip155:8453'), + }, + destinationToken: { + amount: '99999', + decimals: 6, + direction: 'in', + symbol: 'aBasUSDC', + assetId: toAssetId(baseAaveUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('maps a DEPOSIT without an inbound transfer to a deposit activity', () => { + const stakingContractAddress = '0x00000000219ab540356cbb839cbe05303d7705fa'; + const transaction = { + hash: '0xabc123deposit00000000000000000000000000000000000000000000000001', + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 1, + from: subjectAddress, + to: stakingContractAddress, + transactionCategory: 'DEPOSIT', + valueTransfers: [ + { + from: subjectAddress, + to: stakingContractAddress, + amount: '1000000000000000000', + decimal: 18, + symbol: 'ETH', + transferType: 'normal', + }, + ], + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'deposit', + chainId: 'eip155:1', + status: 'success', + timestamp: 1778593067000, + data: { + hash: '0xabc123deposit00000000000000000000000000000000000000000000000001', + token: { + amount: '1000000000000000000', + decimals: 18, + direction: 'in', + symbol: 'ETH', + assetId: toAssetId(NATIVE_TOKEN_ADDRESS, 'eip155:1'), + }, + }, + }); + }); + + it('maps a MetaMask mUSD bonus claim to a Claim mUSD bonus activity', () => { + const transaction = { + hash: '0x875ded271a40278391fca5d71892231afd0cb9592f31bdf3b7c949906cb982c4', + timestamp: '2026-05-13T00:48:45.000Z', + chainId: 59144, + from: subjectAddress, + to: metamaskBonusContract, + transactionCategory: 'CLAIM_BONUS', + valueTransfers: [ + { + from: metamaskBonusContract, + to: subjectAddress, + contractAddress: lineaMusd, + symbol: 'mUSD', + }, + ], + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'claimMusdBonus', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1778633325000, + data: { + hash: '0x875ded271a40278391fca5d71892231afd0cb9592f31bdf3b7c949906cb982c4', + token: { + direction: 'in', + symbol: 'mUSD', + assetId: toAssetId(lineaMusd, 'eip155:59144'), + }, + }, + }); + }); + + it('maps a WETH deposit to a Wrap activity', () => { + const transaction = { + hash: '0x6e448f5b8cf55534507770c1cb90ba14e723d03b4a46b4919a5847eb8d13b7b5', + timestamp: '2026-05-28T13:42:23.000Z', + chainId: 1, + from: subjectAddress, + to: wethContractAddress, + methodId: '0xd0e30db0', + transactionCategory: 'DEPOSIT', + transactionProtocol: 'WETH', + transactionType: 'WETH_DEPOSIT', + valueTransfers: [ + { + from: NATIVE_TOKEN_ADDRESS, + to: subjectAddress, + amount: '1000000000000', + decimal: 18, + contractAddress: wethContractAddress, + symbol: 'WETH', + transferType: 'erc20', + }, + { + from: subjectAddress, + to: wethContractAddress, + amount: '1000000000000', + decimal: 18, + symbol: 'ETH', + transferType: 'normal', + }, + ], + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'wrap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1779975743000, + data: { + hash: '0x6e448f5b8cf55534507770c1cb90ba14e723d03b4a46b4919a5847eb8d13b7b5', + sourceToken: { + amount: '1000000000000', + decimals: 18, + direction: 'out', + symbol: 'ETH', + assetId: toAssetId(NATIVE_TOKEN_ADDRESS, 'eip155:1'), + }, + destinationToken: { + amount: '1000000000000', + decimals: 18, + direction: 'in', + symbol: 'WETH', + assetId: toAssetId(wethContractAddress, 'eip155:1'), + }, + }, + }); + }); + + it('maps a WETH withdrawal to an Unwrap activity', () => { + const transaction = { + hash: '0x8f2a1c9e4b7d30651234567890abcdef1234567890abcdef1234567890abcdef', + timestamp: '2026-05-28T14:15:00.000Z', + chainId: 1, + from: subjectAddress, + to: wethContractAddress, + methodId: '0x2e1a7d4d', + transactionCategory: 'UNWRAP', + transactionProtocol: 'WETH', + transactionType: 'WETH_WITHDRAW', + valueTransfers: [ + { + from: subjectAddress, + to: wethContractAddress, + amount: '1000000000000', + decimal: 18, + contractAddress: wethContractAddress, + symbol: 'WETH', + transferType: 'erc20', + }, + { + from: wethContractAddress, + to: subjectAddress, + amount: '1000000000000', + decimal: 18, + symbol: 'ETH', + transferType: 'normal', + }, + ], + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'unwrap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1779977700000, + data: { + hash: '0x8f2a1c9e4b7d30651234567890abcdef1234567890abcdef1234567890abcdef', + sourceToken: { + amount: '1000000000000', + decimals: 18, + direction: 'out', + symbol: 'WETH', + assetId: toAssetId(wethContractAddress, 'eip155:1'), + }, + destinationToken: { + amount: '1000000000000', + decimals: 18, + direction: 'in', + symbol: 'ETH', + assetId: toAssetId(NATIVE_TOKEN_ADDRESS, 'eip155:1'), + }, + }, + }); + }); + + it('maps a bridge withdraw to a Bridge activity', () => { + const transaction = { + hash: '0x9f81163d00374094411f44732738c6dea194551e4500bde9fd7ee60319aac766', + timestamp: '2026-05-28T04:13:31.000Z', + chainId: 8453, + methodId: '0xe9ae5c53', + value: '0', + to: subjectAddress, + from: subjectAddress, + isError: false, + valueTransfers: [ + { + from: subjectAddress, + to: '0xa5c1ce365ddb5a91ff466774ec4bdf8f97cb9f55', + amount: '100000', + decimal: 6, + contractAddress: baseUsdc, + symbol: 'USDC', + transferType: 'erc20', + }, + ], + logs: [], + transactionCategory: 'BRIDGE_WITHDRAW', + transactionProtocol: 'ACROSS', + transactionType: 'ACROSS_BRIDGE_WITHDRAW', + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'bridge', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779941611000, + data: { + hash: '0x9f81163d00374094411f44732738c6dea194551e4500bde9fd7ee60319aac766', + sourceToken: { + amount: '100000', + decimals: 6, + direction: 'out', + symbol: 'USDC', + assetId: toAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('maps an unrecognized transaction category to a contract interaction activity', () => { + const transaction = { + timestamp: '2026-05-12T16:04:40.000Z', + chainId: 56, + from: bscContractCallerAddress, + to: bscUniversalRouter, + methodId: '0x174dea71', + transactionCategory: 'CONTRACT_CALL', + transactionProtocol: 'GENERIC', + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: subjectAddress, + to: bscRecipientAddress, + symbol: 'BNB', + }, + ], + } as unknown as V1TransactionByHashResponse; + + expect( + withoutRaw(mapApiEvmTransactions({ subjectAddress, transaction })), + ).toStrictEqual({ + type: 'contractInteraction', + chainId: 'eip155:56', + status: 'success', + timestamp: 1778601880000, + data: { + from: bscContractCallerAddress, + hash: undefined, + methodId: '0x174dea71', + to: bscUniversalRouter, + transactionCategory: 'CONTRACT_CALL', + transactionProtocol: 'GENERIC', + transactionType: 'GENERIC_CONTRACT_CALL', + }, + }); + }); +}); diff --git a/app/util/activity-adapters/adapters/api-evm-transactions.ts b/app/util/activity-adapters/adapters/api-evm-transactions.ts new file mode 100644 index 000000000000..0841367c1523 --- /dev/null +++ b/app/util/activity-adapters/adapters/api-evm-transactions.ts @@ -0,0 +1,388 @@ +/* + * Vendored from metamask-extension shared/lib/activity/adapters/api-evm-transactions.ts + * Branch: origin/n3ps/activity-v3-prototype + * TODO: Replace with shared @metamask/activity-adapters package when published. + * + * Extension dependencies are provided via ActivityAdapterEnvironment. + */ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { KnownCaipNamespace, toCaipChainId } from '@metamask/utils'; +import type { ActivityListItem, Status, TokenAmount } from '../types'; +import { supplyMethodIds } from './constants'; +import { + getTokenMetadataFromKnownToken, + getTokenAmountFromTransfer, + withFallbackTokenAssetId, + type ValueTransfer, +} from './helpers'; +import { + mobileActivityAdapterEnvironment, + type ActivityAdapterEnvironment, +} from './environment'; + +// Converts indexed API transactions into the shared activity item shape +export function mapApiEvmTransactions({ + subjectAddress, + transaction, + environment = mobileActivityAdapterEnvironment, +}: { + subjectAddress: string; + transaction: V1TransactionByHashResponse; + environment?: ActivityAdapterEnvironment; +}): ActivityListItem { + const { hash, transactionCategory, valueTransfers } = transaction; + const status: Status = transaction.isError ? 'failed' : 'success'; + const timestamp = new Date(transaction.timestamp).getTime(); + const chainId = toCaipChainId( + KnownCaipNamespace.Eip155, + transaction.chainId.toString(), + ); + const getToken = ( + transfer: ValueTransfer | undefined, + direction: TokenAmount['direction'], + ) => getTokenAmountFromTransfer(transfer, direction, chainId, environment); + + const sentTransfer = valueTransfers?.find(({ from }) => + environment.equalsIgnoreCase(from, subjectAddress), + ); + const receivedTransfer = valueTransfers?.find(({ to }) => + environment.equalsIgnoreCase(to, subjectAddress), + ); + const sentNftTransfer = valueTransfers?.find( + ({ from, transferType }) => + environment.equalsIgnoreCase(from, subjectAddress) && + (transferType === 'erc721' || transferType === 'erc1155'), + ); + const receivedNftTransfer = valueTransfers?.find( + ({ to, transferType }) => + environment.equalsIgnoreCase(to, subjectAddress) && + (transferType === 'erc721' || transferType === 'erc1155'), + ); + const sentNativeTransfer = valueTransfers?.find( + ({ from, transferType }) => + environment.equalsIgnoreCase(from, subjectAddress) && + transferType === 'normal', + ); + const hasNativeTransferWithoutMethod = + transactionCategory === 'CONTRACT_CALL' && + !transaction.methodId && + valueTransfers?.some(({ transferType }) => transferType === 'normal'); + const hasSupplyMethodId = + transaction.methodId && supplyMethodIds.has(transaction.methodId); + + if (transactionCategory === 'SWAP' || transactionCategory === 'EXCHANGE') { + if (!receivedTransfer?.symbol) { + return { + type: 'swapIncomplete', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + sourceToken: getToken(sentTransfer, 'out'), + hash, + }, + }; + } + + return { + type: 'swap', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + hash, + }, + }; + } + + if (transactionCategory === 'APPROVE') { + // TODO: Categorize REVOKE in the backend + const approveTransfer = sentTransfer ?? receivedTransfer; + const approveDirection = receivedTransfer && !sentTransfer ? 'in' : 'out'; + const approveToken = + getToken(approveTransfer, approveDirection) ?? + getTokenMetadataFromKnownToken( + transaction.to, + approveDirection, + chainId, + environment, + ); + + return { + type: 'approveSpendingCap', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + token: approveToken, + }, + }; + } + + // TODO: Categorize NFT in the backend, sometimes TRANSFER or CONTRACT_CALL + if (sentNftTransfer || receivedNftTransfer) { + if (receivedNftTransfer) { + if (receivedNftTransfer.from === environment.nativeTokenAddress) { + return { + type: 'nftMint', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + from: receivedNftTransfer.from, + to: receivedNftTransfer.to, + token: getToken(receivedNftTransfer, 'in'), + }, + }; + } + + if (sentNativeTransfer) { + return { + type: 'buy', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + token: getToken(receivedNftTransfer, 'in'), + }, + }; + } + + return { + type: 'receive', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + from: receivedNftTransfer.from, + to: receivedNftTransfer.to, + token: getToken(receivedNftTransfer, 'in'), + }, + }; + } + + if (sentNftTransfer) { + return { + type: 'send', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + from: sentNftTransfer.from, + to: sentNftTransfer.to, + token: getToken(sentNftTransfer, 'out'), + }, + }; + } + } + + if ( + transactionCategory === 'TRANSFER' || + transactionCategory === 'STANDARD' || + hasNativeTransferWithoutMethod + ) { + const isReceive = + Boolean(receivedTransfer) || + (environment.equalsIgnoreCase(transaction.to, subjectAddress) && + !environment.equalsIgnoreCase(transaction.from, subjectAddress)); + + const transfer = isReceive ? receivedTransfer : sentTransfer; + + return { + type: isReceive ? 'receive' : 'send', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + from: transfer?.from ?? transaction.from, + to: transfer?.to ?? transaction.to, + token: withFallbackTokenAssetId( + getToken(transfer, isReceive ? 'in' : 'out'), + transaction.to, + transfer?.transferType, + chainId, + environment, + ), + hash, + }, + }; + } + + if (transactionCategory === 'CLAIM_BONUS') { + return { + type: 'claimMusdBonus', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + token: getToken(receivedTransfer, 'in'), + }, + }; + } + + if (transactionCategory === 'CLAIM') { + return { + type: 'claim', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + token: getToken( + receivedTransfer ?? sentTransfer, + receivedTransfer ? 'in' : 'out', + ), + }, + }; + } + + if (transactionCategory === 'WITHDRAW') { + return { + type: 'lendingWithdrawal', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + }, + }; + } + + if (transactionCategory === 'BRIDGE_WITHDRAW') { + return { + type: 'bridge', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + sourceToken: getToken(sentTransfer, 'out'), + }, + }; + } + + // TODO: Categorize Deposit/Stake in the backend + if (sentTransfer && hasSupplyMethodId) { + return { + type: 'lendingDeposit', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + }, + }; + } + + // TODO: Categorize these Swaps in the backend + if ( + transactionCategory === 'CONTRACT_CALL' && + sentTransfer?.symbol && + receivedTransfer?.symbol && + sentTransfer.symbol !== receivedTransfer.symbol + ) { + return { + type: 'swap', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + hash, + }, + }; + } + + // TODO: Not sure if this is specific enough, may need separate category in backend + if ( + transactionCategory === 'DEPOSIT' && + receivedTransfer && + !hasSupplyMethodId + ) { + return { + type: 'wrap', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + }, + }; + } + + if (transactionCategory === 'UNWRAP') { + return { + type: 'unwrap', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + }, + }; + } + + if (transactionCategory === 'DEPOSIT') { + return { + type: 'deposit', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + token: getToken(sentTransfer, 'in'), + }, + }; + } + + return { + type: 'contractInteraction', + chainId, + status, + timestamp, + raw: { type: 'apiEvmTransaction', data: transaction }, + data: { + hash, + methodId: transaction.methodId, + from: transaction.from, + to: transaction.to, + transactionCategory, + transactionProtocol: transaction.transactionProtocol, + transactionType: transaction.transactionType, + }, + }; +} diff --git a/app/util/activity-adapters/adapters/constants.ts b/app/util/activity-adapters/adapters/constants.ts new file mode 100644 index 000000000000..086528937177 --- /dev/null +++ b/app/util/activity-adapters/adapters/constants.ts @@ -0,0 +1,15 @@ +/** + * Vendored from metamask-extension shared/lib/activity/adapters/constants.ts + * Branch: origin/n3ps/activity-v3-prototype + * TODO: Replace with shared @metamask/activity-adapters package when published. + */ + +// Known method IDs for supply/deposit calls +export const supplyMethodIds = new Set(['0x617ba037', '0xa1903eab']); + +// Known method IDs for withdraw calls +export const withdrawMethodIds = new Set(['0x69328dec']); + +// WETH9-style wrap / unwrap (deposit / withdraw) +export const wrapMethodIds = new Set(['0xd0e30db0']); +export const unwrapMethodIds = new Set(['0x2e1a7d4d']); diff --git a/app/util/activity-adapters/adapters/dedup.test.ts b/app/util/activity-adapters/adapters/dedup.test.ts new file mode 100644 index 000000000000..675dbd516dfa --- /dev/null +++ b/app/util/activity-adapters/adapters/dedup.test.ts @@ -0,0 +1,38 @@ +import type { ActivityListItem } from '../types'; +import { mergeActivityItems } from './dedup'; + +const makeItem = ( + source: string, + timestamp: number, + hash?: string, +): ActivityListItem => + ({ + type: 'send', + chainId: 'eip155:1', + status: 'success', + timestamp, + data: { hash }, + raw: { type: source, data: {} }, + }) as unknown as ActivityListItem; + +describe('mergeActivityItems', () => { + it('lets confirmed EVM items win over local duplicates and sorts newest first', () => { + const localDuplicate = makeItem('localTransaction', 1, '0xABC'); + const confirmed = makeItem('apiEvmTransaction', 2, '0xabc'); + const nonEvm = makeItem('keyringTransaction', 3, 'solana-hash'); + + expect(mergeActivityItems([localDuplicate], [confirmed], [nonEvm])).toEqual( + [nonEvm, confirmed], + ); + }); + + it('keeps hashless items because there is no stable dedup key', () => { + const local = makeItem('localTransaction', 1); + const confirmed = makeItem('apiEvmTransaction', 2); + + expect(mergeActivityItems([local], [confirmed], [])).toEqual([ + confirmed, + local, + ]); + }); +}); diff --git a/app/util/activity-adapters/adapters/dedup.ts b/app/util/activity-adapters/adapters/dedup.ts new file mode 100644 index 000000000000..6afe47fa776a --- /dev/null +++ b/app/util/activity-adapters/adapters/dedup.ts @@ -0,0 +1,55 @@ +/** + * Deduplication and merge helpers for ActivityListItem lists. + * Mirrors the Extension's helpers.ts#dedupeItems + groupActivityListItems, + * adapted for Mobile's three-source merge pattern. + */ +import type { ActivityListItem } from '../types'; + +function getItemHash(item: ActivityListItem): string | undefined { + return item.data.hash?.toLowerCase(); +} + +/** + * Merges local-EVM, API-confirmed-EVM, and non-EVM ActivityListItem arrays into + * a single list sorted newest-first. API-confirmed items win deduplication over + * local items with the same hash. + * + * Order of precedence: local first, then confirmed, then non-EVM — matching the + * original Mobile behavior where confirmed beats local by hash. + */ +export function mergeActivityItems( + localItems: ActivityListItem[], + confirmedEvmItems: ActivityListItem[], + nonEvmItems: ActivityListItem[], +): ActivityListItem[] { + const seenHashes = new Set(); + const result: ActivityListItem[] = []; + + // Confirmed EVM items are authoritative — add them first to seed seen hashes. + for (const item of confirmedEvmItems) { + const hash = getItemHash(item); + if (hash) { + if (seenHashes.has(hash)) continue; + seenHashes.add(hash); + } + result.push(item); + } + + // Local items: include unless a confirmed item with the same hash exists. + for (const item of localItems) { + const hash = getItemHash(item); + if (hash && seenHashes.has(hash)) continue; + if (hash) seenHashes.add(hash); + result.push(item); + } + + // Non-EVM: deduplicate by hash. + for (const item of nonEvmItems) { + const hash = getItemHash(item); + if (hash && seenHashes.has(hash)) continue; + if (hash) seenHashes.add(hash); + result.push(item); + } + + return result.sort((a, b) => b.timestamp - a.timestamp); +} diff --git a/app/util/activity-adapters/adapters/environment.ts b/app/util/activity-adapters/adapters/environment.ts new file mode 100644 index 000000000000..e52213144ce1 --- /dev/null +++ b/app/util/activity-adapters/adapters/environment.ts @@ -0,0 +1,81 @@ +/** + * Host dependency boundary for the vendored activity adapters. + * TODO: Move this contract into @metamask/activity-adapters when published. + */ +import { + BRIDGE_CHAINID_COMMON_TOKEN_PAIR, + IN_PROGRESS_TRANSACTION_STATUSES, + NATIVE_TOKEN_ADDRESS, + STATIC_MAINNET_TOKEN_LIST, + SWAPS_WRAPPED_TOKENS_ADDRESSES, + SmartTransactionStatus, + TOKEN_TRANSFER_LOG_TOPIC_HASH, + TransactionGroupStatus, + equalsIgnoreCase, + parseStandardTokenTransactionData, + toAssetId, +} from './shims'; +import { getNativeAssetForChainId } from '@metamask/bridge-controller'; + +export interface ActivityTokenMetadata { + symbol?: string; + decimals?: number; + assetId?: string; +} + +export interface ParsedStandardTokenTransactionData { + args?: Record; +} + +export interface ActivityAdapterEnvironment { + bridgeChainIdCommonTokenPair: Record< + string, + ActivityTokenMetadata | undefined + >; + equalsIgnoreCase: (value?: string, other?: string) => boolean; + getNativeAssetForChainId: ( + chainId: string, + ) => ActivityTokenMetadata | undefined; + inProgressTransactionStatuses: readonly string[]; + nativeTokenAddress: string; + parseStandardTokenTransactionData: ( + data: string, + ) => ParsedStandardTokenTransactionData | undefined; + smartTransactionStatus: { + cancelled: string; + pending: string; + success: string; + }; + staticMainnetTokenList: Record; + tokenTransferLogTopicHash: string; + toAssetId: ( + address: string, + chainId: string | undefined, + ) => string | undefined; + transactionGroupStatus: { + cancelled: string; + pending: string; + }; + wrappedTokenAddresses: Record; +} + +export const mobileActivityAdapterEnvironment: ActivityAdapterEnvironment = { + bridgeChainIdCommonTokenPair: BRIDGE_CHAINID_COMMON_TOKEN_PAIR, + equalsIgnoreCase, + getNativeAssetForChainId: (chainId) => { + try { + return getNativeAssetForChainId(chainId); + } catch { + return undefined; + } + }, + inProgressTransactionStatuses: IN_PROGRESS_TRANSACTION_STATUSES, + nativeTokenAddress: NATIVE_TOKEN_ADDRESS, + parseStandardTokenTransactionData, + smartTransactionStatus: SmartTransactionStatus, + staticMainnetTokenList: STATIC_MAINNET_TOKEN_LIST, + tokenTransferLogTopicHash: TOKEN_TRANSFER_LOG_TOPIC_HASH, + toAssetId, + transactionGroupStatus: TransactionGroupStatus, + wrappedTokenAddresses: SWAPS_WRAPPED_TOKENS_ADDRESSES, +}; diff --git a/app/util/activity-adapters/adapters/helpers.test.ts b/app/util/activity-adapters/adapters/helpers.test.ts new file mode 100644 index 000000000000..ee3bee8b6e55 --- /dev/null +++ b/app/util/activity-adapters/adapters/helpers.test.ts @@ -0,0 +1,143 @@ +import { + TransactionStatus, + TransactionType, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import { getLocalTransactionStatus } from './helpers'; + +type LocalTransactionStatusInput = Parameters< + typeof getLocalTransactionStatus +>[0]; + +const baseTransactionMeta = { + chainId: '0x1', + id: 'activity-helpers-test-tx', + networkClientId: 'mainnet', + time: 0, + txParams: {}, +} as const; + +const makeGroup = ( + overrides: Partial = {}, +): LocalTransactionStatusInput => ({ + primaryTransaction: { + ...baseTransactionMeta, + txReceipt: {}, + type: 'simpleSend', + status: TransactionStatus.submitted, + ...overrides, + } as TransactionMeta, + initialTransaction: { + ...baseTransactionMeta, + isSmartTransaction: false, + txParams: {}, + ...overrides, + } as TransactionMeta & { isSmartTransaction?: boolean }, +}); + +describe('getLocalTransactionStatus', () => { + it('maps confirmed → success', () => { + const group = makeGroup({ status: TransactionStatus.confirmed }); + + expect(getLocalTransactionStatus(group)).toBe('success'); + }); + + it('maps failed → failed', () => { + const group = makeGroup({ status: TransactionStatus.failed }); + + expect(getLocalTransactionStatus(group)).toBe('failed'); + }); + + it('maps dropped → failed', () => { + const group = makeGroup({ status: TransactionStatus.dropped }); + + expect(getLocalTransactionStatus(group)).toBe('failed'); + }); + + it('maps rejected → failed', () => { + const group = makeGroup({ status: TransactionStatus.rejected }); + + expect(getLocalTransactionStatus(group)).toBe('failed'); + }); + + it('maps cancelled (cancel-type tx) → failed', () => { + const group = makeGroup({ + status: TransactionStatus.confirmed, + type: TransactionType.cancel, + }); + + expect(getLocalTransactionStatus(group)).toBe('failed'); + }); + + it('maps submitted → pending', () => { + const group = makeGroup({ status: TransactionStatus.submitted }); + + expect(getLocalTransactionStatus(group)).toBe('pending'); + }); + + it('maps approved → pending', () => { + const group = makeGroup({ status: TransactionStatus.approved }); + + expect(getLocalTransactionStatus(group)).toBe('pending'); + }); + + it('maps unapproved → pending', () => { + const group = makeGroup({ status: TransactionStatus.unapproved }); + + expect(getLocalTransactionStatus(group)).toBe('pending'); + }); + + it('maps signed → pending', () => { + const group = makeGroup({ status: TransactionStatus.signed }); + + expect(getLocalTransactionStatus(group)).toBe('pending'); + }); + + it('maps receipt status 0x0 (revert) → failed', () => { + const group = makeGroup({ + status: TransactionStatus.confirmed, + txReceipt: { status: '0x0' }, + }); + + expect(getLocalTransactionStatus(group)).toBe('failed'); + }); + + it('maps smart tx pending → pending', () => { + const group: LocalTransactionStatusInput = { + primaryTransaction: makeGroup({}).primaryTransaction, + initialTransaction: { + ...baseTransactionMeta, + isSmartTransaction: true, + status: 'pending', + } as unknown as TransactionMeta & { isSmartTransaction?: boolean }, + }; + + expect(getLocalTransactionStatus(group)).toBe('pending'); + }); + + it('maps smart tx success → success', () => { + const group: LocalTransactionStatusInput = { + primaryTransaction: makeGroup({}).primaryTransaction, + initialTransaction: { + ...baseTransactionMeta, + isSmartTransaction: true, + status: 'success', + } as unknown as TransactionMeta & { isSmartTransaction?: boolean }, + }; + + expect(getLocalTransactionStatus(group)).toBe('success'); + }); + + it('maps smart tx cancelled → failed', () => { + const group: LocalTransactionStatusInput = { + primaryTransaction: makeGroup({}).primaryTransaction, + initialTransaction: { + ...baseTransactionMeta, + isSmartTransaction: true, + status: 'cancelled', + } as unknown as TransactionMeta & { isSmartTransaction?: boolean }, + }; + + expect(getLocalTransactionStatus(group)).toBe('failed'); + }); +}); diff --git a/app/util/activity-adapters/adapters/helpers.ts b/app/util/activity-adapters/adapters/helpers.ts new file mode 100644 index 000000000000..4316da84d6d6 --- /dev/null +++ b/app/util/activity-adapters/adapters/helpers.ts @@ -0,0 +1,243 @@ +/* + * Vendored from metamask-extension shared/lib/activity/adapters/helpers.ts + * Branch: origin/n3ps/activity-v3-prototype + * TODO: Replace with shared @metamask/activity-adapters package when published. + * + * Extension dependencies are provided via ActivityAdapterEnvironment. + */ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import type { CaipChainId, Hex } from '@metamask/utils'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { TransactionGroup } from './transaction-group'; +import type { Status, TokenAmount } from '../types'; +import { + mobileActivityAdapterEnvironment, + type ActivityAdapterEnvironment, +} from './environment'; + +const MAINNET_HEX_CHAIN_ID = '0x1'; + +export type ValueTransfer = NonNullable< + V1TransactionByHashResponse['valueTransfers'] +>[number]; + +const resolveAssetId = ( + chainId: CaipChainId, + { + contractAddress, + transferType, + }: { + contractAddress?: string; + transferType?: string; + }, + environment: ActivityAdapterEnvironment, +): string | undefined => { + if (contractAddress) { + return environment.toAssetId(contractAddress, chainId); + } + + if (transferType === 'normal' || transferType === 'internal') { + return environment.toAssetId(environment.nativeTokenAddress, chainId); + } + + return undefined; +}; + +function getTransactionStatusKey( + transaction: TransactionGroup['primaryTransaction'], + environment: ActivityAdapterEnvironment, +): string { + const { + txReceipt: { status: receiptStatus } = {}, + type, + status, + } = transaction; + + if (receiptStatus === '0x0') { + return TransactionStatus.failed; + } + + if ( + status === TransactionStatus.confirmed && + type === TransactionType.cancel + ) { + return environment.transactionGroupStatus.cancelled; + } + + return transaction.status; +} + +export function getLocalTransactionStatus( + { + primaryTransaction, + initialTransaction, + }: { + primaryTransaction: TransactionGroup['primaryTransaction']; + initialTransaction: TransactionGroup['initialTransaction']; + }, + environment: ActivityAdapterEnvironment = mobileActivityAdapterEnvironment, +): Status { + if (initialTransaction.isSmartTransaction) { + const smartStatus = initialTransaction.status as string | undefined; + + if (smartStatus === environment.smartTransactionStatus.pending) { + return 'pending'; + } + + if (smartStatus === environment.smartTransactionStatus.success) { + return 'success'; + } + + if (smartStatus === environment.smartTransactionStatus.cancelled) { + return 'failed'; + } + + return 'pending'; + } + + const statusKey = getTransactionStatusKey(primaryTransaction, environment); + + if (statusKey === TransactionStatus.confirmed) { + return 'success'; + } + + if ( + statusKey === TransactionStatus.cancelled || + statusKey === environment.transactionGroupStatus.cancelled || + statusKey === TransactionStatus.dropped || + statusKey === TransactionStatus.failed || + statusKey === TransactionStatus.rejected + ) { + return 'failed'; + } + + if (environment.inProgressTransactionStatuses.includes(statusKey)) { + return 'pending'; + } + + return 'pending'; +} + +export function getKnownTokenMetadata( + chainId: CaipChainId | Hex, + contractAddress?: string, + environment: ActivityAdapterEnvironment = mobileActivityAdapterEnvironment, +) { + if (contractAddress === undefined) { + return undefined; + } + + const assetId = environment.toAssetId(contractAddress, chainId); + const tokenMetadata = + (chainId === MAINNET_HEX_CHAIN_ID || assetId?.startsWith('eip155:1/') + ? environment.staticMainnetTokenList[contractAddress.toLowerCase()] + : undefined) ?? + Object.values(environment.bridgeChainIdCommonTokenPair).find( + (token) => + token?.assetId !== undefined && + assetId !== undefined && + environment.equalsIgnoreCase(token.assetId, assetId), + ); + + return tokenMetadata + ? { ...tokenMetadata, ...(assetId ? { assetId } : {}) } + : undefined; +} + +export function getTokenMetadataFromKnownToken( + contractAddress: string | undefined, + direction: TokenAmount['direction'], + chainId: CaipChainId | Hex, + environment: ActivityAdapterEnvironment = mobileActivityAdapterEnvironment, +) { + const tokenMetadata = getKnownTokenMetadata( + chainId, + contractAddress, + environment, + ); + + if (!tokenMetadata) { + return undefined; + } + + return { + direction, + ...(tokenMetadata.symbol ? { symbol: tokenMetadata.symbol } : {}), + ...(tokenMetadata.decimals === undefined + ? {} + : { decimals: tokenMetadata.decimals }), + ...(tokenMetadata.assetId ? { assetId: tokenMetadata.assetId } : {}), + }; +} + +export function getTokenAmountFromTransfer( + transfer: ValueTransfer | undefined, + direction: TokenAmount['direction'], + chainId: CaipChainId, + environment: ActivityAdapterEnvironment = mobileActivityAdapterEnvironment, +) { + if (!transfer?.symbol && transfer?.amount === undefined) { + return undefined; + } + + const isNftTransfer = + transfer?.transferType === 'erc721' || transfer?.transferType === 'erc1155'; + + const assetId = + transfer && !isNftTransfer + ? resolveAssetId( + chainId, + { + contractAddress: transfer.contractAddress, + transferType: transfer.transferType, + }, + environment, + ) + : undefined; + + return { + direction, + ...(transfer.amount === null || transfer.amount === undefined + ? {} + : { amount: String(transfer.amount) }), + ...(transfer.decimal === undefined ? {} : { decimals: transfer.decimal }), + ...(transfer.symbol ? { symbol: transfer.symbol } : {}), + ...(assetId ? { assetId } : {}), + }; +} + +/** + * When the transfer omits contractAddress, fall back to the indexed tx `to` field. + * + * @param token - Parsed token amount from the value transfer. + * @param fallbackContractAddress - Indexed transaction `to` address used as ERC-20 fallback. + * @param transferType - Value transfer type; native (`normal`) transfers skip the fallback. + * @param chainId - CAIP-2 chain id for asset id encoding. + * @returns Token amount with `assetId` set when a fallback address applies. + */ +export function withFallbackTokenAssetId( + token: TokenAmount | undefined, + fallbackContractAddress: string | undefined, + transferType: string | undefined, + chainId: CaipChainId, + environment: ActivityAdapterEnvironment = mobileActivityAdapterEnvironment, +): TokenAmount | undefined { + if ( + !token || + token.assetId || + transferType === 'normal' || + !fallbackContractAddress + ) { + return token; + } + + const assetId = environment.toAssetId(fallbackContractAddress, chainId); + if (!assetId) { + return token; + } + + return { ...token, assetId }; +} diff --git a/app/util/activity-adapters/adapters/keyring-transaction.test.ts b/app/util/activity-adapters/adapters/keyring-transaction.test.ts new file mode 100644 index 000000000000..75e784fc7e52 --- /dev/null +++ b/app/util/activity-adapters/adapters/keyring-transaction.test.ts @@ -0,0 +1,168 @@ +import { + TransactionStatus, + TransactionType, + type Transaction, +} from '@metamask/keyring-api'; +import { mapKeyringTransaction } from './keyring-transaction'; + +const SOLANA_CHAIN_ID = + 'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ' as Transaction['chain']; +const BITCOIN_CHAIN_ID = + 'bip122:000000000019d6689c085ae165831e93' as Transaction['chain']; + +describe('mapKeyringTransaction', () => { + it('maps keyring send transactions with token amount data', () => { + const item = mapKeyringTransaction({ + transaction: { + id: 'send-id', + chain: SOLANA_CHAIN_ID, + account: '00000000-0000-4000-8000-000000000000', + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.Send, + from: [ + { + address: 'from-address', + asset: { + fungible: true, + type: `${SOLANA_CHAIN_ID}/token:usdc`, + unit: 'USDC', + amount: '2.5', + }, + }, + ], + to: [{ address: 'to-address', asset: null }], + fees: [], + events: [], + } as Transaction, + }); + + expect(item).toStrictEqual( + expect.objectContaining({ + type: 'send', + chainId: SOLANA_CHAIN_ID, + status: 'success', + timestamp: 1716367781000, + data: { + hash: 'send-id', + from: 'from-address', + to: 'to-address', + token: { + amount: '2.5', + assetId: `${SOLANA_CHAIN_ID}/token:usdc`, + direction: 'out', + symbol: 'USDC', + }, + }, + }), + ); + }); + + it('maps keyring swap transactions with source and destination token amounts', () => { + const item = mapKeyringTransaction({ + transaction: { + id: 'swap-id', + chain: SOLANA_CHAIN_ID, + account: '00000000-0000-4000-8000-000000000000', + status: TransactionStatus.Submitted, + timestamp: 1716367781, + type: TransactionType.Swap, + from: [ + { + address: 'from-address', + asset: { + fungible: true, + type: `${SOLANA_CHAIN_ID}/slip44:501`, + unit: 'SOL', + amount: '1', + }, + }, + ], + to: [ + { + address: 'to-address', + asset: { + fungible: true, + type: `${SOLANA_CHAIN_ID}/token:usdc`, + unit: 'USDC', + amount: '100', + }, + }, + ], + fees: [], + events: [], + } as Transaction, + }); + + expect(item).toStrictEqual( + expect.objectContaining({ + type: 'swap', + chainId: SOLANA_CHAIN_ID, + status: 'pending', + timestamp: 1716367781000, + data: { + hash: 'swap-id', + sourceToken: { + amount: '1', + assetId: `${SOLANA_CHAIN_ID}/slip44:501`, + direction: 'out', + symbol: 'SOL', + }, + destinationToken: { + amount: '100', + assetId: `${SOLANA_CHAIN_ID}/token:usdc`, + direction: 'in', + symbol: 'USDC', + }, + }, + }), + ); + }); + + it('maps bitcoin send token from to-movement when from is empty', () => { + const item = mapKeyringTransaction({ + transaction: { + id: 'btc-send-output-id', + chain: BITCOIN_CHAIN_ID, + account: '00000000-0000-4000-8000-000000000000', + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.Send, + from: [{ address: 'bc1from', asset: null }], + to: [ + { + address: 'bc1to', + asset: { + fungible: true, + type: `${BITCOIN_CHAIN_ID}/slip44:0`, + unit: 'BTC', + amount: '0.1', + }, + }, + ], + fees: [], + events: [], + } as Transaction, + }); + + expect(item).toStrictEqual( + expect.objectContaining({ + type: 'send', + chainId: BITCOIN_CHAIN_ID, + status: 'success', + timestamp: 1716367781000, + data: { + hash: 'btc-send-output-id', + from: 'bc1from', + to: 'bc1to', + token: { + amount: '0.1', + assetId: `${BITCOIN_CHAIN_ID}/slip44:0`, + direction: 'out', + symbol: 'BTC', + }, + }, + }), + ); + }); +}); diff --git a/app/util/activity-adapters/adapters/keyring-transaction.ts b/app/util/activity-adapters/adapters/keyring-transaction.ts new file mode 100644 index 000000000000..e3df7726bd12 --- /dev/null +++ b/app/util/activity-adapters/adapters/keyring-transaction.ts @@ -0,0 +1,195 @@ +/** + * Vendored from metamask-extension shared/lib/activity/adapters/keyring-transaction.ts + * Branch: origin/n3ps/activity-v3-prototype + * TODO: Replace with shared @metamask/activity-adapters package when published. + */ +import { + type Transaction, + TransactionStatus as KeyringTransactionStatus, + TransactionType as KeyringTransactionType, +} from '@metamask/keyring-api'; +import type { ActivityListItem, Status, TokenAmount } from '../types'; + +type Movement = Transaction['from'][number]; +type FungibleAsset = Extract< + NonNullable, + { fungible: true } +>; + +function mapStatus(status: Transaction['status']): Status { + switch (status) { + case KeyringTransactionStatus.Confirmed: + return 'success'; + case KeyringTransactionStatus.Failed: + return 'failed'; + case KeyringTransactionStatus.Submitted: + case KeyringTransactionStatus.Unconfirmed: + default: + return 'pending'; + } +} + +function mapTimestamp(timestamp: Transaction['timestamp']) { + return timestamp ? timestamp * 1000 : 0; +} + +function getAddress(movements: Movement[]) { + return movements[0]?.address ?? ''; +} + +function hasFungibleAsset( + movement: Movement, +): movement is Movement & { asset: FungibleAsset } { + return movement.asset?.fungible === true; +} + +function getToken( + movements: Movement[], + direction: TokenAmount['direction'], +): TokenAmount | undefined { + const movement = movements.find(hasFungibleAsset); + + if (!movement) { + return undefined; + } + + return { + amount: movement.asset.amount, + symbol: movement.asset.unit, + assetId: movement.asset.type, + direction, + }; +} + +function aggregateMovementAmount(movements: Movement[]) { + const amountByAssetType: Record< + string, + { + amount: number; + unit: string; + } + > = {}; + + for (const movement of movements) { + if (!hasFungibleAsset(movement)) { + continue; + } + + const { type: assetType, unit } = movement.asset; + const parsedAmount = Number.parseFloat(movement.asset.amount); + const normalizedAmount = Number.isFinite(parsedAmount) ? parsedAmount : 0; + + if (!amountByAssetType[assetType]) { + amountByAssetType[assetType] = { + amount: normalizedAmount, + unit, + }; + continue; + } + + amountByAssetType[assetType].amount += normalizedAmount; + } + + const entries = Object.entries(amountByAssetType); + if (entries.length !== 1) { + return undefined; + } + + const [assetType, aggregate] = entries[0]; + return { + assetType, + amount: String(aggregate.amount), + unit: aggregate.unit, + }; +} + +// Converts keyring API transactions into the shared activity item shape +export function mapKeyringTransaction({ + transaction, +}: { + transaction: Transaction; +}): ActivityListItem { + const status = mapStatus(transaction.status); + const timestamp = mapTimestamp(transaction.timestamp); + const chainId = transaction.chain; + const from = getAddress(transaction.from); + const to = getAddress(transaction.to); + + if (transaction.type === KeyringTransactionType.Send) { + const fromToken = getToken(transaction.from, 'out'); + let token = fromToken; + + // Bitcoin transaction.from can be empty, resulting in no token avatar or send amount displayed. + // Workaround: use the same aggregated to-asset fallback strategy as tx details modal. + if (!fromToken && chainId.startsWith('bip122:')) { + const aggregatedToAsset = aggregateMovementAmount(transaction.to); + if (aggregatedToAsset) { + token = { + direction: 'out', + assetId: aggregatedToAsset.assetType, + symbol: aggregatedToAsset.unit, + amount: aggregatedToAsset.amount, + }; + } + } + + return { + type: 'send', + chainId, + status, + timestamp, + raw: { type: 'keyringTransaction', data: transaction }, + data: { + hash: transaction.id, + from, + to, + token, + }, + }; + } + + if (transaction.type === KeyringTransactionType.Receive) { + return { + type: 'receive', + chainId, + status, + timestamp, + raw: { type: 'keyringTransaction', data: transaction }, + data: { + hash: transaction.id, + from, + to, + token: getToken(transaction.to, 'in'), + }, + }; + } + + if (transaction.type === KeyringTransactionType.Swap) { + return { + type: 'swap', + chainId, + status, + timestamp, + raw: { type: 'keyringTransaction', data: transaction }, + data: { + hash: transaction.id, + destinationToken: getToken(transaction.to, 'in'), + sourceToken: getToken(transaction.from, 'out'), + }, + }; + } + + return { + type: 'contractInteraction', + chainId, + status, + timestamp, + raw: { type: 'keyringTransaction', data: transaction }, + data: { + hash: transaction.id, + from, + to, + transactionType: transaction.type, + }, + }; +} diff --git a/app/util/activity-adapters/adapters/local-transaction.test.ts b/app/util/activity-adapters/adapters/local-transaction.test.ts new file mode 100644 index 000000000000..01c6f7884f20 --- /dev/null +++ b/app/util/activity-adapters/adapters/local-transaction.test.ts @@ -0,0 +1,753 @@ +import { + TransactionStatus, + TransactionType, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import { mapLocalTransaction } from './local-transaction'; +import type { TransactionGroup } from './transaction-group'; +import { toAssetId } from './shims'; +import { MERKL_DISTRIBUTOR_ADDRESS } from '../../../components/UI/Earn/components/MerklRewards/constants'; + +const from = '0x9bed78535d6a03a955f1504aadba974d9a29e292'; +const to = '0x80181d3ba89220cdb80234fc7aa19d5cc56229cc'; +const mainnet = '0x1'; +const base = '0x2105'; +const linea = '0xe708'; +const baseUsdc = '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'; +const baseAavePool = '0xa238dd80c259a72e81d7e4664a9801593f98d1c5'; +const lineaDai = '0x4af15ec2a0bd43db75dd04e62faa3b8ef36b00d5'; +const lineaMusd = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; +const wethContractAddress = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; +const erc20TransferTopic = + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + +const withoutRaw = (item: ReturnType) => { + const activity = { ...item }; + delete activity.raw; + return activity; +}; + +const makeGroup = ( + transaction: Partial, + overrides: Partial = {}, +): TransactionGroup => + ({ + initialTransaction: transaction, + primaryTransaction: transaction, + nativeAssetSymbol: 'ETH', + ...overrides, + }) as TransactionGroup; + +const addressTopic = (address: string) => + `0x${address.toLowerCase().replace('0x', '').padStart(64, '0')}`; + +describe('mapLocalTransaction', () => { + it('maps a pending native send to a Send activity', () => { + const transaction = { + chainId: mainnet, + id: 'send-id', + hash: '0xsend', + status: TransactionStatus.submitted, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { + from, + to, + value: '0x1', + }, + } as unknown as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'send', + chainId: 'eip155:1', + status: 'pending', + timestamp: 1716367781000, + data: { + hash: '0xsend', + from, + to, + token: { + amount: '0x1', + assetId: 'eip155:1/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + }, + }); + }); + + it('maps an ERC-20 transfer without transferInformation from known token metadata', () => { + const tokenContractAddress = '0xdac17f958d2ee523a2206206994597c13d831ec7'; + const recipient = '0xa6372EDD08c857870f9c245A17eE6895307957d5'; + const transaction = { + chainId: mainnet, + id: 'usdt-send-id', + hash: '0x41f675c4a384e5064b1d9620934b0ff5e8a84f5c84530a25d025e27fb784d303', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from, + to: tokenContractAddress, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + } as unknown as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'send', + chainId: 'eip155:1', + status: 'success', + timestamp: 1779392463306, + data: { + hash: '0x41f675c4a384e5064b1d9620934b0ff5e8a84f5c84530a25d025e27fb784d303', + from, + to: recipient, + token: { + amount: '100000', + assetId: toAssetId(tokenContractAddress, 'eip155:1'), + decimals: 6, + direction: 'out', + symbol: 'USDT', + }, + }, + }); + }); + + it('maps a custom network native send without bridge native asset metadata', () => { + const customChainId = '0x53a'; + const transaction = { + chainId: customChainId, + id: 'custom-send-id', + hash: '0xcustomsend', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.simpleSend, + txParams: { + from, + to, + value: '0xde0b6b3a7640000', + }, + } as unknown as Partial; + + expect( + withoutRaw( + mapLocalTransaction( + makeGroup(transaction, { + nativeAssetSymbol: 'ETH', + nonce: '0x1', + transactions: [transaction as TransactionMeta], + }), + ), + ), + ).toStrictEqual({ + type: 'send', + chainId: 'eip155:1338', + status: 'success', + timestamp: 1779392463306, + data: { + hash: '0xcustomsend', + from, + to, + token: { + amount: '0xde0b6b3a7640000', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + }, + }); + }); + + it('maps a USDC transfer with transferInformation', () => { + const tokenContractAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const recipient = '0x50A9D56C2B8BA9A5c7f2C08C3d26E0499F23a706'; + const transaction = { + chainId: mainnet, + id: 'token-send-id', + hash: '0xtokensend', + status: TransactionStatus.submitted, + time: 1716367781000, + transferInformation: { + amount: '20000', + contractAddress: tokenContractAddress, + decimals: 6, + symbol: 'USDC', + }, + type: TransactionType.tokenMethodTransfer, + txParams: { + from, + to: tokenContractAddress, + data: '0xa9059cbb00000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a7060000000000000000000000000000000000000000000000000000000000004e20', + }, + } as unknown as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'send', + chainId: 'eip155:1', + status: 'pending', + timestamp: 1716367781000, + data: { + hash: '0xtokensend', + from, + to: recipient, + token: { + amount: '20000', + assetId: toAssetId(tokenContractAddress, 'eip155:1'), + decimals: 6, + direction: 'out', + symbol: 'USDC', + }, + }, + }); + }); + + it('leaves unknown token transfer symbols blank', () => { + const tokenContractAddress = '0x1111111111111111111111111111111111111111'; + const transaction = { + chainId: mainnet, + id: 'unknown-token-send-id', + hash: '0xunknown', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from, + to: tokenContractAddress, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + } as unknown as Partial; + + const item = mapLocalTransaction(makeGroup(transaction)); + + expect(item.type).toBe('send'); + if (item.type !== 'send') { + throw new Error(`Expected send item, got ${item.type}`); + } + + expect(item.data.token).toStrictEqual({ + amount: '100000', + assetId: toAssetId(tokenContractAddress, 'eip155:1'), + direction: 'out', + }); + }); + + it('uses the original transaction type and primary transaction status', () => { + const initialTransaction = { + chainId: linea, + id: 'approve-id', + hash: '0xapprove', + status: TransactionStatus.submitted, + time: 1716367781000, + transferInformation: { + contractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + decimals: 0, + symbol: 'TDN', + }, + type: TransactionType.tokenMethodApprove, + txParams: { + from, + to: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + data: '0xa22cb465', + }, + } as Partial; + const primaryTransaction = { + ...initialTransaction, + id: 'retry-id', + hash: '0xretry', + status: TransactionStatus.approved, + time: 1716367881000, + type: TransactionType.retry, + } as Partial; + + expect( + withoutRaw( + mapLocalTransaction( + makeGroup(initialTransaction, { + hasRetried: true, + nonce: '0x2', + primaryTransaction: primaryTransaction as TransactionMeta, + transactions: [ + initialTransaction as TransactionMeta, + primaryTransaction as TransactionMeta, + ], + }), + ), + ), + ).toStrictEqual({ + type: 'approveSpendingCap', + chainId: 'eip155:59144', + status: 'pending', + timestamp: 1716367881000, + data: { + hash: '0xretry', + token: { + assetId: toAssetId( + '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + 'eip155:59144', + ), + direction: 'out', + symbol: 'TDN', + }, + }, + }); + }); + + it('uses bridge history token data to map a local swap', () => { + const transaction = { + chainId: base, + id: 'bridge-swap-id', + hash: '0xbridgeswap', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.swap, + txParams: { + from, + to, + value: '0x0', + }, + } as Partial; + + expect( + withoutRaw( + mapLocalTransaction( + makeGroup(transaction, { + sourceToken: { + amount: '10000000000000', + assetId: 'eip155:8453/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + destinationToken: { + amount: '19546', + assetId: + 'eip155:8453/erc20:0xACa92e438df0B2401fF60Da7E4337B687a2435dA', + decimals: 6, + direction: 'in', + symbol: 'MUSD', + }, + }), + ), + ), + ).toStrictEqual({ + type: 'swap', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779392463306, + data: { + hash: '0xbridgeswap', + sourceToken: { + amount: '10000000000000', + assetId: 'eip155:8453/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + destinationToken: { + amount: '19546', + assetId: + 'eip155:8453/erc20:0xACa92e438df0B2401fF60Da7E4337B687a2435dA', + decimals: 6, + direction: 'in', + symbol: 'MUSD', + }, + }, + }); + }); + + it('maps an mUSD conversion to a Convert activity', () => { + const transaction = { + chainId: linea, + id: 'musd-conversion-id', + hash: '0xmusdconversion', + status: TransactionStatus.confirmed, + time: 1779805800000, + type: TransactionType.musdConversion, + txParams: { + from, + to: lineaMusd, + value: '0x0', + data: '0xa9059cbb0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000018703', + }, + } as Partial; + + expect( + withoutRaw( + mapLocalTransaction( + makeGroup(transaction, { + sourceToken: { + assetId: toAssetId(lineaDai, 'eip155:59144'), + decimals: 18, + direction: 'out', + symbol: 'DAI', + }, + }), + ), + ), + ).toStrictEqual({ + type: 'convert', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1779805800000, + data: { + hash: '0xmusdconversion', + sourceToken: { + assetId: toAssetId(lineaDai, 'eip155:59144'), + decimals: 18, + direction: 'out', + symbol: 'DAI', + }, + destinationToken: { + amount: '100099', + assetId: toAssetId(lineaMusd, 'eip155:59144'), + decimals: 6, + direction: 'in', + symbol: 'mUSD', + }, + }, + }); + }); + + it('maps an mUSD claim receipt payout to a Claim mUSD bonus activity with token amount', () => { + const transaction = { + chainId: linea, + id: 'musd-claim-id', + hash: '0xmusdclaim', + status: TransactionStatus.confirmed, + time: 1779805800000, + type: TransactionType.musdClaim, + txParams: { + from, + to: MERKL_DISTRIBUTOR_ADDRESS, + value: '0x0', + }, + txReceipt: { + logs: [ + { + address: lineaMusd, + data: '0x0f4240', + topics: [ + erc20TransferTopic, + addressTopic(MERKL_DISTRIBUTOR_ADDRESS), + addressTopic(from), + ], + }, + ], + }, + } as unknown as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'claimMusdBonus', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1779805800000, + data: { + hash: '0xmusdclaim', + token: { + amount: '1000000', + assetId: toAssetId(lineaMusd, 'eip155:59144'), + decimals: 6, + direction: 'in', + symbol: 'mUSD', + }, + }, + }); + }); + + it('maps an Aave supply contract interaction to a Lending deposit activity', () => { + const transaction = { + chainId: base, + id: 'aave-supply-id', + hash: '0x093844dd6200984f0e27d3c3a76b7a63b360bfb2136213237d693afd2cd69740', + status: TransactionStatus.confirmed, + time: 1779892154611, + type: TransactionType.contractInteraction, + txParams: { + from, + to: baseAavePool, + value: '0x0', + data: '0x617ba037000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda0291300000000000000000000000000000000000000000000000000000000000186a00000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000000000', + }, + simulationData: { + tokenBalanceChanges: [ + { + address: baseUsdc, + difference: '0x186a0', + isDecrease: true, + standard: 'erc20', + }, + ], + }, + } as unknown as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'lendingDeposit', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779892154611, + data: { + hash: '0x093844dd6200984f0e27d3c3a76b7a63b360bfb2136213237d693afd2cd69740', + sourceToken: { + amount: '100000', + assetId: toAssetId(baseUsdc, 'eip155:8453'), + decimals: 6, + direction: 'out', + symbol: 'USDC', + }, + }, + }); + }); + + it('maps a withdraw contract interaction from the received token transfer', () => { + const transaction = { + chainId: base, + hash: '0x26f4911467b538702c0945e4ec5e303de44c0c1c174897141d1b548ea3161795', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from, + to: baseAavePool, + data: '0x69328dec000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda029130000000000000000000000000000000000000000000000000000000000030d400000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e292', + }, + txReceipt: { + logs: [ + { + address: baseUsdc, + data: '0x0000000000000000000000000000000000000000000000000000000000030d40', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x0000000000000000000000004e65fe4dba92790696d040ac24aa414708f5c0ab', + '0x0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e292', + ], + }, + ], + }, + } as unknown as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'lendingWithdrawal', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779912434153, + data: { + hash: '0x26f4911467b538702c0945e4ec5e303de44c0c1c174897141d1b548ea3161795', + destinationToken: { + amount: '200000', + assetId: toAssetId(baseUsdc, 'eip155:8453'), + decimals: 6, + direction: 'in', + symbol: 'USDC', + }, + }, + }); + }); + + it('uses a bridge history activity status override', () => { + const transaction = { + chainId: mainnet, + id: 'bridge-id', + hash: '0xbridge', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.bridge, + txParams: { + from, + to, + value: '0x0', + }, + } as Partial; + + expect( + mapLocalTransaction( + makeGroup(transaction, { + activityStatus: 'failed', + }), + ).status, + ).toBe('failed'); + }); + + it('maps swap metadata token symbols to a Swap activity', () => { + const transaction = { + chainId: base, + id: 'swap-id', + hash: '0xswap', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: '0x246139ca8000', + }, + } as unknown as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'swap', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1716367781000, + data: { + hash: '0xswap', + sourceToken: { + assetId: 'eip155:8453/slip44:60', + direction: 'out', + symbol: 'ETH', + }, + destinationToken: { + direction: 'in', + symbol: 'USDC', + }, + }, + }); + }); + + it('maps a WETH9 deposit contract interaction to a Wrap activity', () => { + const transaction = { + chainId: mainnet, + id: 'wrap-id', + hash: '0xwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from, + to: wethContractAddress, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + } as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'wrap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1716367781000, + data: { + hash: '0xwrap', + sourceToken: { + amount: '0x3782dace9d900000', + assetId: 'eip155:1/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + destinationToken: { + amount: '0x3782dace9d900000', + assetId: toAssetId(wethContractAddress, 'eip155:1'), + decimals: 18, + direction: 'in', + symbol: 'WETH', + }, + }, + }); + }); + + it('maps a WETH9 withdraw contract interaction to an Unwrap activity', () => { + const unwrapAmount = '1000000000000000000'; + const unwrapAmountHex = BigInt(unwrapAmount).toString(16).padStart(64, '0'); + const transaction = { + chainId: mainnet, + id: 'unwrap-id', + hash: '0xunwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from, + to: wethContractAddress, + value: '0x0', + data: `0x2e1a7d4d${unwrapAmountHex}`, + }, + } as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'unwrap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1716367781000, + data: { + hash: '0xunwrap', + sourceToken: { + amount: unwrapAmount, + assetId: toAssetId(wethContractAddress, 'eip155:1'), + decimals: 18, + direction: 'out', + symbol: 'WETH', + }, + destinationToken: { + amount: unwrapAmount, + assetId: 'eip155:1/slip44:60', + decimals: 18, + direction: 'in', + symbol: 'ETH', + }, + }, + }); + }); + + it('maps a native value contract interaction with an outgoing token', () => { + const transaction = { + chainId: mainnet, + id: 'contract-interaction-id', + hash: '0xcontract', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from, + to, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + } as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'contractInteraction', + chainId: 'eip155:1', + status: 'success', + timestamp: 1716367781000, + data: { + hash: '0xcontract', + from, + to, + token: { + amount: '0x3782dace9d900000', + assetId: 'eip155:1/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + methodId: '0xd0e30db0', + transactionType: TransactionType.contractInteraction, + }, + }); + }); +}); diff --git a/app/util/activity-adapters/adapters/local-transaction.ts b/app/util/activity-adapters/adapters/local-transaction.ts new file mode 100644 index 000000000000..a2a1537a1098 --- /dev/null +++ b/app/util/activity-adapters/adapters/local-transaction.ts @@ -0,0 +1,628 @@ +/* + * Vendored from metamask-extension shared/lib/activity/adapters/local-transaction.ts + * Branch: origin/n3ps/activity-v3-prototype + * TODO: Replace with shared @metamask/activity-adapters package when published. + * + * Extension dependencies are provided via ActivityAdapterEnvironment. + */ +import { TransactionType } from '@metamask/transaction-controller'; +import { KnownCaipNamespace, toCaipChainId, type Hex } from '@metamask/utils'; +import { getClaimPayoutFromReceipt } from '../../../components/UI/Earn/utils/musd'; +import { + MUSD_DECIMALS, + MUSD_TOKEN, + MUSD_TOKEN_ADDRESS, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, +} from '../../../components/UI/Earn/constants/musd'; +import type { ActivityListItem, TokenAmount } from '../types'; +import type { TransactionGroup } from './transaction-group'; +import { + supplyMethodIds, + unwrapMethodIds, + withdrawMethodIds, + wrapMethodIds, +} from './constants'; +import { getKnownTokenMetadata, getLocalTransactionStatus } from './helpers'; +import { + mobileActivityAdapterEnvironment, + type ActivityAdapterEnvironment, +} from './environment'; + +const EVM_NATIVE_DECIMALS = 18; + +// Converts local TransactionController groups into activity items +export function mapLocalTransaction( + transactionGroup: TransactionGroup, + environment: ActivityAdapterEnvironment = mobileActivityAdapterEnvironment, +): ActivityListItem { + const { initialTransaction, primaryTransaction } = transactionGroup; + const chainId = toCaipChainId( + KnownCaipNamespace.Eip155, + Number.parseInt(initialTransaction.chainId, 16).toString(), + ); + const nativeAsset = environment.getNativeAssetForChainId( + initialTransaction.chainId, + ); + // Prefer the network-configured ticker (resolved by the selector from + // NetworkController state) over the bridge-controller swaps registry, + // which hard-codes synthetic symbols like `TESTETH` for chains such as + // Localhost (0x539) regardless of how the user configured the network. + const nativeSymbol = + transactionGroup.nativeAssetSymbol ?? nativeAsset?.symbol; + + const getNativeToken = ( + transaction: TransactionGroup['initialTransaction'], + direction: TokenAmount['direction'], + ) => { + if (nativeSymbol === undefined) { + return undefined; + } + + return { + direction, + symbol: nativeSymbol, + ...(transaction.txParams.value + ? { amount: transaction.txParams.value } + : {}), + ...(nativeAsset?.assetId ? { assetId: nativeAsset.assetId } : {}), + decimals: nativeAsset?.decimals ?? EVM_NATIVE_DECIMALS, + }; + }; + + const getContractToken = ({ + amount, + contractAddress, + direction, + transaction, + }: { + amount?: string; + contractAddress?: string; + direction: TokenAmount['direction']; + transaction: TransactionGroup['initialTransaction']; + }) => { + if (contractAddress === undefined) { + return undefined; + } + + const tokenMetadata = getKnownTokenMetadata( + chainId, + contractAddress, + environment, + ); + const decimals = + transaction.transferInformation?.amount === undefined + ? (tokenMetadata?.decimals ?? + transactionGroup.contractTokenMetadata?.decimals) + : transaction.transferInformation.decimals; + const tokenAmount = transaction.transferInformation?.amount ?? amount; + const symbol = + transaction.transferInformation?.symbol ?? + tokenMetadata?.symbol ?? + transactionGroup.contractTokenMetadata?.symbol; + const assetId = environment.toAssetId(contractAddress, chainId); + + return { + direction, + ...(symbol ? { symbol } : {}), + ...(assetId ? { assetId } : {}), + ...(tokenAmount ? { amount: tokenAmount } : {}), + ...(decimals === undefined ? {} : { decimals }), + }; + }; + + const getLegacySwapToken = (direction: TokenAmount['direction']) => { + const { value } = initialTransaction.txParams; + let hasNativeValue = false; + + if (value !== undefined && value !== '') { + try { + hasNativeValue = BigInt(value) > 0n; + } catch { + hasNativeValue = false; + } + } + + let symbol: string | undefined; + + if (direction === 'out') { + symbol = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (initialTransaction as any).sourceTokenSymbol ?? + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (primaryTransaction as any).sourceTokenSymbol ?? + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (initialTransaction as any).swapMetaData?.token_from ?? + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (primaryTransaction as any).swapMetaData?.token_from ?? + (hasNativeValue ? nativeSymbol : undefined); + } else { + symbol = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (initialTransaction as any).destinationTokenSymbol ?? + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (primaryTransaction as any).destinationTokenSymbol ?? + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (initialTransaction as any).swapMetaData?.token_to ?? + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (primaryTransaction as any).swapMetaData?.token_to; + } + + if (symbol === undefined) { + return undefined; + } + + return { + direction, + symbol, + ...(symbol === nativeSymbol && nativeAsset?.assetId + ? { assetId: nativeAsset.assetId } + : {}), + }; + }; + + const status = + transactionGroup.activityStatus ?? + getLocalTransactionStatus( + { + primaryTransaction, + initialTransaction, + }, + environment, + ); + const timestamp = primaryTransaction.time ?? initialTransaction.time; + const hash = + primaryTransaction.hash ?? initialTransaction.hash ?? primaryTransaction.id; + const from = initialTransaction.txParams.from ?? ''; + const to = initialTransaction.txParams.to ?? ''; + const methodId = initialTransaction.txParams.data?.slice(0, 10); + + switch (initialTransaction.type) { + case TransactionType.simpleSend: { + return { + type: 'send', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + from, + to, + token: getNativeToken(initialTransaction, 'out'), + }, + }; + } + + case TransactionType.tokenMethodSafeTransferFrom: + case TransactionType.tokenMethodTransfer: + case TransactionType.tokenMethodTransferFrom: { + const transactionData = initialTransaction.txParams.data + ? environment.parseStandardTokenTransactionData( + initialTransaction.txParams.data, + ) + : undefined; + const recipient = transactionData?.args?._to ?? transactionData?.args?.to; + const amount = + transactionData?.args?._value ?? transactionData?.args?.value; + + return { + type: 'send', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + from, + to: typeof recipient === 'string' ? recipient : to, + token: getContractToken({ + amount: amount?.toString(), + transaction: initialTransaction, + direction: 'out', + contractAddress: initialTransaction.txParams.to, + }), + }, + }; + } + + case TransactionType.incoming: { + return { + type: 'receive', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + from, + to, + token: initialTransaction.transferInformation?.contractAddress + ? getContractToken({ + transaction: initialTransaction, + direction: 'in', + contractAddress: + initialTransaction.transferInformation.contractAddress, + }) + : getNativeToken(initialTransaction, 'in'), + }, + }; + } + + case TransactionType.swap: + case TransactionType.swapAndSend: { + const { + sourceToken: enrichedSourceToken, + destinationToken: enrichedDestinationToken, + } = transactionGroup; + const sourceToken = enrichedSourceToken ?? getLegacySwapToken('out'); + const destinationToken = + enrichedDestinationToken ?? getLegacySwapToken('in'); + + if (destinationToken?.symbol === undefined) { + return { + type: 'swapIncomplete', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + sourceToken, + }, + }; + } + + return { + type: 'swap', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + sourceToken, + destinationToken, + }, + }; + } + + case TransactionType.bridge: { + const { + sourceToken: enrichedSourceToken, + destinationToken: enrichedDestinationToken, + } = transactionGroup; + return { + type: 'bridge', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + sourceToken: enrichedSourceToken, + destinationToken: enrichedDestinationToken, + }, + }; + } + + case TransactionType.musdConversion: { + const transactionData = initialTransaction.txParams.data + ? environment.parseStandardTokenTransactionData( + initialTransaction.txParams.data, + ) + : undefined; + const amount = + transactionData?.args?._value ?? transactionData?.args?.value; + + return { + type: 'convert', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + sourceToken: transactionGroup.sourceToken, + destinationToken: getContractToken({ + amount: amount?.toString(), + transaction: initialTransaction, + direction: 'in', + contractAddress: initialTransaction.txParams.to, + }), + }, + }; + } + + case TransactionType.bridgeApproval: + case TransactionType.shieldSubscriptionApprove: + case TransactionType.swapApproval: + case TransactionType.tokenMethodApprove: + case TransactionType.tokenMethodSetApprovalForAll: + return { + type: 'approveSpendingCap', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + token: getContractToken({ + transaction: initialTransaction, + direction: 'out', + contractAddress: initialTransaction.txParams.to, + }), + }, + }; + + case TransactionType.tokenMethodIncreaseAllowance: + return { + type: 'increaseSpendingCap', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + token: getContractToken({ + transaction: initialTransaction, + direction: 'out', + contractAddress: initialTransaction.txParams.to, + }), + }, + }; + + case TransactionType.lendingDeposit: + return { + type: 'lendingDeposit', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + sourceToken: getContractToken({ + transaction: initialTransaction, + direction: 'out', + contractAddress: initialTransaction.txParams.to, + }), + }, + }; + + case TransactionType.stakingDeposit: + return { + type: 'deposit', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + token: getContractToken({ + transaction: initialTransaction, + direction: 'out', + contractAddress: initialTransaction.txParams.to, + }), + }, + }; + + case TransactionType.musdClaim: { + const claimAmountRaw = getClaimPayoutFromReceipt( + primaryTransaction.txReceipt?.logs, + from, + ); + + return { + type: 'claimMusdBonus', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + ...(claimAmountRaw + ? { + token: { + amount: claimAmountRaw, + assetId: + MUSD_TOKEN_ASSET_ID_BY_CHAIN[ + initialTransaction.chainId as Hex + ] ?? environment.toAssetId(MUSD_TOKEN_ADDRESS, chainId), + decimals: MUSD_DECIMALS, + direction: 'in', + symbol: MUSD_TOKEN.symbol, + }, + } + : {}), + }, + }; + } + + default: { + const isSupplyContractInteraction = + initialTransaction.type === TransactionType.contractInteraction && + methodId && + supplyMethodIds.has(methodId.toLowerCase()); + const isWithdrawContractInteraction = + initialTransaction.type === TransactionType.contractInteraction && + methodId && + withdrawMethodIds.has(methodId.toLowerCase()); + + const suppliedTokenBalanceChange = + isSupplyContractInteraction && + initialTransaction.simulationData?.tokenBalanceChanges?.find( + ({ isDecrease, standard }) => isDecrease && standard === 'erc20', + ); + + if (suppliedTokenBalanceChange) { + return { + type: 'lendingDeposit', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + sourceToken: getContractToken({ + amount: BigInt(suppliedTokenBalanceChange.difference).toString(), + transaction: initialTransaction, + direction: 'out', + contractAddress: suppliedTokenBalanceChange.address, + }), + }, + }; + } + + if (isWithdrawContractInteraction) { + const fromAddress = from.toLowerCase(); + const receivedTokenLog = ( + initialTransaction.txReceipt?.logs ?? [] + ).find(({ topics: [eventTopic, , logTo] = [] }) => { + const toAddress = logTo + ? `0x${logTo.slice(-40)}`.toLowerCase() + : undefined; + + return ( + eventTopic?.toLowerCase() === + environment.tokenTransferLogTopicHash && toAddress === fromAddress + ); + }); + const destinationToken = receivedTokenLog + ? getContractToken({ + amount: BigInt(String(receivedTokenLog.data)).toString(), + transaction: initialTransaction, + direction: 'in', + contractAddress: receivedTokenLog.address, + }) + : undefined; + + return { + type: 'lendingWithdrawal', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + destinationToken, + }, + }; + } + + // wrap and unwrap + if ( + initialTransaction.type === TransactionType.contractInteraction && + methodId + ) { + const wrappedTokenAddress = + environment.wrappedTokenAddresses[initialTransaction.chainId]; + + if ( + wrappedTokenAddress && + environment.equalsIgnoreCase(to, wrappedTokenAddress) + ) { + const normalizedMethodId = methodId.toLowerCase(); + const activityRaw = { + type: 'localTransaction' as const, + data: transactionGroup, + }; + + if (wrapMethodIds.has(normalizedMethodId)) { + const { value: wrapAmount } = initialTransaction.txParams; + + try { + if (wrapAmount && BigInt(wrapAmount) > 0n) { + return { + type: 'wrap', + chainId, + status, + timestamp, + raw: activityRaw, + data: { + hash, + sourceToken: getNativeToken(initialTransaction, 'out'), + destinationToken: getContractToken({ + amount: wrapAmount, + transaction: initialTransaction, + direction: 'in', + contractAddress: wrappedTokenAddress, + }), + }, + }; + } + } catch { + // Invalid native value — fall through. + } + } + + if (unwrapMethodIds.has(normalizedMethodId)) { + const { data } = initialTransaction.txParams; + let unwrapAmount: string | undefined; + + if (data && data.length >= 74) { + try { + unwrapAmount = BigInt(`0x${data.slice(10, 74)}`).toString(); + } catch { + unwrapAmount = undefined; + } + } + + const nativeToken = getNativeToken(initialTransaction, 'in'); + + return { + type: 'unwrap', + chainId, + status, + timestamp, + raw: activityRaw, + data: { + hash, + sourceToken: getContractToken({ + amount: unwrapAmount, + transaction: initialTransaction, + direction: 'out', + contractAddress: wrappedTokenAddress, + }), + destinationToken: + nativeToken && unwrapAmount + ? { ...nativeToken, amount: unwrapAmount } + : nativeToken, + }, + }; + } + } + } + + const token = (() => { + const { value } = initialTransaction.txParams; + + if (value === undefined || value === '') { + return undefined; + } + + try { + return BigInt(value) > 0n + ? getNativeToken(initialTransaction, 'out') + : undefined; + } catch { + return undefined; + } + })(); + + return { + type: 'contractInteraction', + chainId, + status, + timestamp, + raw: { type: 'localTransaction', data: transactionGroup }, + data: { + hash, + from, + to, + ...(token ? { token } : {}), + methodId, + transactionType: initialTransaction.type, + }, + }; + } + } +} diff --git a/app/util/activity-adapters/adapters/shims.ts b/app/util/activity-adapters/adapters/shims.ts new file mode 100644 index 000000000000..6a4961bd60d2 --- /dev/null +++ b/app/util/activity-adapters/adapters/shims.ts @@ -0,0 +1,246 @@ +/** + * Mobile shims for Extension-only constants used by the vendored adapters. + * Each shim maps to the equivalent Mobile constant or provides a minimal inline definition. + * TODO: Remove when shared @metamask/activity-adapters package is published. + */ +import { TransactionStatus } from '@metamask/transaction-controller'; +import { + KnownCaipNamespace, + toCaipChainId, + type CaipChainId, + type CaipAssetType, + isCaipAssetType, +} from '@metamask/utils'; +import { + getNativeAssetForChainId, + isNativeAddress, +} from '@metamask/bridge-controller'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; +import { equalsIgnoreCase as mobileEqualsIgnoreCase } from '../../string'; +import { parseStandardTokenTransactionData as mobileParseStandardTokenTransactionData } from '../../../components/Views/confirmations/utils/transaction'; + +// --------------------------------------------------------------------------- +// Transaction status constants (Extension: shared/constants/transaction.ts) +// --------------------------------------------------------------------------- + +export const IN_PROGRESS_TRANSACTION_STATUSES = [ + TransactionStatus.unapproved, + TransactionStatus.approved, + TransactionStatus.signed, + TransactionStatus.submitted, +] as const; + +/** Matches Extension's SmartTransactionStatus enum values (lowercase). */ +export const SmartTransactionStatus = { + cancelled: 'cancelled', + pending: 'pending', + success: 'success', +} as const; + +/** Matches Extension's TransactionGroupStatus enum. */ +export const TransactionGroupStatus = { + cancelled: 'cancelled', + pending: 'pending', +} as const; + +/** Zero address used as the "native token" sentinel (matches Extension). */ +export const NATIVE_TOKEN_ADDRESS = '0x0'.padEnd(42, '0'); + +// --------------------------------------------------------------------------- +// ERC-20 Transfer log topic hash (Extension: shared/lib/transactions-controller-utils.ts) +// --------------------------------------------------------------------------- + +export const TOKEN_TRANSFER_LOG_TOPIC_HASH = + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + +// --------------------------------------------------------------------------- +// Small dependency shims for Extension shared utilities. +// Keep Mobile-specific imports centralized here so adapter files remain close to +// the future shared package surface. +// --------------------------------------------------------------------------- + +export const equalsIgnoreCase = mobileEqualsIgnoreCase; +export const parseStandardTokenTransactionData = + mobileParseStandardTokenTransactionData; + +// --------------------------------------------------------------------------- +// Wrapped-token addresses per EVM chain (Extension: shared/constants/swaps.ts) +// Only chains where wrap/unwrap detection is needed. +// --------------------------------------------------------------------------- + +export const SWAPS_WRAPPED_TOKENS_ADDRESSES: Record = { + '0x1': '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH (mainnet) + '0x38': '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', // WBNB (BSC) + '0x89': '0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270', // WMATIC (Polygon) + '0xa': '0x4200000000000000000000000000000000000006', // WETH (Optimism) + '0xa4b1': '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', // WETH (Arbitrum) + '0x2105': '0x4200000000000000000000000000000000000006', // WETH (Base) + '0xe708': '0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34', // WETH (Linea) + '0xa86a': '0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7', // WAVAX (Avalanche) + '0x144': '0x5AEa5775959fBC2557Cc8789bC1bf90A239D9a91', // WETH (zkSync Era) +}; + +// --------------------------------------------------------------------------- +// Known token metadata (Extension: shared/constants/tokens.ts STATIC_MAINNET_TOKEN_LIST) +// Minimal known-token fallback used by Extension adapter tests and common +// mainnet approvals/transfers where transaction metadata omits token details. +// TODO: Replace with Mobile's TokensController/token-list data once the +// adapters move to a shared package. +// --------------------------------------------------------------------------- + +export const STATIC_MAINNET_TOKEN_LIST: Record< + string, + { symbol: string; decimals: number; assetId?: string } +> = { + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + symbol: 'USDC', + decimals: 6, + assetId: 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }, + '0xdac17f958d2ee523a2206206994597c13d831ec7': { + symbol: 'USDT', + decimals: 6, + assetId: 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', + }, + '0x6b175474e89094c44da98b954eedeac495271d0f': { + symbol: 'DAI', + decimals: 18, + assetId: 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F', + }, + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': { + symbol: 'WETH', + decimals: 18, + assetId: 'eip155:1/erc20:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + }, +}; + +// --------------------------------------------------------------------------- +// Bridge chain common token pairs (Extension: shared/constants/bridge.ts) +// Common bridge output tokens used as fallback metadata by the Extension +// adapters for approvals without value-transfer metadata. +// --------------------------------------------------------------------------- + +export const BRIDGE_CHAINID_COMMON_TOKEN_PAIR: Record< + string, + { symbol: string; decimals: number; assetId?: string } | undefined +> = { + 'eip155:1': { + symbol: 'mUSD', + decimals: 6, + assetId: 'eip155:1/erc20:0xACa92e438df0B2401fF60Da7E4337B687a2435dA', + }, + 'eip155:10': { + symbol: 'USDC', + decimals: 6, + assetId: 'eip155:10/erc20:0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + }, + 'eip155:56': { + symbol: 'USDT', + decimals: 18, + assetId: 'eip155:56/erc20:0x55d398326f99059fF775485246999027B3197955', + }, + 'eip155:137': { + symbol: 'USDT', + decimals: 6, + assetId: 'eip155:137/erc20:0xc2132D05D31c914a87C6611C10748AEb04B58e8F', + }, + 'eip155:8453': { + symbol: 'USDC', + decimals: 6, + assetId: 'eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + }, + 'eip155:42161': { + symbol: 'USDC', + decimals: 6, + assetId: 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + }, + 'eip155:43114': { + symbol: 'USDC', + decimals: 6, + assetId: 'eip155:43114/erc20:0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E', + }, + 'eip155:59144': { + symbol: 'mUSD', + decimals: 6, + assetId: 'eip155:59144/erc20:0xACa92e438df0B2401fF60Da7E4337B687a2435dA', + }, + 'eip155:324': { + symbol: 'USDT', + decimals: 6, + assetId: 'eip155:324/erc20:0x493257fD37EDB34451f62EDf8D2a0C418852bA4C', + }, +}; + +// --------------------------------------------------------------------------- +// toAssetId — simplified EVM implementation of Extension shared/lib/asset-utils.ts +// Handles the EVM ERC-20 and native-token cases used by the adapters. +// --------------------------------------------------------------------------- + +export function toAssetId( + address: string, + chainId: string | undefined, +): CaipAssetType | undefined { + if (!chainId || !address) { + return undefined; + } + + // Already CAIP-19 — pass through. + if (isCaipAssetType(address)) { + return address as CaipAssetType; + } + + // Resolve hex chainId → CAIP-2 + let caipChainId: CaipChainId; + try { + caipChainId = chainId.includes(':') + ? (chainId as CaipChainId) + : toCaipChainId( + KnownCaipNamespace.Eip155, + parseInt(chainId, 16).toString(), + ); + } catch { + return undefined; + } + + const lowerAddress = address.toLowerCase(); + + // Native token (zero address) → use bridge-controller's native asset registry. + if (isNativeAddress(lowerAddress)) { + try { + return getNativeAssetForChainId(caipChainId)?.assetId as + | CaipAssetType + | undefined; + } catch { + return undefined; + } + } + + // EVM ERC-20 (strict hex address). + if (/^0x[0-9a-f]{40}$/i.test(address)) { + const checksummed = toChecksumHexAddress(address) ?? address; + return `${caipChainId}/erc20:${checksummed}` as CaipAssetType; + } + + return undefined; +} + +// --------------------------------------------------------------------------- +// formatUnits — pure bigint implementation matching Extension shared/lib/unit.ts +// --------------------------------------------------------------------------- + +export function formatUnits(value: bigint, decimals: number): string { + let display = value.toString(); + const negative = display.startsWith('-'); + + if (negative) { + display = display.slice(1); + } + + display = display.padStart(decimals, '0'); + + const integer = display.slice(0, display.length - decimals); + const rawFraction = display.slice(display.length - decimals); + const fraction = rawFraction.replace(/(0+)$/u, ''); + + return `${negative ? '-' : ''}${integer || '0'}${fraction ? `.${fraction}` : ''}`; +} diff --git a/app/util/activity-adapters/adapters/transaction-group.ts b/app/util/activity-adapters/adapters/transaction-group.ts new file mode 100644 index 000000000000..dfb7e6b502d5 --- /dev/null +++ b/app/util/activity-adapters/adapters/transaction-group.ts @@ -0,0 +1,28 @@ +/** + * Mobile-specific TransactionGroup type for the local activity adapter. + * Derived from metamask-extension shared/lib/multichain/types.ts#TransactionGroup. + * TODO: Replace with shared @metamask/activity-adapters package when published. + */ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Status, TokenAmount } from '../types'; + +export interface TransactionGroup { + hasCancelled?: boolean; + hasRetried?: boolean; + /** The earliest transaction in the group (determines type/category). */ + initialTransaction: TransactionMeta & { isSmartTransaction?: boolean }; + nonce?: string; + /** The most recent transaction (determines status). */ + primaryTransaction: TransactionMeta; + transactions?: TransactionMeta[]; + /** Optional status override from bridge history or other enrichment. */ + activityStatus?: Status; + /** Enriched source token (swaps, bridges, converts). */ + sourceToken?: TokenAmount; + /** Enriched destination token (swaps, bridges). */ + destinationToken?: TokenAmount; + /** Native asset ticker from NetworkController, overrides bridge-controller symbol. */ + nativeAssetSymbol?: string; + /** Token metadata from TokensController for contract interactions. */ + contractTokenMetadata?: { symbol?: string; decimals?: number }; +} diff --git a/app/util/activity-adapters/fiat.test.ts b/app/util/activity-adapters/fiat.test.ts new file mode 100644 index 000000000000..e0275f71a5ed --- /dev/null +++ b/app/util/activity-adapters/fiat.test.ts @@ -0,0 +1,106 @@ +import { + applyDisplaySign, + calculateFiatFromMarketRates, + getDisplaySignPrefix, + getHumanReadableTokenAmount, + getTokenAddressForMarketRates, + type MarketRateLookupToken, + toMarketRateLookupToken, +} from './fiat'; +import { NATIVE_TOKEN_ADDRESS } from './adapters/shims'; + +const ethToken: MarketRateLookupToken = { + address: NATIVE_TOKEN_ADDRESS, + symbol: 'ETH', + decimals: 18, + chainId: '0x1', +}; + +describe('activity adapter fiat helpers', () => { + describe('calculateFiatFromMarketRates', () => { + const marketRates = { + 1: { [NATIVE_TOKEN_ADDRESS]: 2500 }, + }; + + it('returns fiat amount for a valid token and amount', () => { + expect(calculateFiatFromMarketRates('1.5', ethToken, marketRates)).toBe( + 3750, + ); + }); + + it('preserves sign for negative amounts', () => { + expect(calculateFiatFromMarketRates('-1', ethToken, marketRates)).toBe( + -2500, + ); + }); + + it('parses leading plus amounts', () => { + expect(calculateFiatFromMarketRates('+1.5', ethToken, marketRates)).toBe( + 3750, + ); + }); + + it('returns undefined when amount, token, or rate is missing', () => { + expect( + calculateFiatFromMarketRates(undefined, ethToken, marketRates), + ).toBeUndefined(); + expect(calculateFiatFromMarketRates('1', undefined, marketRates)).toBe( + undefined, + ); + expect( + calculateFiatFromMarketRates('1', ethToken, { 1: {} }), + ).toBeUndefined(); + }); + }); + + it('returns an unsigned human-readable token amount', () => { + expect( + getHumanReadableTokenAmount({ + amount: '1000000000000000000', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }), + ).toBe('1'); + }); + + it('returns no prefix for incoming amounts when plus is disabled', () => { + expect(getDisplaySignPrefix('in', { showPlus: false })).toBe(''); + }); + + it('applies display signs without duplicating existing signs', () => { + expect(applyDisplaySign('$2,500.00', '+')).toBe('+$2,500.00'); + expect(applyDisplaySign('+$2,500.00', '+')).toBe('+$2,500.00'); + expect(applyDisplaySign('-$2,500.00', '+')).toBe('-$2,500.00'); + expect(applyDisplaySign('1.5 ETH', '-')).toBe('-1.5 ETH'); + expect(applyDisplaySign('-$2,500.00', '-')).toBe('-$2,500.00'); + expect(applyDisplaySign('+$2,500.00', '-')).toBe('+$2,500.00'); + expect(applyDisplaySign('1.5 ETH', '')).toBe('1.5 ETH'); + }); + + it('maps CAIP asset ids to market-rate token addresses', () => { + expect(getTokenAddressForMarketRates('eip155:1/slip44:60')).toBe( + NATIVE_TOKEN_ADDRESS, + ); + expect( + getTokenAddressForMarketRates( + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ), + ).toBe('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'); + }); + + it('builds a market-rate lookup token from an activity token amount', () => { + expect( + toMarketRateLookupToken( + { + amount: '1', + decimals: 18, + direction: 'out', + symbol: 'ETH', + assetId: 'eip155:1/slip44:60', + }, + '0x1', + ), + ).toStrictEqual(ethToken); + }); +}); diff --git a/app/util/activity-adapters/fiat.ts b/app/util/activity-adapters/fiat.ts new file mode 100644 index 000000000000..fd394d732032 --- /dev/null +++ b/app/util/activity-adapters/fiat.ts @@ -0,0 +1,143 @@ +/* + * Vendored from metamask-extension shared/lib/activity/fiat.ts + * Branch: origin/n3ps/activity-v3-prototype + * TODO: Replace with shared @metamask/activity-adapters package when published. + * + * Extension imports replaced with Mobile equivalents: + * - shared/lib/unit#formatUnits → shims#formatUnits (inline pure bigint impl) + * - shared/constants/transaction#NATIVE_TOKEN_ADDRESS → shims + */ +import { + isCaipAssetType, + parseCaipAssetType, + type CaipAssetType, + type Hex, +} from '@metamask/utils'; +import { NATIVE_TOKEN_ADDRESS, formatUnits } from './adapters/shims'; +import type { TokenAmount } from './types'; + +/** Minimal token descriptor used for market-rate lookups. */ +export interface MarketRateLookupToken { + address: string; + symbol: string; + decimals: number; + chainId: Hex; +} + +export function calculateFiatFromMarketRates( + amount: string | undefined, + token: MarketRateLookupToken | undefined, + marketRates: Record>, +) { + if (amount === undefined || !token) { + return undefined; + } + + const parsed = Number.parseFloat(amount); + const rate = marketRates[Number.parseInt(token.chainId, 16)]?.[token.address]; + return rate === undefined ? undefined : parsed * rate; +} + +export function getDisplaySignPrefix( + direction: TokenAmount['direction'], + { showPlus }: { showPlus: boolean }, +): string { + if (direction === 'out') { + return '-'; + } + + if (direction === 'in' && showPlus) { + return '+'; + } + + return ''; +} + +// Converts TokenAmount to unsigned human-readable numeric string (e.g. "1", "1.5") +export function getHumanReadableTokenAmount( + token: TokenAmount, +): string | undefined { + if (!token.amount) { + return undefined; + } + + let value: string; + try { + value = formatUnits(BigInt(token.amount), token.decimals ?? 0); + } catch { + value = token.amount; + } + + return value.startsWith('-') ? value.slice(1) : value; +} + +// Applies display + or - sign to a formatted display value +export function applyDisplaySign( + formattedDisplay: string, + signPrefix: string, +): string { + if ( + signPrefix === '+' && + !formattedDisplay.startsWith('+') && + !formattedDisplay.startsWith('-') + ) { + return `+${formattedDisplay}`; + } + + if ( + signPrefix === '-' && + !formattedDisplay.startsWith('-') && + !formattedDisplay.startsWith('+') + ) { + return `-${formattedDisplay}`; + } + + return formattedDisplay; +} + +export function getTokenAddressForMarketRates( + assetId: CaipAssetType | undefined, +): string | undefined { + if (!assetId) { + return undefined; + } + + if (assetId.includes('/slip44:') || assetId.includes('/native:')) { + return NATIVE_TOKEN_ADDRESS; + } + + try { + const { assetNamespace, assetReference } = parseCaipAssetType(assetId); + + if (assetNamespace === 'erc20' && typeof assetReference === 'string') { + return assetReference.toLowerCase(); + } + + if (assetNamespace === 'slip44' || assetNamespace === 'native') { + return NATIVE_TOKEN_ADDRESS; + } + } catch { + return undefined; + } + + return undefined; +} + +export function toMarketRateLookupToken( + token: TokenAmount, + hexChainId: Hex, +): MarketRateLookupToken | undefined { + const assetId = isCaipAssetType(token.assetId) ? token.assetId : undefined; + const address = getTokenAddressForMarketRates(assetId); + + if (!address) { + return undefined; + } + + return { + address, + symbol: token.symbol ?? '', + decimals: token.decimals ?? 0, + chainId: hexChainId, + }; +} diff --git a/app/util/activity-adapters/index.ts b/app/util/activity-adapters/index.ts new file mode 100644 index 000000000000..b2efcbfbd545 --- /dev/null +++ b/app/util/activity-adapters/index.ts @@ -0,0 +1,33 @@ +/** + * Vendored Activity adapters from metamask-extension shared/lib/activity/ + * Branch: origin/n3ps/activity-v3-prototype + * TODO: Replace with shared @metamask/activity-adapters package when published. + */ +export type { + ActivityListItem, + ActivityKind, + Status, + TokenAmount, +} from './types'; +export { mapApiEvmTransactions } from './adapters/api-evm-transactions'; +export { mapKeyringTransaction } from './adapters/keyring-transaction'; +export { mapLocalTransaction } from './adapters/local-transaction'; +export { + mobileActivityAdapterEnvironment, + type ActivityAdapterEnvironment, +} from './adapters/environment'; +export type { TransactionGroup } from './adapters/transaction-group'; +export { getLabelKeys } from './label-keys'; +export { + calculateFiatFromMarketRates, + getHumanReadableTokenAmount, + getDisplaySignPrefix, + applyDisplaySign, + toMarketRateLookupToken, +} from './fiat'; +export { + activityMatchesAssetId, + groupActivityListItems, + shouldShowPlusSign, + type GroupedActivityListItem, +} from './activity-list-helpers'; diff --git a/app/util/activity-adapters/label-keys.test.ts b/app/util/activity-adapters/label-keys.test.ts new file mode 100644 index 000000000000..017acc0d9cde --- /dev/null +++ b/app/util/activity-adapters/label-keys.test.ts @@ -0,0 +1,19 @@ +import { getLabelKeys } from './label-keys'; + +describe('getLabelKeys', () => { + it('returns title and description keys', () => { + expect( + getLabelKeys({ + type: 'swap', + status: 'pending', + }), + ).toStrictEqual({ + title: { + key: 'activity_swap_pending_title', + }, + description: { + key: 'activity_swap_pending_description', + }, + }); + }); +}); diff --git a/app/util/activity-adapters/label-keys.ts b/app/util/activity-adapters/label-keys.ts new file mode 100644 index 000000000000..3727af2f1bda --- /dev/null +++ b/app/util/activity-adapters/label-keys.ts @@ -0,0 +1,27 @@ +/** + * Vendored from metamask-extension shared/lib/activity/label-keys.ts + * Branch: origin/n3ps/activity-v3-prototype + * TODO: Replace with shared @metamask/activity-adapters package when published. + */ +import type { ActivityKind, Status } from './types'; + +const fallbackLabelKey = 'activity_fallback'; + +export function getLabelKeys({ + type, + status, +}: { + type: ActivityKind; + status: Status; +}) { + const key = type && status ? `activity_${type}_${status}` : fallbackLabelKey; + + return { + title: { + key: `${key}_title`, + }, + description: { + key: `${key}_description`, + }, + }; +} diff --git a/app/util/activity-adapters/types.ts b/app/util/activity-adapters/types.ts new file mode 100644 index 000000000000..00efeb619d51 --- /dev/null +++ b/app/util/activity-adapters/types.ts @@ -0,0 +1,189 @@ +/** + * Vendored from metamask-extension shared/lib/activity/types.ts + * Branch: origin/n3ps/activity-v3-prototype + * TODO: Replace with shared @metamask/activity-adapters package when published. + */ +import type { Transaction } from '@metamask/keyring-api'; +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import type { CaipChainId } from '@metamask/utils'; +import type { TransactionGroup } from './adapters/transaction-group'; + +export type Status = 'pending' | 'success' | 'failed' | 'cancelled'; + +export type ActivityKind = + | 'receive' + | 'sell' + | 'buy' + | 'deposit' + | 'swap' + | 'swapIncomplete' + | 'claim' + | 'claimMusdBonus' + | 'send' + | 'wrap' + | 'unwrap' + | 'approveSpendingCap' + | 'revokeSpendingCap' + | 'increaseSpendingCap' + | 'contractInteraction' + | 'contractDeployment' + | 'bridge' + | 'convert' + | 'smartAccountUpgrade' + | 'lendingDeposit' + | 'lendingWithdrawal' + | 'predictionsAddFunds' + | 'predictionsWithdrawFunds' + | 'predictionClaimWinnings' + | 'predictionCashedOut' + | 'predictionPlaced' + | 'perpsAddFunds' + | 'perpsWithdrawFunds' + | 'perpsOpenLong' + | 'perpsCloseLong' + | 'perpsCloseLongLiquidated' + | 'perpsCloseLongStopLoss' + | 'perpsOpenShort' + | 'perpsCloseShort' + | 'perpsCloseShortLiquidated' + | 'perpsCloseShortStopLoss' + | 'perpsPaidFundingFees' + | 'perpsReceivedFundingFees' + | 'perpsCloseShortTakeProfit' + | 'perpsCloseLongTakeProfit' + | 'marketShort' + | 'stopMarketCloseShort' + | 'marketCloseShort' + | 'nftMint'; + +export interface TokenAmount { + amount?: string; + decimals?: number; + symbol?: string; + // CAIP-19 asset id (from adapters) + assetId?: string; + direction: 'in' | 'out'; +} + +interface ActivityData { + type: Type; + chainId: CaipChainId; + status: Status; + timestamp: number; + isEarliestNonce?: boolean; + /* Used by legacy details modals. Interim until redesigned details are implemented */ + raw?: + | { type: 'apiEvmTransaction'; data: V1TransactionByHashResponse } + | { type: 'keyringTransaction'; data: Transaction } + | { type: 'localTransaction'; data: TransactionGroup }; + data: Data & { + hash?: string; + }; +} + +export type ActivityListItem = + | ActivityData< + 'send' | 'receive', + { + from: string; + to: string; + token?: TokenAmount; + } + > + | ActivityData< + | 'swap' + | 'convert' + | 'lendingDeposit' + | 'lendingWithdrawal' + | 'wrap' + | 'unwrap', + { + sourceToken?: TokenAmount; + destinationToken?: TokenAmount; + } + > + | ActivityData< + 'swapIncomplete', + { + sourceToken?: TokenAmount; + } + > + | ActivityData< + 'bridge', + { + sourceToken?: TokenAmount; + destinationToken?: TokenAmount; + } + > + | ActivityData< + 'buy' | 'claim' | 'deposit', + { + token?: TokenAmount; + } + > + | ActivityData< + 'claimMusdBonus', + { + token?: TokenAmount; + } + > + | ActivityData< + 'approveSpendingCap' | 'revokeSpendingCap' | 'increaseSpendingCap', + { + token?: TokenAmount; + } + > + | ActivityData< + 'nftMint', + { + from: string; + to: string; + token?: TokenAmount; + } + > + | ActivityData< + 'contractInteraction', + { + from: string; + to: string; + token?: TokenAmount; + methodId?: string; + transactionCategory?: string; + transactionProtocol?: string; + transactionType?: string; + } + > + | ActivityData< + | 'sell' + | 'contractDeployment' + | 'smartAccountUpgrade' + | 'predictionsAddFunds' + | 'predictionsWithdrawFunds' + | 'predictionClaimWinnings' + | 'predictionCashedOut' + | 'predictionPlaced' + | 'perpsAddFunds' + | 'perpsWithdrawFunds' + | 'perpsOpenLong' + | 'perpsCloseLong' + | 'perpsCloseLongLiquidated' + | 'perpsCloseLongStopLoss' + | 'perpsOpenShort' + | 'perpsCloseShort' + | 'perpsCloseShortLiquidated' + | 'perpsCloseShortStopLoss' + | 'perpsPaidFundingFees' + | 'perpsReceivedFundingFees' + | 'perpsCloseShortTakeProfit' + | 'perpsCloseLongTakeProfit' + | 'marketShort' + | 'stopMarketCloseShort' + | 'marketCloseShort', + { + from?: string; + to?: string; + token?: TokenAmount; + sourceToken?: TokenAmount; + destinationToken?: TokenAmount; + } + >; diff --git a/app/util/remoteFeatureFlag/index.test.ts b/app/util/remoteFeatureFlag/index.test.ts index 7b67aeae1827..658ff3f52c97 100644 --- a/app/util/remoteFeatureFlag/index.test.ts +++ b/app/util/remoteFeatureFlag/index.test.ts @@ -3,6 +3,7 @@ import { hasMinimumRequiredVersion, validatedVersionGatedFeatureFlag, isVersionGatedFeatureFlag, + parseBlockedCountriesEnv, VersionGatedFeatureFlag, } from '.'; @@ -17,6 +18,52 @@ jest.mock( }), ); +describe('parseBlockedCountriesEnv', () => { + it('returns empty array for undefined input', () => { + expect(parseBlockedCountriesEnv(undefined)).toEqual([]); + }); + + it('returns empty array for empty string', () => { + expect(parseBlockedCountriesEnv('')).toEqual([]); + }); + + it('returns empty array for whitespace-only string', () => { + expect(parseBlockedCountriesEnv(' ')).toEqual([]); + }); + + it('parses single country code', () => { + expect(parseBlockedCountriesEnv('GB')).toEqual(['GB']); + }); + + it('parses comma-separated country codes', () => { + expect(parseBlockedCountriesEnv('GB,US,FR')).toEqual(['GB', 'US', 'FR']); + }); + + it('trims whitespace around country codes', () => { + expect(parseBlockedCountriesEnv('GB , US , FR')).toEqual([ + 'GB', + 'US', + 'FR', + ]); + }); + + it('converts country codes to uppercase', () => { + expect(parseBlockedCountriesEnv('gb,us,fr')).toEqual(['GB', 'US', 'FR']); + }); + + it('filters out empty entries', () => { + expect(parseBlockedCountriesEnv('GB,,US,')).toEqual(['GB', 'US']); + }); + + it('handles mixed case and whitespace', () => { + expect(parseBlockedCountriesEnv(' gb , Us, FR ')).toEqual([ + 'GB', + 'US', + 'FR', + ]); + }); +}); + describe('isVersionGatedFeatureFlag', () => { it('returns true for valid VersionGatedFeatureFlag', () => { const validFlag = { diff --git a/app/util/remoteFeatureFlag/index.ts b/app/util/remoteFeatureFlag/index.ts index 91857303fd13..1d072f2a00a6 100644 --- a/app/util/remoteFeatureFlag/index.ts +++ b/app/util/remoteFeatureFlag/index.ts @@ -65,6 +65,24 @@ const unwrapVersionGatedFeatureFlag = ( return undefined; }; +/** + * Parses a comma-separated string of country codes into an array. + * Used by feature flag selectors to read geo-block lists from env vars. + * Returns empty array if input is undefined/empty. + * + * @param envValue - Comma-separated country codes (e.g., "GB,US,FR") + * @returns Array of uppercased country codes with whitespace stripped + */ +export const parseBlockedCountriesEnv = (envValue?: string): string[] => { + if (!envValue || envValue.trim() === '') { + return []; + } + return envValue + .split(',') + .map((code) => code.trim().toUpperCase()) + .filter((code) => code.length > 0); +}; + export const validatedVersionGatedFeatureFlag = (remoteFlag: unknown) => { // If remote flag is overridden, return undefined to trigger caller fallback if (isRemoteFeatureFlagOverrideActivated) { diff --git a/app/util/test/testSetupView.js b/app/util/test/testSetupView.js index b84b4be64093..ab31f27bff9c 100644 --- a/app/util/test/testSetupView.js +++ b/app/util/test/testSetupView.js @@ -114,6 +114,35 @@ jest.mock('expo/fetch', () => ({ fetch, })); +// @metamask/perps-controller no longer exports MarketCategory / MARKET_CATEGORIES on +// this branch, but Perps UI (pulled in via TransactionElement → Balance) still reads +// them at module load. Stub the enum so component-view tests can import Activity views. +jest.mock('@metamask/perps-controller', () => { + const actual = jest.requireActual('@metamask/perps-controller'); + + return { + ...actual, + MarketCategory: { + CryptoCurrency: 'crypto', + Stock: 'stock', + PreIpo: 'pre-ipo', + Index: 'index', + Etf: 'etf', + Forex: 'forex', + Commodity: 'commodity', + }, + MARKET_CATEGORIES: [ + 'crypto', + 'stock', + 'pre-ipo', + 'forex', + 'commodity', + 'index', + 'etf', + ], + }; +}); + // Reanimated setup is usually required for navigation/animations try { require('react-native-reanimated').setUpTests(); diff --git a/docs/readme/e2e-testing.md b/docs/readme/e2e-testing.md index c14a13d3e88f..2d518a38d6d3 100644 --- a/docs/readme/e2e-testing.md +++ b/docs/readme/e2e-testing.md @@ -124,6 +124,7 @@ First, ensure the build watcher is running in a dedicated terminal for logs: ```bash export METAMASK_ENVIRONMENT='e2e' export METAMASK_BUILD_TYPE='main' +export HAS_TEST_OVERRIDES="true" yarn setup:expo yarn watch:clean # First time or after dependency changes yarn watch # Subsequent runs diff --git a/locales/languages/en.json b/locales/languages/en.json index e630ce4e7a7b..806075cbaa6c 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3017,7 +3017,64 @@ "disclaimer": "Disclaimer: MetaMask pulls the media file from the source URL. This URL is sometimes changed by the marketplace the NFT was minted on." }, "activity_view": { - "title": "Activity" + "title": "Activity", + "search_placeholder": "Search tokens, transaction types...", + "filter_all_networks": "All networks", + "filter_all_types": "All types", + "filter_types_selected": "Types: {{label}}", + "filter_network_selected": "Network: {{label}}", + "type_filter": { + "title": "Types", + "all": "All types", + "transactions": "Transactions", + "buy_sell": "Buy/Sell", + "perps": "Perps", + "predictions": "Predictions", + "metamask_card": "MetaMask Card", + "money": "Money" + }, + "empty_state": { + "default_funded": { + "description": "Nothing to see yet. Swap your first token today.", + "action": "Swap tokens" + }, + "default_unfunded": { + "description": "Nothing to see yet. Add funds to your wallet to get started.", + "action": "Add funds" + }, + "predictions": { + "description": "Your first prediction could be your best.", + "action": "Make a prediction" + }, + "perps": { + "description": "Your first perps trade could be your best.", + "action": "Browse markets" + }, + "transactions_funded": { + "description": "Swap your first token today.", + "action": "Swap tokens" + }, + "transactions_unfunded": { + "description": "Add funds to your wallet to get started.", + "action": "Add funds" + }, + "buy_sell": { + "description": "Add funds to your wallet to get started.", + "action": "Add funds" + }, + "money_funded": { + "description": "You've got funds—put them to work in MetaMask Money.", + "action": "Transfer funds" + }, + "money_unfunded": { + "description": "Transfer to MetaMask Money and unlock all it has to offer.", + "action": "Transfer funds" + }, + "metamask_card": { + "description": "Manage your MetaMask Card and spend crypto anywhere.", + "action": "Open MetaMask Card" + } + } }, "transactions_view": { "title": "Transactions" @@ -4656,6 +4713,31 @@ "tx_review_predict_withdraw": "Predictions withdraw", "tx_review_perps_withdraw": "Perps withdraw", "tx_review_musd_conversion": "mUSD conversion", + "activity_buy": "Bought", + "activity_sell": "Sold", + "activity_claim_musd_bonus": "Claimed mUSD bonus", + "activity_wrap": "Wrapped", + "activity_unwrap": "Unwrapped", + "activity_revoke_spending_cap": "Revoked spending cap", + "activity_nft_mint": "NFT mint", + "activity_smart_account_upgrade": "Smart account upgrade", + "activity_prediction_cashed_out": "Prediction cashed out", + "activity_prediction_placed": "Prediction placed", + "activity_perps_open_long": "Opened long", + "activity_perps_close_long": "Closed long", + "activity_perps_close_long_liquidated": "Closed long - liquidated", + "activity_perps_close_long_stop_loss": "Closed long - stop loss", + "activity_perps_open_short": "Opened short", + "activity_perps_close_short": "Closed short", + "activity_perps_close_short_liquidated": "Closed short - liquidated", + "activity_perps_close_short_stop_loss": "Closed short - stop loss", + "activity_perps_paid_funding_fees": "Paid funding fees", + "activity_perps_received_funding_fees": "Received funding fees", + "activity_perps_close_short_take_profit": "Closed short - take profit", + "activity_perps_close_long_take_profit": "Closed long - take profit", + "activity_market_short": "Market short", + "activity_stop_market_close_short": "Stop market - close short", + "activity_market_close_short": "Market - close short", "claim": "Claim", "sent_ether": "Sent ETH", "self_sent_ether": "Sent yourself ETH", @@ -7022,7 +7104,7 @@ "benefit_liquidity": "Get full liquidity with no lockups, so you can trade or withdraw anytime", "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", "benefit_spend_cashback": "1-3% mUSD back", - "benefit_transfer": "Transfer to any of your wallets across MetaMask", + "benefit_transfer": "Send to any of your wallets across MetaMask", "benefit_global": "Send and receive funds globally", "learn_more": "Learn more" }, @@ -7032,7 +7114,8 @@ "add_money_sheet": { "title": "Add funds", "convert_crypto": "Convert crypto", - "deposit_funds": "Debit card or bank account", + "deposit_funds": "Debit card or Apple Pay", + "bank_account": "Bank account", "move_musd": "{{amount}} mUSD", "add_musd": "Add mUSD", "receive_external": "External address", @@ -7077,8 +7160,9 @@ "apy_tooltip": { "title": "Annual Percentage Yield (APY)", "paragraph_1": "Your Money account earns up to {{percentage}}% automatically.", - "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and may change due to various factors. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols.", + "paragraph_2": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and is not guaranteed. Funds go into a DeFi vault, administered by Veda with risk oversight by Steakhouse Financial, that generates returns across established lending markets.", "paragraph_3": "When you link your MetaMask Card, purchases will be deducted from this balance.", + "paragraph_4": "Money account is powered by Monad.", "deposit_body": "The rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and is not guaranteed." }, "earnings_tooltip": { @@ -7152,7 +7236,7 @@ "description_3": "Money account is powered by Monad.", "faq_title": "FAQs", "faq_q1": "What is a MetaMask Money Account?", - "faq_a1": "MetaMask Money Account is a self-custodial account built into MetaMask that earns up to {{percentage}}% variable APY on deposited funds—with no lockups, no withdrawal penalties, and no account fees. When you deposit a supported stablecoin (USDC, USDT, or DAI) or aTokens into a Money Account, it converts to mUSD, MetaMask's dollar-backed stablecoin, with no conversion fees. You can also deposit any token into a Money Account, with a conversion fee. Your Money Account balance (mUSD) stays available to trade, send, and earn—or spend anytime via MetaMask Card, across 150+ million Mastercard merchants worldwide.", + "faq_a1": "MetaMask Money Account is a self-custodial account built into MetaMask that earns up to {{percentage}}% variable APY on deposited funds—with no lockups, no withdrawal penalties, and no account fees. When you deposit a supported stablecoin ({{stablecoins}}) or aTokens into a Money Account, it converts to mUSD, MetaMask's dollar-backed stablecoin, with no conversion fees. You can also deposit any token into a Money Account, with a conversion fee. Your Money Account balance (mUSD) stays available to trade, send, and earn—or spend anytime via MetaMask Card, across 150+ million Mastercard merchants worldwide.", "faq_q2": "Is MetaMask Money Account safe?", "faq_a2": "MetaMask is a self-custodial wallet—only you control your private keys and funds. MetaMask cannot access, freeze, or move your balance. mUSD itself is backed 1:1 by US dollars and short-term US Treasury bills held in regulated custody by Bridge (a Stripe company). However, like all DeFi-based products, Money Account carries risks, including smart-contract failures, protocol exploits, liquidity shortages, third-party failures and legal or regulatory restrictions. The APY is variable and not guaranteed, and there is a risk of loss. The Money balance (mUSD) is not insured by the FDIC or any government agency.", "faq_q3": "Who controls my MetaMask Money Account?", @@ -7162,9 +7246,9 @@ "faq_q5": "How much can I earn with a MetaMask Money Account?", "faq_a5": "MetaMask Money Account earns up to {{percentage}}% APY. The rate is variable and fluctuates with market conditions. The current rate is always visible inside the Money Account.", "faq_q6": "Where does the yield come from?", - "faq_a6": "When funds are deposited into a Money Account, these assets go into a third-party smart contract vault. The vault interacts with established DeFi protocols—for example, lending markets like Aave and Morpho—where they earn returns from real economic activity, primarily interest paid by borrowers. Any resulting returns, after applicable protocol costs and product fees, show up as APY. Earnings are not interest paid by MetaMask or by the issuer of mUSD. They are the onchain returns generated by the vault's positions in third-party DeFi protocols.", + "faq_a6": "When funds are deposited into a Money Account, these assets go into a third-party smart contract vault. The vault interacts with established DeFi protocols—for example, lending markets like Aave and Morpho—where they earn returns from real economic activity, primarily interest paid by borrowers. Any resulting returns, after applicable protocol costs and product fees, show up as APY. Earnings are not interest paid by MetaMask or by the issuer of mUSD. They are the onchain returns generated by the vault's positions in third-party DeFi protocols.\n\nThe rate shown is an estimated Annual Percentage Yield (APY) based on current market conditions. APY is variable and is not guaranteed. The DeFi vault is administered by Veda with risk oversight by Steakhouse Financial.", "faq_q7": "Which tokens can I deposit into the Money Account?", - "faq_a7": "You can deposit the following stablecoins and aTokens into your Money Account with no conversion fees:\n\n• Ethereum: USDC, USDT, DAI, aUSDC, aUSDT, aDAI\n• Arbitrum: USDC\n• Base: USDC\n• BNB Chain: USDC, USDT\n\nAll other EVM tokens can also be deposited into the Money Account, but standard exchange fees may apply. You can also fund your Money Account using a debit card, credit card, bank account, PayPal, Apple Pay, or Google Pay.\n\nNote: There is an initial max deposit of $100,000 per token to ensure liquidity.", + "faq_a7": "You can deposit the following stablecoins and aTokens into your Money Account with no conversion fees:\n\n{{tokenBullets}}\n\nAll other EVM tokens can also be deposited into the Money Account, but standard exchange fees may apply. You can also fund your Money Account using a debit card, credit card, or Apple Pay.\n\nNote: There is an initial max deposit of $100,000 per token to ensure liquidity.", "faq_q8": "Can I withdraw from the MetaMask Money Account anytime?", "faq_a8": "Yes. Your mUSD is never locked. You can trade, send, spend, or withdraw your full balance at any time with no notice periods, no penalties, and no withdrawal fees from MetaMask. Withdrawals process immediately, subject to standard network confirmation times on the relevant blockchain. If liquidity is tight, there may be temporary delays.", "faq_q9": "Do I need to complete KYC to use MetaMask Money Account?", @@ -9561,11 +9645,6 @@ "perps": "Perps", "rwa_perps_section": "Perps", "macro_stocks_commodity_perps": "Perps", - "macro_pill_stocks": "Stocks", - "macro_pill_commodities": "Commodities", - "rwa_pill_commodities": "Commodities", - "rwa_pill_stocks": "Stocks", - "rwa_pill_forex": "Forex", "crypto_perps_section": "Perps", "predictions": "Predictions", "no_results": "No results found", @@ -9742,15 +9821,6 @@ "money": "Money", "money_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Money section on the homepage.", "money_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", - "money_empty_state": { - "get_started": "Get started", - "earn": "Earn", - "earn_apy": "Earn {{percentage}}% APY" - }, - "money_filled_state": { - "add": "Add", - "apy": "{{percentage}}% APY" - }, "tokens": "Tokens", "perps": "Perps", "related_assets": "Related Assets", diff --git a/package.json b/package.json index 1b8f571e1db1..d8328b53bd74 100644 --- a/package.json +++ b/package.json @@ -242,7 +242,7 @@ "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^9.0.0", "@metamask/assets-controller": "^8.3.1", - "@metamask/assets-controllers": "^109.0.0", + "@metamask/assets-controllers": "^109.1.0", "@metamask/authenticated-user-storage": "^2.0.0", "@metamask/base-controller": "^9.0.1", "@metamask/bitcoin-wallet-snap": "^1.12.0", @@ -348,7 +348,7 @@ "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/transaction-controller": "^68.0.0", "@metamask/transaction-pay-controller": "^23.7.0", - "@metamask/tron-wallet-snap": "^1.25.6", + "@metamask/tron-wallet-snap": "^1.26.0", "@metamask/utils": "^11.11.0", "@metamask/wallet": "^3.0.0", "@myx-trade/sdk": "^0.1.265", diff --git a/tests/api-mocking/mock-responses/defaults/price-apis.ts b/tests/api-mocking/mock-responses/defaults/price-apis.ts index 94c118181726..eb4002aed7af 100644 --- a/tests/api-mocking/mock-responses/defaults/price-apis.ts +++ b/tests/api-mocking/mock-responses/defaults/price-apis.ts @@ -145,26 +145,88 @@ export const PRICE_API_MOCKS: MockEventsObject = { responseCode: 200, response: { 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f': { + id: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + price: 0.999588, usd: 0.999588, eth: 0.000233, + marketCap: 5000000000, + pricePercentChange1h: 0.01, + pricePercentChange1d: 0.1, + pricePercentChange7d: 0.5, + pricePercentChange14d: 0.8, + pricePercentChange30d: 1.0, + pricePercentChange200d: 2.0, + pricePercentChange1y: 5.0, }, 'eip155:1/slip44:60': { + id: 'eip155:1/slip44:60', + price: 4280.15, usd: 4280.15, eth: 1.0, + marketCap: 500000000000, + pricePercentChange1h: 0.5, + pricePercentChange1d: 2.5, + pricePercentChange7d: 5.0, + pricePercentChange14d: 8.0, + pricePercentChange30d: 10.0, + pricePercentChange200d: 20.0, + pricePercentChange1y: 50.0, }, 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + id: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + price: 1.0, usd: 1.0, eth: 0.000233, + marketCap: 35000000000, + pricePercentChange1h: 0.01, + pricePercentChange1d: 0.1, + pricePercentChange7d: 0.2, + pricePercentChange14d: 0.3, + pricePercentChange30d: 0.5, + pricePercentChange200d: 1.0, + pricePercentChange1y: 2.0, + }, + 'eip155:1/erc20:0x96f6ef951840721adbf46ac996b59e0235cb985c': { + id: 'eip155:1/erc20:0x96f6ef951840721adbf46ac996b59e0235cb985c', + price: 1.02, + usd: 1.02, + eth: 0.000238, + marketCap: 500000000, + pricePercentChange1h: 0.01, + pricePercentChange1d: 0.05, + pricePercentChange7d: 0.15, + pricePercentChange14d: 0.25, + pricePercentChange30d: 0.4, + pricePercentChange200d: 0.8, + pricePercentChange1y: 1.5, }, 'eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174': { + id: 'eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + price: 1.0, usd: 1.0, eth: 0.000344, - price: 1.0, + marketCap: 1000000000, + pricePercentChange1h: 0.01, + pricePercentChange1d: 0.1, + pricePercentChange7d: 0.2, + pricePercentChange14d: 0.3, + pricePercentChange30d: 0.5, + pricePercentChange200d: 1.0, + pricePercentChange1y: 2.0, }, 'eip155:137/slip44:966': { - usd: 1.0, + id: 'eip155:137/slip44:966', price: 1.0, + usd: 1.0, eth: 0.000344, + marketCap: 50000000, + pricePercentChange1h: 0.5, + pricePercentChange1d: 1.0, + pricePercentChange7d: 3.0, + pricePercentChange14d: 5.0, + pricePercentChange30d: 8.0, + pricePercentChange200d: 15.0, + pricePercentChange1y: 20.0, }, }, }, diff --git a/tests/component-view/mocks.ts b/tests/component-view/mocks.ts index 0cfc00a1b271..38e8ba4f931a 100644 --- a/tests/component-view/mocks.ts +++ b/tests/component-view/mocks.ts @@ -268,6 +268,7 @@ jest.mock('../../app/core/Engine', () => { addTransaction: jest.fn().mockResolvedValue({}), getTransactions: jest.fn().mockReturnValue([]), updateEditableParams: jest.fn(), + updateIncomingTransactions: jest.fn().mockResolvedValue(undefined), getNonceLock: jest .fn() .mockResolvedValue({ nextNonce: 0, releaseLock: jest.fn() }), diff --git a/tests/component-view/presets/activity.ts b/tests/component-view/presets/activity.ts new file mode 100644 index 000000000000..3da38adc92b0 --- /dev/null +++ b/tests/component-view/presets/activity.ts @@ -0,0 +1,143 @@ +import { + TransactionStatus, + TransactionType, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import { createStateFixture } from '../stateFixture'; +import type { DeepPartial } from '../../../app/util/test/renderWithProvider'; +import type { RootState } from '../../../app/reducers'; + +export const ACTIVITY_CV_ACCOUNT = '0x0000000000000000000000000000000000000001'; + +const ACTIVITY_CV_RECIPIENT = '0x80181d3ba89220cdb80234fc7aa19d5cc56229cc'; + +export const buildConfirmedLocalSendTransaction = (): TransactionMeta => + ({ + id: 'activity-cv-confirmed-send', + hash: '0xactivitycvconfirmedsend', + chainId: '0x1', + status: TransactionStatus.confirmed, + time: 1_716_367_781_000, + type: TransactionType.simpleSend, + txParams: { + from: ACTIVITY_CV_ACCOUNT, + to: ACTIVITY_CV_RECIPIENT, + value: '0xde0b6b3a7640000', + nonce: '0x0', + }, + txReceipt: { status: '0x1' }, + }) as unknown as TransactionMeta; + +export const buildPendingLocalSendTransaction = (): TransactionMeta => + ({ + id: 'activity-cv-pending-send', + hash: '0xactivitycvpendingsend', + chainId: '0x1', + status: TransactionStatus.submitted, + time: 1_716_367_782_000, + type: TransactionType.simpleSend, + txParams: { + from: ACTIVITY_CV_ACCOUNT, + to: ACTIVITY_CV_RECIPIENT, + value: '0xde0b6b3a7640000', + nonce: '0x1', + }, + }) as unknown as TransactionMeta; + +const enabledMainnetNetworkMap = { + eip155: { + '0x1': true, + }, + solana: {}, +} as const; + +/** + * Minimal Activity state. EVM networks are intentionally disabled by default so + * ActivityList renders through Redux without kicking off the external tx API. + */ +export const initialStateActivity = () => + createStateFixture() + .withMinimalAccounts(ACTIVITY_CV_ACCOUNT) + .withMinimalMainnetNetwork() + .withMinimalMultichainNetwork(true) + .withMinimalTransactionController() + .withMinimalKeyringController() + .withMinimalBridgeController() + .withMinimalTokenRates() + .withMinimalMultichainTransactions() + .withAccountTreeForSelectedAccount() + .withRemoteFeatureFlags({}) + .withOverrides({ + settings: { + showFiatOnTestnets: true, + }, + engine: { + backgroundState: { + AccountTrackerController: { + accounts: {}, + accountsByChainId: {}, + }, + CurrencyRateController: { + currentCurrency: 'USD', + currencyRates: { + ETH: { + conversionRate: 2500, + usdConversionRate: 2500, + }, + }, + }, + GasFeeController: { + gasFeeEstimates: {}, + }, + NetworkEnablementController: { + enabledNetworkMap: { + eip155: {}, + solana: {}, + }, + }, + PreferencesController: { + showTestNetworks: false, + tokenNetworkFilter: {}, + }, + SmartTransactionsController: { + smartTransactionsState: { + smartTransactions: {}, + }, + }, + TokenBalancesController: { + tokenBalances: {}, + }, + TokensController: { + allTokens: { + '0x1': { + [ACTIVITY_CV_ACCOUNT]: [], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, + }, + }, + } as unknown as DeepPartial); + +export const initialStateActivityWithLocalTransactions = ( + transactions: TransactionMeta[], +) => + initialStateActivity().withOverrides({ + engine: { + backgroundState: { + NetworkEnablementController: { + enabledNetworkMap: enabledMainnetNetworkMap, + }, + TransactionController: { + transactions, + swapsTransactions: {}, + }, + }, + }, + } as unknown as DeepPartial); + +export const initialStateActivityWithRedesignEnabled = () => + initialStateActivity().withRemoteFeatureFlags({ + tmcuActivityRedesignEnabled: true, + }); diff --git a/tests/component-view/renderers/activity.ts b/tests/component-view/renderers/activity.ts new file mode 100644 index 000000000000..e9022a944397 --- /dev/null +++ b/tests/component-view/renderers/activity.ts @@ -0,0 +1,168 @@ +import '../mocks'; +import React from 'react'; +import type { DeepPartial } from '../../../app/util/test/renderWithProvider'; +import type { RootState } from '../../../app/reducers'; +import Routes from '../../../app/constants/navigation/Routes'; +import ActivityScreen from '../../../app/components/Views/ActivityScreen/ActivityScreen'; +import ActivityList from '../../../app/components/Views/ActivityList'; +import ActivityView from '../../../app/components/Views/ActivityView'; +import { HardwareWalletProvider } from '../../../app/core/HardwareWallet/HardwareWalletProvider'; +import { renderComponentViewScreen, renderScreenWithRoutes } from '../render'; +import { + initialStateActivity, + initialStateActivityWithRedesignEnabled, +} from '../presets/activity'; + +interface RenderActivityScreenViewOptions { + overrides?: DeepPartial; + state?: DeepPartial; +} + +interface RenderActivityScreenViewWithRoutesOptions + extends RenderActivityScreenViewOptions { + extraRoutes: { name: string; Component?: React.ComponentType }[]; +} + +interface RenderActivityListViewOptions { + overrides?: DeepPartial; + state?: DeepPartial; +} + +interface RenderActivityListViewWithRoutesOptions + extends RenderActivityListViewOptions { + extraRoutes: { name: string; Component?: React.ComponentType }[]; +} + +interface RenderActivityViewOptions { + overrides?: DeepPartial; + redesignEnabled?: boolean; +} + +interface RenderActivityViewWithRoutesOptions + extends RenderActivityViewOptions { + extraRoutes: { name: string; Component?: React.ComponentType }[]; +} + +function ActivityViewWithProviders() { + return React.createElement( + HardwareWalletProvider, + null, + React.createElement(ActivityView as unknown as React.ComponentType), + ); +} + +function ActivityScreenWithProviders() { + return React.createElement( + HardwareWalletProvider, + null, + React.createElement(ActivityScreen), + ); +} + +function ActivityListWithProviders() { + return React.createElement( + HardwareWalletProvider, + null, + React.createElement(ActivityList), + ); +} + +function buildActivityState(options: { + overrides?: DeepPartial; + state?: DeepPartial; + redesignEnabled?: boolean; +}) { + if (options.state) { + return options.state; + } + + const builder = options.redesignEnabled + ? initialStateActivityWithRedesignEnabled() + : initialStateActivity(); + if (options.overrides) { + builder.withOverrides(options.overrides); + } + + return builder.build(); +} + +export function renderActivityScreenView( + options: RenderActivityScreenViewOptions = {}, +): ReturnType { + const state = buildActivityState(options); + + return renderComponentViewScreen( + ActivityScreenWithProviders, + { name: Routes.TRANSACTIONS_VIEW }, + { state }, + ); +} + +export function renderActivityScreenViewWithRoutes( + options: RenderActivityScreenViewWithRoutesOptions, +): ReturnType { + const state = buildActivityState(options); + + return renderScreenWithRoutes( + ActivityScreenWithProviders, + { name: Routes.TRANSACTIONS_VIEW }, + options.extraRoutes, + { state }, + ); +} + +export function renderActivityListView( + options: RenderActivityListViewOptions = {}, +): ReturnType { + const state = buildActivityState(options); + + return renderComponentViewScreen( + ActivityListWithProviders, + { name: Routes.TRANSACTIONS_VIEW }, + { state }, + ); +} + +export function renderActivityListViewWithRoutes( + options: RenderActivityListViewWithRoutesOptions, +): ReturnType { + const state = buildActivityState(options); + + return renderScreenWithRoutes( + ActivityListWithProviders, + { name: Routes.TRANSACTIONS_VIEW }, + options.extraRoutes, + { state }, + ); +} + +export function renderActivityView( + options: RenderActivityViewOptions = {}, +): ReturnType { + const state = buildActivityState({ + overrides: options.overrides, + redesignEnabled: options.redesignEnabled, + }); + + return renderComponentViewScreen( + ActivityViewWithProviders, + { name: Routes.TRANSACTIONS_VIEW }, + { state }, + ); +} + +export function renderActivityViewWithRoutes( + options: RenderActivityViewWithRoutesOptions, +): ReturnType { + const state = buildActivityState({ + overrides: options.overrides, + redesignEnabled: options.redesignEnabled, + }); + + return renderScreenWithRoutes( + ActivityViewWithProviders, + { name: Routes.TRANSACTIONS_VIEW }, + options.extraRoutes, + { state }, + ); +} diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index 05d5aa50cdb7..97ad7897b041 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -3480,6 +3480,16 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + moneyAccountGeoBlockedCountries: { + name: 'moneyAccountGeoBlockedCountries', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + blockedRegions: ['GB'], + }, + status: FeatureFlagStatus.Active, + }, + moneyAccountVaultConfig: { name: 'moneyAccountVaultConfig', type: FeatureFlagType.Remote, @@ -4347,6 +4357,14 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + tmcuActivityRedesignEnabled: { + name: 'tmcuActivityRedesignEnabled', + type: FeatureFlagType.Remote, + inProd: false, + productionDefault: false, + status: FeatureFlagStatus.Active, + }, + trendingTokens: { name: 'trendingTokens', type: FeatureFlagType.Remote, diff --git a/tests/framework/EncapsulatedElement.test.ts b/tests/framework/EncapsulatedElement.test.ts index 7c943090886e..898d0f06345d 100644 --- a/tests/framework/EncapsulatedElement.test.ts +++ b/tests/framework/EncapsulatedElement.test.ts @@ -781,6 +781,31 @@ describe('EncapsulatedElement', () => { }); }); + describe('{ detoxTestID, androidAppiumTestID, iosAppiumXPath }', () => { + it('calls EncapsulatedElement.create with ios xpath config', () => { + FrameworkDetector.setFramework(TestFramework.DETOX); + const spy = createSpyOnEncapsulatedCreate(); + spy.mockReturnValue(createMockDetoxElement()); + + resolve({ + detoxTestID: 'seed-phrase-input', + androidAppiumTestID: 'seed-phrase-input', + iosAppiumXPath: '//XCUIElementTypeOther[@name="textfield"]', + }); + + expect(spy).toHaveBeenCalledTimes(1); + const config = spy.mock.calls[0][0] as LocatorConfig; + expect(typeof config.detox).toBe('function'); + expect( + typeof (config.appium as { android: unknown; ios: unknown }).android, + ).toBe('function'); + expect( + typeof (config.appium as { android: unknown; ios: unknown }).ios, + ).toBe('function'); + spy.mockRestore(); + }); + }); + describe('{ testID, iosAppiumTestID }', () => { it('calls EncapsulatedElement.create with ios-override config', () => { FrameworkDetector.setFramework(TestFramework.DETOX); diff --git a/tests/framework/Selector.ts b/tests/framework/Selector.ts index 70da42481567..e2bed0333951 100644 --- a/tests/framework/Selector.ts +++ b/tests/framework/Selector.ts @@ -14,6 +14,11 @@ export type Selector = androidAppiumTestID: string; iosAppiumTestID: string; } + | { + detoxTestID: string; + androidAppiumTestID: string; + iosAppiumXPath: string; + } | { testID: string; iosAppiumTestID: string; index?: number }; /** @@ -21,6 +26,21 @@ export type Selector = * This can also be used in the original Matchers, Assertions, and Gestures methods that currently return DetoxElements to make them cross-framework compatible without page-object changes. */ export function resolve(selector: Selector): EncapsulatedElementType { + if ('iosAppiumXPath' in selector) { + return encapsulated({ + detox: () => + element(by.id(selector.detoxTestID)) as unknown as DetoxElement, + appium: { + android: () => + PlaywrightMatchers.getElementById(selector.androidAppiumTestID, { + exact: true, + }), + ios: () => + PlaywrightMatchers.getElementByXPath(selector.iosAppiumXPath), + }, + }); + } + if ('androidAppiumTestID' in selector) { return encapsulated({ detox: () => @@ -149,6 +169,7 @@ export function isSelector(value: unknown): value is Selector { 'text' in v || 'detoxTestID' in v || 'androidAppiumTestID' in v || - 'iosAppiumTestID' in v + 'iosAppiumTestID' in v || + 'iosAppiumXPath' in v ); } diff --git a/tests/page-objects/importSrp/ImportSrpView.ts b/tests/page-objects/importSrp/ImportSrpView.ts index 1695e45f80bf..2c009babfd7f 100644 --- a/tests/page-objects/importSrp/ImportSrpView.ts +++ b/tests/page-objects/importSrp/ImportSrpView.ts @@ -1,6 +1,14 @@ import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; -import { EncapsulatedElementType } from '../../framework/EncapsulatedElement'; +import { + asDetoxElement, + EncapsulatedElementType, +} from '../../framework/EncapsulatedElement'; +import { PlatformDetector } from '../../framework/PlatformLocator'; +import { encapsulatedAction } from '../../framework/encapsulatedAction'; +import { resolve } from '../../framework/Selector'; +import UnifiedGestures from '../../framework/UnifiedGestures'; +import PlaywrightGestures from '../../framework/PlaywrightGestures'; import { ImportSRPIDs } from '../../../app/components/Views/ImportNewSecretRecoveryPhrase/SRPImport.testIds'; class ImportSrpView { @@ -20,13 +28,25 @@ class ImportSrpView { return Matchers.getElementByID(ImportSRPIDs.SEED_PHRASE_INPUT_ID); } - seedPhraseInput(index: number): EncapsulatedElementType { - if (index !== 0) { - return Matchers.getElementByID( - `${ImportSRPIDs.SEED_PHRASE_INPUT_ID}_${index}`, - ); + private getAppiumIosSeedPhraseXPath(index: number): string { + if (index === 0) { + return '//XCUIElementTypeOther[@name="textfield"]'; } - return Matchers.getElementByID(ImportSRPIDs.SEED_PHRASE_INPUT_ID); + + return `//XCUIElementTypeOther[@name="textfield" and @label="${index + 1}."]`; + } + + seedPhraseInput(index: number): EncapsulatedElementType { + const testID = + index === 0 + ? ImportSRPIDs.SEED_PHRASE_INPUT_ID + : `${ImportSRPIDs.SEED_PHRASE_INPUT_ID}_${index}`; + + return resolve({ + detoxTestID: testID, + androidAppiumTestID: testID, + iosAppiumXPath: this.getAppiumIosSeedPhraseXPath(index), + }); } async tapTitle() { @@ -42,21 +62,57 @@ class ImportSrpView { } async enterSrp(mnemonic: string): Promise { - if (device.getPlatform() === 'ios') { - const srpArray = mnemonic.split(' '); - for (const [i, word] of srpArray.entries()) { - await Gestures.typeText(this.seedPhraseInput(i), `${word} `, { - elemDescription: 'Import SRP Secret Recovery Phrase Input Box', - hideKeyboard: i === srpArray.length - 1, - }); - } - await this.tapTitle(); - } else { - await Gestures.replaceText(this.textareaInput, mnemonic, { - elemDescription: 'SRP textarea input', - checkVisibility: false, - }); - } + const srpArray = mnemonic.split(' '); + + await encapsulatedAction({ + detox: async () => { + if (PlatformDetector.isIOS()) { + for (const [i, word] of srpArray.entries()) { + await Gestures.typeText( + asDetoxElement(this.seedPhraseInput(i)), + `${word} `, + { + elemDescription: 'Import SRP Secret Recovery Phrase Input Box', + hideKeyboard: i === srpArray.length - 1, + }, + ); + } + await this.tapTitle(); + return; + } + + await Gestures.replaceText( + asDetoxElement(this.textareaInput), + mnemonic, + { + elemDescription: 'SRP textarea input', + checkVisibility: false, + }, + ); + }, + appium: async () => { + if (PlatformDetector.isAndroid()) { + await UnifiedGestures.replaceText(this.seedPhraseInput(0), mnemonic, { + description: 'Import SRP Secret Recovery Phrase Input Box', + }); + return; + } + + for (const [i, word] of srpArray.entries()) { + const suffix = i === srpArray.length - 1 ? '' : ' '; + await UnifiedGestures.typeText( + this.seedPhraseInput(i), + `${word}${suffix}`, + { + description: 'Import SRP Secret Recovery Phrase Input Box', + waitForInteractive: true, + postEnabledSettleMs: 500, + }, + ); + } + await PlaywrightGestures.hideKeyboard(); + }, + }); } } diff --git a/tests/reporters/PerformanceReporter.ts b/tests/reporters/PerformanceReporter.ts index 429b8e27b45e..723f868b74b6 100644 --- a/tests/reporters/PerformanceReporter.ts +++ b/tests/reporters/PerformanceReporter.ts @@ -131,6 +131,7 @@ class PerformanceReporter { if (this.sessions.length > 0 && isBrowserStackRun) { await this.enrichSessionsWithProviderData(); + await this.publishAppSizeToSentry(); } // Clean up leftover environment variables @@ -484,6 +485,65 @@ class PerformanceReporter { } } + /** + * After BrowserStack enrichment, publish the installed app size for each + * session that has profiling data to Sentry, following the same pattern + * as the per-test timing measurements sent from the fixture. + */ + private async publishAppSizeToSentry(): Promise { + for (const session of this.sessions) { + const appSizeMb = session.profilingSummary?.appSizeMb; + if (appSizeMb === undefined) { + continue; + } + + const metric = this.metrics.find((m) => m.testName === session.testTitle); + + const deviceInfo: DeviceInfo = session.deviceInfo ?? + metric?.device ?? { + name: 'Unknown', + osVersion: 'Unknown', + provider: 'browserstack', + }; + + const teamInfo = session.team ?? metric?.team ?? null; + + try { + const sentToSentry = await publishPerformanceScenarioToSentry({ + metrics: { + steps: [], + timestamp: session.timestamp ?? new Date().toISOString(), + thresholdMarginPercent: 0, + team: teamInfo, + total: 0, + totalThreshold: null, + hasThresholds: false, + totalValidation: null, + device: deviceInfo, + appSizeMb, + }, + testTitle: session.testTitle, + projectName: session.projectName ?? 'unknown', + testFilePath: session.testFilePath, + videoRecordingUrl: session.videoURL ?? null, + tags: session.tags ?? metric?.tags ?? [], + status: session.testStatus, + }); + + if (sentToSentry) { + logger.info( + `App size (${appSizeMb} MB) for "${session.testTitle}" sent to Sentry`, + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn( + `Failed to publish app size for "${session.testTitle}" to Sentry: ${message}`, + ); + } + } + } + private async generateReports(summary: ReportSummary): Promise { const testName = (this.metrics[0]?.testName ?? 'Unknown') .replace(/[^a-zA-Z0-9]/g, '_') diff --git a/tests/reporters/PerformanceTracker.ts b/tests/reporters/PerformanceTracker.ts index e0c013784ff4..b669ed8d35a5 100644 --- a/tests/reporters/PerformanceTracker.ts +++ b/tests/reporters/PerformanceTracker.ts @@ -27,6 +27,7 @@ export interface MetricsOutput { } | null; device: DeviceInfo; sessionCreationDurationMs?: number; + appSizeMb?: number; } /** diff --git a/tests/reporters/providers/browserstack/BrowserStackEnricher.ts b/tests/reporters/providers/browserstack/BrowserStackEnricher.ts index d4ac91a61993..4a8d0d056cc1 100644 --- a/tests/reporters/providers/browserstack/BrowserStackEnricher.ts +++ b/tests/reporters/providers/browserstack/BrowserStackEnricher.ts @@ -182,6 +182,9 @@ export class BrowserStackEnricher extends BaseSessionDataEnricher { criticalIssues: appData.detected_issues?.filter((issue) => issue.type === 'error') .length ?? 0, + ...(metrics.app_size !== undefined && { + appSizeMb: metrics.app_size, + }), cpu: { avg: metrics.cpu?.avg ?? 0, max: metrics.cpu?.max ?? 0, diff --git a/tests/reporters/providers/sentry/PerformanceSentryPublisher.test.ts b/tests/reporters/providers/sentry/PerformanceSentryPublisher.test.ts index 9c8b17f970c3..24405efff994 100644 --- a/tests/reporters/providers/sentry/PerformanceSentryPublisher.test.ts +++ b/tests/reporters/providers/sentry/PerformanceSentryPublisher.test.ts @@ -370,6 +370,93 @@ describe('PerformanceSentryPublisher', () => { expect(longTimerKeys.every((key: string) => key.length <= 64)).toBe(true); }); + it('sends app_size_mb measurement with megabyte unit when appSizeMb is set', async () => { + process.env.E2E_PERFORMANCE_SENTRY_DSN = + 'https://publicKey@o123.ingest.sentry.io/4567'; + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + + const sent = await publishPerformanceScenarioToSentry({ + metrics: createMetrics({ appSizeMb: 291.62 }), + testTitle: 'Import wallet flow', + projectName: 'browserstack-android', + testFilePath: 'tests/performance/onboarding/import-wallet.spec.js', + tags: ['@PerformanceOnboarding'], + status: 'passed', + retry: 0, + workerIndex: 0, + }); + + expect(sent).toBe(true); + const [, requestInit] = fetchMock.mock.calls[0] ?? []; + expect(requestInit).toBeDefined(); + if (!requestInit) { + throw new Error('Expected request init payload for Sentry request'); + } + + const body = requestInit.body as string; + const [, , payloadLine] = body.split('\n'); + const payload = JSON.parse(payloadLine); + + expect(payload.measurements.app_size_mb).toEqual({ + value: 291.62, + unit: 'megabyte', + }); + }); + + it('sends when only appSizeMb is set and steps is empty', async () => { + process.env.E2E_PERFORMANCE_SENTRY_DSN = + 'https://publicKey@o123.ingest.sentry.io/4567'; + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + + const sent = await publishPerformanceScenarioToSentry({ + metrics: createMetrics({ steps: [], appSizeMb: 289.75 }), + testTitle: 'Send ETH flow', + projectName: 'browserstack-android', + testFilePath: 'tests/performance/login/send-eth.spec.ts', + tags: ['@PerformanceLogin'], + status: 'passed', + retry: 0, + workerIndex: 0, + }); + + expect(sent).toBe(true); + const [, requestInit] = fetchMock.mock.calls[0] ?? []; + const body = requestInit?.body as string; + const [, , payloadLine] = body.split('\n'); + const payload = JSON.parse(payloadLine); + + expect(payload.measurements.app_size_mb).toEqual({ + value: 289.75, + unit: 'megabyte', + }); + expect(payload.spans).toHaveLength(0); + }); + + it('does not send when steps is empty and appSizeMb is not set', async () => { + process.env.E2E_PERFORMANCE_SENTRY_DSN = + 'https://publicKey@o123.ingest.sentry.io/4567'; + + const sent = await publishPerformanceScenarioToSentry({ + metrics: createMetrics({ steps: [] }), + testTitle: 'Import wallet flow', + projectName: 'browserstack-android', + testFilePath: 'tests/performance/onboarding/import-wallet.spec.js', + tags: ['@PerformanceOnboarding'], + status: 'passed', + retry: 0, + workerIndex: 0, + }); + + expect(sent).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('includes profiling measurements when profilingSummary is provided', async () => { process.env.E2E_PERFORMANCE_SENTRY_DSN = 'https://publicKey@o123.ingest.sentry.io/4567'; diff --git a/tests/reporters/providers/sentry/PerformanceSentryPublisher.ts b/tests/reporters/providers/sentry/PerformanceSentryPublisher.ts index 65c4cf875e6a..ce52c35e5110 100644 --- a/tests/reporters/providers/sentry/PerformanceSentryPublisher.ts +++ b/tests/reporters/providers/sentry/PerformanceSentryPublisher.ts @@ -227,7 +227,10 @@ function getGithubJobUrl(): string | null { export async function publishPerformanceScenarioToSentry( options: PublishPerformanceScenarioOptions, ): Promise { - if (options.metrics.steps.length === 0) { + if ( + options.metrics.steps.length === 0 && + options.metrics.appSizeMb === undefined + ) { return false; } @@ -308,6 +311,13 @@ export async function publishPerformanceScenarioToSentry( }; } + if (options.metrics.appSizeMb !== undefined) { + measurements.app_size_mb = { + value: options.metrics.appSizeMb, + unit: 'megabyte', + }; + } + if (options.profilingSummary?.uiRendering) { const { slowFrames, frozenFrames, anrs } = options.profilingSummary.uiRendering; diff --git a/tests/reporters/types.ts b/tests/reporters/types.ts index 68a89c378fe6..ece88bb58f14 100644 --- a/tests/reporters/types.ts +++ b/tests/reporters/types.ts @@ -90,6 +90,7 @@ export interface ProfilingData { status?: string; detected_issues?: ProfilingIssue[]; metrics: { + app_size?: number; cpu?: { avg?: number; max?: number }; mem?: { avg?: number; max?: number }; batt?: { total_batt_usage?: number; total_batt_usage_pct?: number }; @@ -118,6 +119,7 @@ export interface ProfilingSummary { status?: string; issues?: number; criticalIssues?: number; + appSizeMb?: number; cpu?: { avg: number; max: number; unit?: string }; memory?: { avg: number; max: number; unit?: string }; battery?: { total: number; percentage: number; unit?: string }; diff --git a/tests/smoke-appium/accounts/delete-account.spec.ts b/tests/smoke-appium/accounts/delete-account.spec.ts new file mode 100644 index 000000000000..8e4800e45fc9 --- /dev/null +++ b/tests/smoke-appium/accounts/delete-account.spec.ts @@ -0,0 +1,93 @@ +import { test as appiumTest } from '../../framework/fixtures/playwright/index.js'; +import { SmokeAccounts } from '../../tags.js'; +import { + SIMPLE_KEYPAIR_ACCOUNT, + goToAccountDetailsV2, +} from '../../helpers/multichain-accounts/common.js'; +import AccountDetails from '../../page-objects/MultichainAccounts/AccountDetails.js'; +import DeleteAccount from '../../page-objects/MultichainAccounts/DeleteAccount.js'; +import Assertions from '../../framework/Assertions.js'; +import Matchers from '../../framework/Matchers.js'; +import WalletView from '../../page-objects/wallet/WalletView.js'; +import AccountListBottomSheet from '../../page-objects/wallet/AccountListBottomSheet.js'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder.js'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper.js'; +import { loginToAppPlaywright } from '../../flows/wallet.flow.js'; +import { Mockttp } from 'mockttp'; +import { setupMockRequest } from '../../api-mocking/helpers/mockHelpers.js'; +import { Utilities } from '../../framework/index.js'; + +const deleteAccount = async () => { + await AccountDetails.tapDeleteAccountLink(); + await Assertions.expectElementToBeVisible(DeleteAccount.container); + await DeleteAccount.tapDeleteAccount(); +}; + +const assertAccountCount = async ( + accountName: string, + expectedCount: number, +): Promise => { + await Utilities.executeWithRetry( + async () => { + const accountElements = + await AccountListBottomSheet.getAccountElementsByAccountNameV2( + accountName, + ); + return accountElements.length === expectedCount; + }, + { + description: `Count accounts with name "${accountName}" in the account list`, + timeout: 5000, + interval: 500, + }, + ); +}; + +appiumTest.describe( + SmokeAccounts('Multichain Accounts: Account Details'), + () => { + appiumTest( + 'deletes the account', + async ({ driver: _driver, currentDeviceDetails }) => { + await withFixtures( + { + fixture: new FixtureBuilder() + .withImportedHdKeyringAndTwoDefaultAccountsSimpleKeyPairAccount() + .build(), + restartDevice: true, + currentDeviceDetails, + testSpecificMock: async (mockServer: Mockttp) => { + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: /^https:\/\/api\.merkl\.xyz\/v4\/users\/[a-zA-Z0-9]+\/rewards(\?|$)/, + response: [], + responseCode: 200, + }); + }, + }, + async () => { + await loginToAppPlaywright({ scenarioType: 'e2e' }); + await WalletView.tapIdenticon(); + + await Assertions.expectElementToBeVisible( + AccountListBottomSheet.accountList, + ); + + await assertAccountCount('Imported Account 1', 1); + + await goToAccountDetailsV2({ + ...SIMPLE_KEYPAIR_ACCOUNT, + index: 2, + }); + await deleteAccount(); + // Go back to account list + await WalletView.tapIdenticon(); + + // Assert that the account is no longer present in the account list + await assertAccountCount('Imported Account 1', 0); + }, + ); + }, + ); + }, +); diff --git a/tests/smoke-appium/accounts/import-srp.spec.ts b/tests/smoke-appium/accounts/import-srp.spec.ts new file mode 100644 index 000000000000..f2b3aff7ac14 --- /dev/null +++ b/tests/smoke-appium/accounts/import-srp.spec.ts @@ -0,0 +1,44 @@ +import { test as appiumTest } from '../../framework/fixtures/playwright/index.js'; +import { SmokeAccounts } from '../../tags.js'; +import { loginToAppPlaywright } from '../../flows/wallet.flow.js'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder.js'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper.js'; +import WalletView from '../../page-objects/wallet/WalletView.js'; +import Assertions from '../../framework/Assertions.js'; +import ImportSrpView from '../../page-objects/importSrp/ImportSrpView.js'; +import { goToImportSrp, inputSrp } from '../../flows/accounts.flow.js'; +import { IDENTITY_TEAM_SEED_PHRASE } from '../../smoke/identity/utils/constants.js'; + +// We now have account indexes "per wallets", thus the new account for that new SRP (wallet), will +// be: "Account 1". +const IMPORTED_ACCOUNT_NAME = 'Account 1'; + +appiumTest.describe(SmokeAccounts('Multichain import SRP account'), () => { + appiumTest( + 'should import account with SRP', + async ({ driver: _driver, currentDeviceDetails }) => { + await withFixtures( + { + fixture: new FixtureBuilder() + .withImportedHdKeyringAndTwoDefaultAccountsOneImportedHdAccountKeyringController() + .build(), + restartDevice: true, + currentDeviceDetails, + }, + async () => { + await loginToAppPlaywright({ scenarioType: 'e2e' }); + await goToImportSrp(); + await inputSrp(IDENTITY_TEAM_SEED_PHRASE); + await ImportSrpView.tapImportButton(); + await Assertions.expectElementToHaveText( + WalletView.accountName, + IMPORTED_ACCOUNT_NAME, + { + description: `Expect selected account to be ${IMPORTED_ACCOUNT_NAME}`, + }, + ); + }, + ); + }, + ); +}); diff --git a/tests/smoke/multichain-accounts/delete-account.spec.ts b/tests/smoke/multichain-accounts/delete-account.spec.ts deleted file mode 100644 index 7296750b710e..000000000000 --- a/tests/smoke/multichain-accounts/delete-account.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { SmokeWalletPlatform } from '../../tags'; -import { - SIMPLE_KEYPAIR_ACCOUNT, - goToAccountDetailsV2, -} from '../../helpers/multichain-accounts/common'; -import AccountDetails from '../../page-objects/MultichainAccounts/AccountDetails'; -import DeleteAccount from '../../page-objects/MultichainAccounts/DeleteAccount'; -import Assertions from '../../framework/Assertions'; -import Matchers from '../../framework/Matchers'; -import WalletView from '../../page-objects/wallet/WalletView'; -import TestHelpers from '../../helpers'; -import AccountListBottomSheet from '../../page-objects/wallet/AccountListBottomSheet'; -import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../framework/fixtures/FixtureHelper'; -import { loginToApp } from '../../flows/wallet.flow'; -import { Mockttp } from 'mockttp'; -import { setupMockRequest } from '../../api-mocking/helpers/mockHelpers'; - -const deleteAccount = async () => { - await AccountDetails.tapDeleteAccountLink(); - await Assertions.expectElementToBeVisible(DeleteAccount.container); - await DeleteAccount.tapDeleteAccount(); -}; - -describe(SmokeWalletPlatform('Multichain Accounts: Account Details'), () => { - beforeEach(async () => { - await TestHelpers.reverseServerPort(); - }); - - it('deletes the account', async () => { - await withFixtures( - { - fixture: new FixtureBuilder() - .withImportedHdKeyringAndTwoDefaultAccountsSimpleKeyPairAccount() - .build(), - restartDevice: true, - testSpecificMock: async (mockServer: Mockttp) => { - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /^https:\/\/api\.merkl\.xyz\/v4\/users\/[a-zA-Z0-9]+\/rewards(\?|$)/, - response: [], - responseCode: 200, - }); - }, - }, - async () => { - await loginToApp(); - await WalletView.tapIdenticon(); - - await Assertions.expectElementToBeVisible( - AccountListBottomSheet.accountList, - ); - - await goToAccountDetailsV2({ - ...SIMPLE_KEYPAIR_ACCOUNT, - index: 2, - }); - await deleteAccount(); - // Go back to account list - await WalletView.tapIdenticon(); - - const importedAccountsSection = - Matchers.getElementByText('Imported Accounts'); - await Assertions.expectElementToNotBeVisible(importedAccountsSection); - }, - ); - }); -}); diff --git a/tests/smoke/snaps/test-snap-management.spec.ts b/tests/smoke/snaps/test-snap-management.spec.ts index 78ec735392ea..dc9c3a344fa8 100644 --- a/tests/smoke/snaps/test-snap-management.spec.ts +++ b/tests/smoke/snaps/test-snap-management.spec.ts @@ -10,6 +10,28 @@ import SnapSettingsView from '../../page-objects/Settings/SnapSettingsView'; import { Assertions } from '../../framework'; import BrowserView from '../../page-objects/Browser/BrowserView'; import AccountMenu from '../../page-objects/AccountMenu/AccountMenu'; +import WalletView from '../../page-objects/wallet/WalletView'; + +/** + * Navigate from the browser to Snap Settings. + * With disableSynchronization the tab bar may not be immediately available + * after closing the browser, so we navigate step-by-step with explicit waits. + */ +async function navigateFromBrowserToSnapSettings() { + await BrowserView.tapCloseBrowserButton(); + await TabBarComponent.tapWallet(); + await WalletView.tapHamburgerMenu(); + await Assertions.expectElementToBeVisible(AccountMenu.container, { + timeout: 10000, + description: 'Account menu should be visible', + }); + await AccountMenu.tapSettings(); + await Assertions.expectElementToBeVisible(SettingsView.title, { + timeout: 10000, + description: 'Settings view title should be visible', + }); + await SettingsView.tapSnaps(); +} jest.setTimeout(150_000); @@ -40,9 +62,7 @@ describe(SmokeSnaps('Snap Management Tests'), () => { disableSynchronization: true, }, async () => { - await BrowserView.tapCloseBrowserButton(); - await TabBarComponent.tapSettings(); - await SettingsView.tapSnaps(); + await navigateFromBrowserToSnapSettings(); await SnapSettingsView.selectSnap('Dialog Example Snap'); await SnapSettingsView.toggleEnable(); @@ -73,9 +93,7 @@ describe(SmokeSnaps('Snap Management Tests'), () => { disableSynchronization: true, }, async () => { - await BrowserView.tapCloseBrowserButton(); - await TabBarComponent.tapSettings(); - await SettingsView.tapSnaps(); + await navigateFromBrowserToSnapSettings(); await SnapSettingsView.selectSnap('Dialog Example Snap'); await SnapSettingsView.toggleEnable(); @@ -107,9 +125,7 @@ describe(SmokeSnaps('Snap Management Tests'), () => { disableSynchronization: true, }, async () => { - await BrowserView.tapCloseBrowserButton(); - await TabBarComponent.tapSettings(); - await SettingsView.tapSnaps(); + await navigateFromBrowserToSnapSettings(); await SnapSettingsView.selectSnap('Dialog Example Snap'); await SnapSettingsView.removeSnap(); diff --git a/tests/smoke/wallet/import-srp.spec.ts b/tests/smoke/wallet/import-srp.spec.ts deleted file mode 100644 index 9e765d4cc75c..000000000000 --- a/tests/smoke/wallet/import-srp.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { SmokeWalletPlatform } from '../../tags'; -import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../framework/fixtures/FixtureHelper'; -import WalletView from '../../page-objects/wallet/WalletView'; -import { loginToApp } from '../../flows/wallet.flow'; -import Assertions from '../../framework/Assertions'; -import ImportSrpView from '../../page-objects/importSrp/ImportSrpView'; -import { goToImportSrp, inputSrp } from '../../flows/accounts.flow'; -import { IDENTITY_TEAM_SEED_PHRASE } from '../identity/utils/constants'; - -// We now have account indexes "per wallets", thus the new account for that new SRP (wallet), will -// be: "Account 1". -const IMPORTED_ACCOUNT_NAME = 'Account 1'; - -describe(SmokeWalletPlatform('Multichain import SRP account'), () => { - it('should import account with SRP', async () => { - await withFixtures( - { - fixture: new FixtureBuilder() - .withImportedHdKeyringAndTwoDefaultAccountsOneImportedHdAccountKeyringController() - .build(), - restartDevice: true, - }, - async () => { - await loginToApp(); - await goToImportSrp(); - await inputSrp(IDENTITY_TEAM_SEED_PHRASE); - await ImportSrpView.tapImportButton(); - await Assertions.expectElementToHaveText( - WalletView.accountName, - IMPORTED_ACCOUNT_NAME, - { - description: `Expect selected account to be ${IMPORTED_ACCOUNT_NAME}`, - }, - ); - }, - ); - }); -}); diff --git a/yarn.lock b/yarn.lock index 4cd367aedf2c..6bf2b8647687 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10423,10 +10423,10 @@ __metadata: languageName: node linkType: hard -"@metamask/tron-wallet-snap@npm:^1.25.6": - version: 1.25.8 - resolution: "@metamask/tron-wallet-snap@npm:1.25.8" - checksum: 10/c533b566360b1587865b2e8f74125a301c348e6baa5a721e5629080b03fc47067b0275bec26183863ab5575a812bac8946c4fa1ac5c3daa645faec16b7ab3368 +"@metamask/tron-wallet-snap@npm:^1.26.0": + version: 1.26.0 + resolution: "@metamask/tron-wallet-snap@npm:1.26.0" + checksum: 10/ba7dc5d4bdae9f0a5c346744ea5eda311a1301a390c8ba3eedee4c99bf29d7d4937c65607198dcedda760223f07363638026e2649912123c4f89460de10906b4 languageName: node linkType: hard @@ -35312,7 +35312,7 @@ __metadata: "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^9.0.0" "@metamask/assets-controller": "npm:^8.3.1" - "@metamask/assets-controllers": "npm:^109.0.0" + "@metamask/assets-controllers": "npm:^109.1.0" "@metamask/authenticated-user-storage": "npm:^2.0.0" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.1" @@ -35432,7 +35432,7 @@ __metadata: "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/transaction-controller": "npm:^68.0.0" "@metamask/transaction-pay-controller": "npm:^23.7.0" - "@metamask/tron-wallet-snap": "npm:^1.25.6" + "@metamask/tron-wallet-snap": "npm:^1.26.0" "@metamask/utils": "npm:^11.11.0" "@metamask/wallet": "npm:^3.0.0" "@myx-trade/sdk": "npm:^0.1.265"