diff --git a/app/component-library/components-temp/ButtonFilter/ButtonFilter.tsx b/app/component-library/components-temp/ButtonFilter/ButtonFilter.tsx index 72ffaeda9f1..07aed12f3aa 100644 --- a/app/component-library/components-temp/ButtonFilter/ButtonFilter.tsx +++ b/app/component-library/components-temp/ButtonFilter/ButtonFilter.tsx @@ -5,6 +5,12 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { ButtonFilterProps } from './ButtonFilter.types'; +/** + * @deprecated Please update your code to use `ButtonFilter` from `@metamask/design-system-react-native`. + * The API may have changed — compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/ButtonFilter/README.md} + * @since @metamask/design-system-react-native@0.11.0 + */ const ButtonFilter = ({ children, isActive = false, diff --git a/app/component-library/components-temp/MainActionButton/MainActionButton.tsx b/app/component-library/components-temp/MainActionButton/MainActionButton.tsx index 1b23d11dc41..8d60f1edb7b 100644 --- a/app/component-library/components-temp/MainActionButton/MainActionButton.tsx +++ b/app/component-library/components-temp/MainActionButton/MainActionButton.tsx @@ -13,6 +13,12 @@ import { useAnimatedPressable, useStyles } from '../../hooks'; import { MainActionButtonProps } from './MainActionButton.types'; import styleSheet from './MainActionButton.styles'; +/** + * @deprecated Please update your code to use `MainActionButton` from `@metamask/design-system-react-native`. + * The API may have changed — compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/MainActionButton/README.md} + * @since @metamask/design-system-react-native@0.11.0 + */ const MainActionButton = ({ iconName, label, diff --git a/app/component-library/components-temp/TabEmptyState/TabEmptyState.tsx b/app/component-library/components-temp/TabEmptyState/TabEmptyState.tsx index d6ab4c51f75..12add47d5d5 100644 --- a/app/component-library/components-temp/TabEmptyState/TabEmptyState.tsx +++ b/app/component-library/components-temp/TabEmptyState/TabEmptyState.tsx @@ -14,6 +14,12 @@ import { import type { TabEmptyStateProps } from './TabEmptyState.types'; +/** + * @deprecated Please update your code to use `TabEmptyState` from `@metamask/design-system-react-native`. + * The API may have changed — compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/TabEmptyState/README.md} + * @since @metamask/design-system-react-native@0.11.0 + */ export const TabEmptyState: React.FC = ({ icon, description, diff --git a/app/component-library/components/Banners/Banner/Banner.tsx b/app/component-library/components/Banners/Banner/Banner.tsx index a733cf4d313..878583c0272 100644 --- a/app/component-library/components/Banners/Banner/Banner.tsx +++ b/app/component-library/components/Banners/Banner/Banner.tsx @@ -7,6 +7,14 @@ import BannerTip from './variants/BannerTip'; // Internal dependencies. import { BannerProps, BannerVariant } from './Banner.types'; +/** + * @deprecated Please update your code to use `BannerAlert` from `@metamask/design-system-react-native`. + * The `BannerVariant.Tip` variant is unused and will be removed. + * The API may have changed — compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/BannerAlert/README.md} + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/MIGRATION.md Migration docs} + * @since @metamask/design-system-react-native@0.11.0 + */ const Banner = (bannerProps: BannerProps) => { switch (bannerProps.variant) { case BannerVariant.Alert: diff --git a/app/component-library/components/Banners/Banner/foundation/BannerBase/BannerBase.tsx b/app/component-library/components/Banners/Banner/foundation/BannerBase/BannerBase.tsx index f49a3670d7c..bd8f1003332 100644 --- a/app/component-library/components/Banners/Banner/foundation/BannerBase/BannerBase.tsx +++ b/app/component-library/components/Banners/Banner/foundation/BannerBase/BannerBase.tsx @@ -24,6 +24,12 @@ import { TESTID_BANNER_CLOSE_BUTTON_ICON, } from './BannerBase.constants'; +/** + * @deprecated Please update your code to use `BannerBase` from `@metamask/design-system-react-native`. + * The API may have changed — compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/BannerBase/README.md} + * @since @metamask/design-system-react-native@0.11.0 + */ const BannerBase: React.FC = ({ style, startAccessory, diff --git a/app/component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.tsx b/app/component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.tsx index a0b6890391e..4d4c98d281a 100644 --- a/app/component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.tsx +++ b/app/component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.tsx @@ -18,6 +18,13 @@ import { BANNERALERT_TEST_ID, } from './BannerAlert.constants'; +/** + * @deprecated Please update your code to use `BannerAlert` from `@metamask/design-system-react-native`. + * The API may have changed — compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/BannerAlert/README.md} + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/MIGRATION.md Migration docs} + * @since @metamask/design-system-react-native@0.11.0 + */ const BannerAlert: React.FC = ({ style, severity = DEFAULT_BANNERALERT_SEVERITY, diff --git a/app/component-library/components/Banners/Banner/variants/BannerTip/BannerTip.tsx b/app/component-library/components/Banners/Banner/variants/BannerTip/BannerTip.tsx index 9b28a485be9..14c3d2ef72f 100644 --- a/app/component-library/components/Banners/Banner/variants/BannerTip/BannerTip.tsx +++ b/app/component-library/components/Banners/Banner/variants/BannerTip/BannerTip.tsx @@ -17,6 +17,12 @@ import { BANNERTIP_TEST_ID, } from './BannerTip.constants'; +/** + * @deprecated This component is unused and will be removed. + * Please use `BannerBase` from `@metamask/design-system-react-native` instead. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/BannerBase/README.md} + * @since @metamask/design-system-react-native@0.11.0 + */ const BannerTip: React.FC = ({ style, logoType = DEFAULT_BANNERTIP_LOGOTYPE, diff --git a/app/component-library/components/BottomSheets/BottomSheet/BottomSheet.tsx b/app/component-library/components/BottomSheets/BottomSheet/BottomSheet.tsx index 5c11c33e3d9..616a522a983 100644 --- a/app/component-library/components/BottomSheets/BottomSheet/BottomSheet.tsx +++ b/app/component-library/components/BottomSheets/BottomSheet/BottomSheet.tsx @@ -31,6 +31,12 @@ import BottomSheetDialog, { BottomSheetDialogRef, } from './foundation/BottomSheetDialog'; +/** + * @deprecated Please update your code to use `BottomSheet` from `@metamask/design-system-react-native`. + * The API may have changed — compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/BottomSheet/README.md} + * @since @metamask/design-system-react-native@0.11.0 + */ const BottomSheet = forwardRef( ( { diff --git a/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.tsx b/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.tsx index 48771d8aa55..24b09dd0356 100644 --- a/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.tsx +++ b/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.tsx @@ -47,6 +47,12 @@ import { BottomSheetDialogProps, } from './BottomSheetDialog.types'; +/** + * @deprecated Please update your code to use `BottomSheetDialog` from `@metamask/design-system-react-native`. + * The API may have changed — compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/BottomSheetDialog/README.md} + * @since @metamask/design-system-react-native@0.11.0 + */ const BottomSheetDialog = forwardRef< BottomSheetDialogRef, BottomSheetDialogProps diff --git a/app/component-library/components/Icons/Icon/Icon.tsx b/app/component-library/components/Icons/Icon/Icon.tsx index 2b801706f1d..5252f5e65ee 100644 --- a/app/component-library/components/Icons/Icon/Icon.tsx +++ b/app/component-library/components/Icons/Icon/Icon.tsx @@ -16,6 +16,7 @@ import { DEFAULT_ICON_SIZE, DEFAULT_ICON_COLOR } from './Icon.constants'; * @deprecated Please update your code to use `Icon` from `@metamask/design-system-react-native`. * The API may have changed — compare props before migrating. * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/Icon/README.md} + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/MIGRATION.md Migration docs} */ const Icon = ({ size = DEFAULT_ICON_SIZE, diff --git a/app/component-library/components/List/ListItem/ListItem.tsx b/app/component-library/components/List/ListItem/ListItem.tsx index 7c86fbfbe53..84e1dff0aa2 100644 --- a/app/component-library/components/List/ListItem/ListItem.tsx +++ b/app/component-library/components/List/ListItem/ListItem.tsx @@ -16,6 +16,12 @@ import { TESTID_LISTITEM_GAP, } from './ListItem.constants'; +/** + * @deprecated Please update your code to use `ListItem` from `@metamask/design-system-react-native`. + * The API may have changed — compare props before migrating. + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/ListItem/README.md} + * @since @metamask/design-system-react-native@0.11.0 + */ const ListItem: React.FC = ({ style, children, diff --git a/app/component-library/components/Texts/Text/Text.tsx b/app/component-library/components/Texts/Text/Text.tsx index 30db539e8b4..85105b32a20 100644 --- a/app/component-library/components/Texts/Text/Text.tsx +++ b/app/component-library/components/Texts/Text/Text.tsx @@ -16,6 +16,7 @@ import { DEFAULT_TEXT_COLOR, DEFAULT_TEXT_VARIANT } from './Text.constants'; * @deprecated Please update your code to use `Text` from `@metamask/design-system-react-native`. * The API may have changed — compare props before migrating. * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/src/components/Text/README.md} + * @see {@link https://github.com/MetaMask/metamask-design-system/blob/main/packages/design-system-react-native/MIGRATION.md Migration docs} */ const Text: React.FC = ({ variant = DEFAULT_TEXT_VARIANT, diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index e94b1d3c928..dce5f8c11f1 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -52,6 +52,7 @@ import ActivityView from '../../Views/ActivityView'; import RewardsNavigator from '../../UI/Rewards/RewardsNavigator'; import { ExploreFeed } from '../../Views/TrendingView/TrendingView'; import ExploreSearchScreen from '../../Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen'; +import ExploreSectionResultsFullView from '../../Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView'; import TrendingFeedSessionManager from '../../UI/Trending/services/TrendingFeedSessionManager'; import CollectiblesDetails from '../../UI/CollectibleModal'; import OptinMetrics from '../../UI/OptinMetrics'; @@ -1181,6 +1182,11 @@ const MainNavigator = () => { component={SitesFullView} options={{ headerShown: false, ...slideFromRightAnimation }} /> + + + + { - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const { colors } = useTheme(); const styles = createStyles(colors); const bottomSheetRef = useRef(null); diff --git a/app/components/UI/Bridge/components/InputStepper/index.tsx b/app/components/UI/Bridge/components/InputStepper/index.tsx index 3eac5fc2d1b..217f532cf1f 100644 --- a/app/components/UI/Bridge/components/InputStepper/index.tsx +++ b/app/components/UI/Bridge/components/InputStepper/index.tsx @@ -4,6 +4,7 @@ import Input from '../../../../../component-library/components/Form/TextField/fo import { ButtonIcon, ButtonIconSize, + ButtonIconVariant, IconColor, IconName, Text, @@ -42,7 +43,7 @@ export const InputStepper = ({ setMinusPressed(true)} @@ -72,7 +73,7 @@ export const InputStepper = ({ setPlusPressed(true)} diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx index 4cd65c0efc2..7aa44bd2d05 100644 --- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx @@ -301,7 +301,7 @@ const SpendingLimit: React.FC = ({ route }) => { {accountGroupName ?? selectedAccount.metadata.name} = ({ route }) => { {tokenLabel} = ({ route }) => { {spendingLimitLabel} ({ useQuery: jest.fn().mockReturnValue({ data: undefined, + isFetching: false, isLoading: false, error: null, refetch: jest.fn(), @@ -63,6 +64,7 @@ describe('useCardDetails', () => { (useQuery as jest.Mock).mockReturnValue({ data: undefined, + isFetching: false, isLoading: false, error: null, refetch: mockRefetch, @@ -90,6 +92,7 @@ describe('useCardDetails', () => { }; (useQuery as jest.Mock).mockReturnValue({ data: cardDetailsResult, + isFetching: false, isLoading: false, error: null, refetch: mockRefetch, @@ -106,6 +109,7 @@ describe('useCardDetails', () => { it('returns loading state from useQuery', () => { (useQuery as jest.Mock).mockReturnValue({ data: undefined, + isFetching: true, isLoading: true, error: null, refetch: mockRefetch, @@ -141,6 +145,7 @@ describe('useCardDetails', () => { const mockError = new Error('Test error'); (useQuery as jest.Mock).mockReturnValue({ data: undefined, + isFetching: false, isLoading: false, error: mockError, refetch: mockRefetch, diff --git a/app/components/UI/Card/hooks/useCardDetails.ts b/app/components/UI/Card/hooks/useCardDetails.ts index 10c0df41aa6..0805b2b45fe 100644 --- a/app/components/UI/Card/hooks/useCardDetails.ts +++ b/app/components/UI/Card/hooks/useCardDetails.ts @@ -23,6 +23,7 @@ const useCardDetails = () => { const { data: cardDetailsData, isLoading, + isFetching, error, refetch, } = useQuery({ @@ -69,7 +70,7 @@ const useCardDetails = () => { return { cardDetails: cardDetailsData?.cardDetails ?? null, warning: cardDetailsData?.warning ?? null, - isLoading, + isLoading: isLoading && isFetching, error: error as Error | null, fetchCardDetails, }; diff --git a/app/components/UI/Card/hooks/useCardPinToken.ts b/app/components/UI/Card/hooks/useCardPinToken.ts index 9d86d8542b2..b0742d4da6c 100644 --- a/app/components/UI/Card/hooks/useCardPinToken.ts +++ b/app/components/UI/Card/hooks/useCardPinToken.ts @@ -34,7 +34,11 @@ const useCardPinToken = (): UseCardPinTokenResult => { [theme.themeAppearance], ); - const { mutateAsync, isPending, error, data, reset } = useMutation({ + const { mutateAsync, isPending, error, data, reset } = useMutation< + CardPinTokenResponse, + Error, + { customCss: typeof customCss } + >({ mutationKey: cardQueries.pin.keys.token(), mutationFn: cardQueries.pin.tokenMutationFn(sdk), }); diff --git a/app/components/UI/Card/hooks/useCashbackWallet.ts b/app/components/UI/Card/hooks/useCashbackWallet.ts index f6f5c153cf9..82ed3e1d98e 100644 --- a/app/components/UI/Card/hooks/useCashbackWallet.ts +++ b/app/components/UI/Card/hooks/useCashbackWallet.ts @@ -124,7 +124,7 @@ const useCashbackWallet = () => { return { cashbackWallet: walletQuery.data ?? null, - isLoading: walletQuery.isLoading, + isLoading: walletQuery.isLoading && walletQuery.isFetching, error: walletQuery.error, fetchCashbackWallet: walletQuery.refetch, diff --git a/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.ts b/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.ts index 7b99725b1cb..7f198e076d9 100644 --- a/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.ts +++ b/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.ts @@ -69,7 +69,7 @@ const useGetCardExternalWalletDetails = ( const sdkRef = useRef(sdk); sdkRef.current = sdk; - const { data, isLoading, error, refetch } = useQuery({ + const { data, isLoading, isFetching, error, refetch } = useQuery({ queryKey: cardQueries.dashboard.keys.externalWalletDetails(), queryFn: async () => { const currentDelegationSettings = @@ -155,7 +155,7 @@ const useGetCardExternalWalletDetails = ( return { data: data ?? null, - isLoading, + isLoading: isLoading && isFetching, error: error as Error | null, fetchData, }; diff --git a/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts b/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts index 90d65341b70..1ea8ec1e9e0 100644 --- a/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts +++ b/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts @@ -369,6 +369,7 @@ describe('useGetDelegationSettings', () => { it('returns loading state from useQuery', () => { (useQuery as jest.Mock).mockReturnValue({ data: undefined, + isFetching: true, isLoading: true, error: null, }); diff --git a/app/components/UI/Card/hooks/useGetDelegationSettings.ts b/app/components/UI/Card/hooks/useGetDelegationSettings.ts index 12fd0c0442a..77e887f1d63 100644 --- a/app/components/UI/Card/hooks/useGetDelegationSettings.ts +++ b/app/components/UI/Card/hooks/useGetDelegationSettings.ts @@ -27,7 +27,7 @@ const useGetDelegationSettings = () => { [], ); - const { data, isLoading, error } = + const { data, isLoading, isFetching, error } = useQuery({ queryKey: QUERY_KEY, queryFn, @@ -46,7 +46,7 @@ const useGetDelegationSettings = () => { return { data: data ?? null, - isLoading, + isLoading: isLoading && isFetching, error: error as Error | null, fetchData, }; diff --git a/app/components/UI/Card/hooks/useGetLatestAllowanceForPriorityToken.ts b/app/components/UI/Card/hooks/useGetLatestAllowanceForPriorityToken.ts index 153c42f6297..bf76f6fa287 100644 --- a/app/components/UI/Card/hooks/useGetLatestAllowanceForPriorityToken.ts +++ b/app/components/UI/Card/hooks/useGetLatestAllowanceForPriorityToken.ts @@ -65,7 +65,9 @@ const useGetLatestAllowanceForPriorityToken = ( : ('' as `${string}:${string}`); const decimals = applicable ? priorityToken.decimals || 0 : 0; - const { data, isLoading, error, refetch } = useQuery({ + const { data, isLoading, isFetching, error, refetch } = useQuery< + string | null + >({ queryKey: cardQueries.dashboard.keys.latestAllowance( tokenAddress, delegationContract, @@ -111,7 +113,7 @@ const useGetLatestAllowanceForPriorityToken = ( return { latestAllowance: data ?? null, - isLoading, + isLoading: isLoading && isFetching, error: error as Error | null, refetch: refetchAllowance, }; diff --git a/app/components/UI/Card/hooks/useGetUserKYCStatus.ts b/app/components/UI/Card/hooks/useGetUserKYCStatus.ts index a3367d2bc8f..9c2bcf63631 100644 --- a/app/components/UI/Card/hooks/useGetUserKYCStatus.ts +++ b/app/components/UI/Card/hooks/useGetUserKYCStatus.ts @@ -28,29 +28,32 @@ const useGetUserKYCStatus = (): UseGetUserKYCStatusResult => { const sdkRef = useRef(sdk); sdkRef.current = sdk; - const { data, isLoading, error, refetch } = useQuery({ - queryKey: cardQueries.dashboard.keys.kycStatus(), - queryFn: async () => { - try { - const currentSdk = sdkRef.current; - if (!currentSdk) throw new Error('SDK not initialized'); - const response = await currentSdk.getUserDetails(); + const { data, isLoading, isFetching, error, refetch } = + useQuery({ + queryKey: cardQueries.dashboard.keys.kycStatus(), + queryFn: async () => { + try { + const currentSdk = sdkRef.current; + if (!currentSdk) throw new Error('SDK not initialized'); + const response = await currentSdk.getUserDetails(); - return { - verificationState: response.verificationState ?? null, - userId: response.id, - userDetails: response, - }; - } catch (err) { - const errorMessage = - err instanceof Error ? err : new Error('Failed to fetch KYC status'); - Logger.log('useGetUserKYCStatus: Error fetching KYC status', err); - throw errorMessage; - } - }, - enabled: false, - staleTime: 0, - }); + return { + verificationState: response.verificationState ?? null, + userId: response.id, + userDetails: response, + }; + } catch (err) { + const errorMessage = + err instanceof Error + ? err + : new Error('Failed to fetch KYC status'); + Logger.log('useGetUserKYCStatus: Error fetching KYC status', err); + throw errorMessage; + } + }, + enabled: false, + staleTime: 0, + }); const fetchKYCStatus = useCallback(async () => { const result = await refetch(); @@ -59,7 +62,7 @@ const useGetUserKYCStatus = (): UseGetUserKYCStatusResult => { return { kycStatus: data ?? null, - isLoading, + isLoading: isLoading && isFetching, error: error as Error | null, fetchKYCStatus, }; diff --git a/app/components/UI/Card/hooks/useRegistrationSettings.test.ts b/app/components/UI/Card/hooks/useRegistrationSettings.test.ts index 4edb42eb206..c2239556109 100644 --- a/app/components/UI/Card/hooks/useRegistrationSettings.test.ts +++ b/app/components/UI/Card/hooks/useRegistrationSettings.test.ts @@ -11,11 +11,13 @@ const mockRefetch = jest.fn(); let mockQueryFn: (() => Promise) | undefined; let mockQueryReturn: { data: unknown; + isFetching: boolean; isLoading: boolean; error: Error | null; refetch: jest.Mock; } = { data: undefined, + isFetching: false, isLoading: false, error: null, refetch: mockRefetch, @@ -49,6 +51,7 @@ describe('useRegistrationSettings', () => { mockQueryReturn = { data: undefined, + isFetching: false, isLoading: false, error: null, refetch: mockRefetch, @@ -154,6 +157,7 @@ describe('useRegistrationSettings', () => { it('returns data from useQuery', () => { mockQueryReturn = { data: mockRegistrationSettingsResponse, + isFetching: false, isLoading: false, error: null, refetch: mockRefetch, @@ -170,6 +174,7 @@ describe('useRegistrationSettings', () => { it('returns loading state from useQuery', () => { mockQueryReturn = { data: undefined, + isFetching: true, isLoading: true, error: null, refetch: mockRefetch, @@ -185,6 +190,7 @@ describe('useRegistrationSettings', () => { mockQueryReturn = { data: undefined, isLoading: false, + isFetching: false, error: new Error('Registration settings error'), refetch: mockRefetch, }; diff --git a/app/components/UI/Card/hooks/useRegistrationSettings.ts b/app/components/UI/Card/hooks/useRegistrationSettings.ts index a4290b5af21..9600e277454 100644 --- a/app/components/UI/Card/hooks/useRegistrationSettings.ts +++ b/app/components/UI/Card/hooks/useRegistrationSettings.ts @@ -11,7 +11,7 @@ import { cardQueries } from '../queries'; const useRegistrationSettings = () => { const { sdk } = useCardSDK(); - const { data, isLoading, error, refetch } = useQuery({ + const { data, isLoading, isFetching, error, refetch } = useQuery({ queryKey: cardQueries.dashboard.keys.registrationSettings(), queryFn: () => { if (!sdk) throw new Error('SDK not initialized'); @@ -28,7 +28,7 @@ const useRegistrationSettings = () => { return { data: data ?? null, - isLoading, + isLoading: isLoading && isFetching, error: error as Error | null, fetchData, }; diff --git a/app/components/UI/Card/hooks/useSpendingLimit.test.ts b/app/components/UI/Card/hooks/useSpendingLimit.test.ts index 670cc2ee9b6..c84c542f04f 100644 --- a/app/components/UI/Card/hooks/useSpendingLimit.test.ts +++ b/app/components/UI/Card/hooks/useSpendingLimit.test.ts @@ -269,7 +269,9 @@ describe('useSpendingLimit', () => { {}, ] as never); - // Default selected account + // Default selected account (all useSelector calls use this by default; + // selectEvmNetworkConfigurationsByChainId call gets an object whose keys + // are not real chain IDs, but useTokensWithBalance is mocked so it doesn't matter) mockUseSelector.mockReturnValue({ id: 'account-1', address: '0xaccount1', @@ -961,6 +963,128 @@ describe('useSpendingLimit', () => { expect.objectContaining({ flow: 'onboarding' }), ); }); + + it('includes musd_linea_balance from walletTokens', () => { + const musdToken = { + address: '0xmusd', + symbol: 'mUSD', + chainId: LINEA_CAIP_CHAIN_ID, + tokenFiatAmount: 450, + }; + (useTokensWithBalance as jest.Mock) + .mockReturnValueOnce([musdToken]) // walletTokens (card) + .mockReturnValueOnce([]); // allWalletTokens + + renderHook(() => useSpendingLimit(createDefaultParams())); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ musd_linea_balance: 450 }), + ); + }); + + it('emits musd_linea_balance of 0 when mUSD not in wallet', () => { + (useTokensWithBalance as jest.Mock) + .mockReturnValueOnce([]) // walletTokens — no mUSD + .mockReturnValueOnce([]); + + renderHook(() => useSpendingLimit(createDefaultParams())); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ musd_linea_balance: 0 }), + ); + }); + + it('includes top_card_chain_asset for highest-balance card token', () => { + const lowToken = { + address: '0xlow', + symbol: 'USDC', + chainId: LINEA_CAIP_CHAIN_ID, + tokenFiatAmount: 50, + }; + const highToken = { + address: '0xhigh', + symbol: 'mUSD', + chainId: LINEA_CAIP_CHAIN_ID, + tokenFiatAmount: 500, + }; + // allTokens must include the same addresses so cardSupportedKeys accepts them + const allTokens = [ + createMockToken({ address: '0xlow', symbol: 'USDC' }), + createMockToken({ address: '0xhigh', symbol: 'mUSD' }), + ]; + (useTokensWithBalance as jest.Mock) + .mockReturnValueOnce([lowToken, highToken]) // walletTokens (card) + .mockReturnValueOnce([]); + + renderHook(() => useSpendingLimit(createDefaultParams({ allTokens }))); + + // LINEA_CAIP_CHAIN_ID maps to 'linea' in caipChainIdToNetwork + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ top_card_chain_asset: 'linea:musd' }), + ); + }); + + it('emits null for top_card_chain_asset when no card tokens have balance', () => { + (useTokensWithBalance as jest.Mock) + .mockReturnValueOnce([]) // walletTokens — empty + .mockReturnValueOnce([]); + + renderHook(() => useSpendingLimit(createDefaultParams())); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ top_card_chain_asset: null }), + ); + }); + + it('emits null for top_card_chain_asset when wallet token is not in card-supported allTokens', () => { + // Native SOL has an address but is not in allTokens (card does not support it). + // allTokens contains only an unrelated USDC token so allTokens.length > 0, + // which lets the effect fire while still excluding nativeSol from the result. + const nativeSol = { + address: 'So11111111111111111111111111111111111111112', + symbol: 'SOL', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + tokenFiatAmount: 1200, + }; + (useTokensWithBalance as jest.Mock) + .mockReturnValueOnce([nativeSol]) // walletTokens + .mockReturnValueOnce([nativeSol]); + + // allTokens has a card-supported token (USDC on Linea) but NOT nativeSol + renderHook(() => useSpendingLimit(createDefaultParams())); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ top_card_chain_asset: null }), + ); + }); + + it('includes top_wallet_chain_asset from all-wallet tokens', () => { + const cardToken = { + address: '0xcard', + symbol: 'mUSD', + chainId: LINEA_CAIP_CHAIN_ID, + tokenFiatAmount: 100, + }; + const walletToken = { + address: '0xwallet', + symbol: 'ETH', + chainId: '0x1', // Ethereum mainnet — not a card chain + tokenFiatAmount: 9000, + }; + (useTokensWithBalance as jest.Mock) + .mockReturnValueOnce([cardToken]) // walletTokens (card) + .mockReturnValueOnce([walletToken]); // allWalletTokens + + renderHook(() => useSpendingLimit(createDefaultParams())); + + // 0x1 → eip155:1, not in caipChainIdToNetwork → strips namespace → '1:eth' + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + top_wallet_chain_asset: '1:eth', + top_wallet_asset_balance: 9000, + }), + ); + }); }); describe('Returned Token from AssetSelectionBottomSheet', () => { diff --git a/app/components/UI/Card/hooks/useSpendingLimit.ts b/app/components/UI/Card/hooks/useSpendingLimit.ts index a52d7c0b45e..cefb1f002b9 100644 --- a/app/components/UI/Card/hooks/useSpendingLimit.ts +++ b/app/components/UI/Card/hooks/useSpendingLimit.ts @@ -15,6 +15,7 @@ import { useSelector } from 'react-redux'; import { useQueryClient } from '@tanstack/react-query'; import { useTheme } from '../../../../util/theme'; import { selectSelectedInternalAccount } from '../../../../selectors/accountsController'; +import { selectEvmNetworkConfigurationsByChainId } from '../../../../selectors/networkController'; import { createAccountSelectorNavDetails } from '../../../Views/AccountSelector'; import { useCardDelegation, UserCancelledError } from './useCardDelegation'; import { useCardSDK } from '../sdk'; @@ -28,6 +29,7 @@ import { BAANX_MAX_LIMIT, caipChainIdToNetwork, CARD_CHAIN_IDS, + cardNetworkInfos, } from '../constants'; import { buildTokenListFromSettings, @@ -48,6 +50,7 @@ import { ToastVariants, } from '../../../../component-library/components/Toast'; import { IconName } from '../../../../component-library/components/Icons/Icon'; +import { CaipChainId, Hex } from '@metamask/utils'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { CardActions, CardScreens } from '../util/metrics'; @@ -143,6 +146,10 @@ const useSpendingLimit = ({ const selectedAccount = useSelector(selectSelectedInternalAccount); const accountIdRef = useRef(selectedAccount?.id); + // Guard that ensures the screen-view analytics event fires exactly once, + // but only after allTokens has loaded (non-empty) so cardSupportedKeys is accurate. + const screenViewFiredRef = useRef(false); + useEffect(() => { if (selectedAccount?.id && selectedAccount.id !== accountIdRef.current) { accountIdRef.current = selectedAccount.id; @@ -161,25 +168,115 @@ const useSpendingLimit = ({ const isLoading = isDelegationLoading || isProcessing; - // Track screen view + // Wallet-only token balances for the currently selected MetaMask account. + // Using this (instead of useAssetBalances) ensures sorting reflects the active + // account's real wallet balance — not the card's availableBalance or another + // account's cached data — so account switches are reflected immediately. + const walletTokens = useTokensWithBalance({ + chainIds: CARD_CHAIN_IDS, + }); + + // All-wallet token balances across every EVM chain the user has configured + // plus Solana mainnet — used for the top_wallet_chain_asset metric. + const evmNetworkConfigs = useSelector( + selectEvmNetworkConfigurationsByChainId, + ); + const allWalletChainIds = useMemo( + () => + [ + ...(Object.keys(evmNetworkConfigs) as Hex[]), + cardNetworkInfos.solana.caipChainId, + ] as (Hex | CaipChainId)[], + [evmNetworkConfigs], + ); + const allWalletTokens = useTokensWithBalance({ chainIds: allWalletChainIds }); + + // Returns 'network:symbol' (e.g. 'linea:musd', 'base:usdc') for analytics. + // For card chains, uses the friendly network name from caipChainIdToNetwork. + // For unknown chains, strips the CAIP namespace prefix so the output is + // always a clean two-part 'chainId:symbol' string (e.g. '1:eth', '137:usdc'). + const toNetworkAsset = (token: { + chainId: string; + symbol?: string | null; + }): string => { + const caipId = token.chainId.startsWith('0x') + ? `eip155:${parseInt(token.chainId, 16)}` + : token.chainId; + const network = + caipChainIdToNetwork[caipId as CaipChainId] ?? + caipId.replace(/^[^:]+:/, ''); + return `${network}:${token.symbol?.toLowerCase() ?? ''}`; + }; + + // Track screen view — fires exactly once, after allTokens has loaded so that + // cardSupportedKeys is accurate and top_card_chain_asset is not spuriously null. useEffect(() => { + if (screenViewFiredRef.current || allTokens.length === 0) return; + screenViewFiredRef.current = true; + const screen = flow === 'enable' ? CardScreens.ENABLE_TOKEN : CardScreens.SPENDING_LIMIT; + const musdOnLinea = walletTokens.find( + (t) => + t.symbol?.toUpperCase() === 'MUSD' && + (t.chainId === LINEA_CAIP_CHAIN_ID || + t.chainId === safeFormatChainIdToHex(LINEA_CAIP_CHAIN_ID)), + ); + // Only consider tokens actually supported by the card (present in allTokens) + const cardSupportedKeys = new Set( + allTokens.map((t) => { + const chainId = isSolanaChainId(t.caipChainId) + ? t.caipChainId + : safeFormatChainIdToHex(t.caipChainId ?? ''); + return `${chainId}:${t.address?.toLowerCase()}`; + }), + ); + const topCardToken = + [...walletTokens] + .filter((t) => { + if (!t.address || (t.tokenFiatAmount ?? 0) <= 0) return false; + const wtChainId = isSolanaChainId(t.chainId) + ? t.chainId + : safeFormatChainIdToHex(t.chainId); + return cardSupportedKeys.has( + `${wtChainId}:${t.address.toLowerCase()}`, + ); + }) + .sort( + (a, b) => (b.tokenFiatAmount ?? 0) - (a.tokenFiatAmount ?? 0), + )[0] ?? null; + const topWalletToken = + [...allWalletTokens] + .filter((t) => t.address && (t.tokenFiatAmount ?? 0) > 0) + .sort( + (a, b) => (b.tokenFiatAmount ?? 0) - (a.tokenFiatAmount ?? 0), + )[0] ?? null; + trackEvent( createEventBuilder(MetaMetricsEvents.CARD_VIEWED) - .addProperties({ screen, flow }) + .addProperties({ + screen, + flow, + musd_linea_balance: musdOnLinea?.tokenFiatAmount ?? 0, + top_card_chain_asset: topCardToken + ? toNetworkAsset(topCardToken) + : null, + top_wallet_chain_asset: topWalletToken + ? toNetworkAsset(topWalletToken) + : null, + top_wallet_asset_balance: topWalletToken?.tokenFiatAmount ?? 0, + }) .build(), ); - }, [trackEvent, createEventBuilder, flow]); - - // Wallet-only token balances for the currently selected MetaMask account. - // Using this (instead of useAssetBalances) ensures sorting reflects the active - // account's real wallet balance — not the card's availableBalance or another - // account's cached data — so account switches are reflected immediately. - const walletTokens = useTokensWithBalance({ - chainIds: CARD_CHAIN_IDS, - }); + }, [ + trackEvent, + createEventBuilder, + flow, + allTokens, + walletTokens, + allWalletTokens, + ]); useEffect(() => { if (hasInitialized) return; diff --git a/app/components/UI/Card/hooks/useUserRegistrationStatus.test.ts b/app/components/UI/Card/hooks/useUserRegistrationStatus.test.ts index ab51f738732..3ace4e653fe 100644 --- a/app/components/UI/Card/hooks/useUserRegistrationStatus.test.ts +++ b/app/components/UI/Card/hooks/useUserRegistrationStatus.test.ts @@ -198,13 +198,11 @@ describe('useUserRegistrationStatus', () => { it('refetchInterval returns false when state is VERIFIED', () => { renderHook(() => useUserRegistrationStatus()); - const refetchIntervalFn = mockQueryConfig.refetchInterval as (query: { - state: { data: { verificationState: string } | undefined }; - }) => number | false; + const refetchIntervalFn = mockQueryConfig.refetchInterval as ( + data: { verificationState: string } | undefined, + ) => number | false; - const result = refetchIntervalFn({ - state: { data: { verificationState: 'VERIFIED' } }, - }); + const result = refetchIntervalFn({ verificationState: 'VERIFIED' }); expect(result).toBe(false); }); @@ -212,13 +210,11 @@ describe('useUserRegistrationStatus', () => { it('refetchInterval returns false when state is REJECTED', () => { renderHook(() => useUserRegistrationStatus()); - const refetchIntervalFn = mockQueryConfig.refetchInterval as (query: { - state: { data: { verificationState: string } | undefined }; - }) => number | false; + const refetchIntervalFn = mockQueryConfig.refetchInterval as ( + data: { verificationState: string } | undefined, + ) => number | false; - const result = refetchIntervalFn({ - state: { data: { verificationState: 'REJECTED' } }, - }); + const result = refetchIntervalFn({ verificationState: 'REJECTED' }); expect(result).toBe(false); }); @@ -226,13 +222,11 @@ describe('useUserRegistrationStatus', () => { it('refetchInterval returns 5000 when data is undefined', () => { renderHook(() => useUserRegistrationStatus()); - const refetchIntervalFn = mockQueryConfig.refetchInterval as (query: { - state: { data: undefined }; - }) => number | false; + const refetchIntervalFn = mockQueryConfig.refetchInterval as ( + data: undefined, + ) => number | false; - const result = refetchIntervalFn({ - state: { data: undefined }, - }); + const result = refetchIntervalFn(undefined); expect(result).toBe(5000); }); @@ -289,57 +283,43 @@ describe('useUserRegistrationStatus', () => { it('refetchInterval stops polling when state is VERIFIED', () => { renderHook(() => useUserRegistrationStatus()); - const refetchIntervalFn = mockQueryConfig.refetchInterval as (query: { - state: { data: { verificationState: string } | undefined }; - }) => number | false; + const refetchIntervalFn = mockQueryConfig.refetchInterval as ( + data: { verificationState: string } | undefined, + ) => number | false; - expect( - refetchIntervalFn({ - state: { data: { verificationState: 'VERIFIED' } }, - }), - ).toBe(false); + expect(refetchIntervalFn({ verificationState: 'VERIFIED' })).toBe(false); }); it('refetchInterval stops polling when state is UNVERIFIED', () => { renderHook(() => useUserRegistrationStatus()); - const refetchIntervalFn = mockQueryConfig.refetchInterval as (query: { - state: { data: { verificationState: string } | undefined }; - }) => number | false; + const refetchIntervalFn = mockQueryConfig.refetchInterval as ( + data: { verificationState: string } | undefined, + ) => number | false; - expect( - refetchIntervalFn({ - state: { data: { verificationState: 'UNVERIFIED' } }, - }), - ).toBe(false); + expect(refetchIntervalFn({ verificationState: 'UNVERIFIED' })).toBe( + false, + ); }); it('refetchInterval stops polling when state is REJECTED', () => { renderHook(() => useUserRegistrationStatus()); - const refetchIntervalFn = mockQueryConfig.refetchInterval as (query: { - state: { data: { verificationState: string } | undefined }; - }) => number | false; + const refetchIntervalFn = mockQueryConfig.refetchInterval as ( + data: { verificationState: string } | undefined, + ) => number | false; - expect( - refetchIntervalFn({ - state: { data: { verificationState: 'REJECTED' } }, - }), - ).toBe(false); + expect(refetchIntervalFn({ verificationState: 'REJECTED' })).toBe(false); }); it('refetchInterval continues polling when state is PENDING', () => { renderHook(() => useUserRegistrationStatus()); - const refetchIntervalFn = mockQueryConfig.refetchInterval as (query: { - state: { data: { verificationState: string } | undefined }; - }) => number | false; + const refetchIntervalFn = mockQueryConfig.refetchInterval as ( + data: { verificationState: string } | undefined, + ) => number | false; - expect( - refetchIntervalFn({ - state: { data: { verificationState: 'PENDING' } }, - }), - ).toBe(5000); + expect(refetchIntervalFn({ verificationState: 'PENDING' })).toBe(5000); }); }); diff --git a/app/components/UI/Card/hooks/useUserRegistrationStatus.ts b/app/components/UI/Card/hooks/useUserRegistrationStatus.ts index c7d7c44350a..1e36a0f8061 100644 --- a/app/components/UI/Card/hooks/useUserRegistrationStatus.ts +++ b/app/components/UI/Card/hooks/useUserRegistrationStatus.ts @@ -29,7 +29,7 @@ export const useUserRegistrationStatus = const onboardingId = useSelector(selectOnboardingId); const [isPolling, setIsPolling] = useState(false); - const { data, isLoading, error } = useQuery({ + const { data, isLoading, isFetching, error } = useQuery({ queryKey: cardQueries.dashboard.keys.registrationStatus( onboardingId ?? '', ), @@ -39,8 +39,8 @@ export const useUserRegistrationStatus = return sdk.getRegistrationStatus(onboardingId); }, enabled: isPolling && !!sdk && !!onboardingId, - refetchInterval: (query) => { - const state = query.state.data?.verificationState; + refetchInterval: (data) => { + const state = data?.verificationState; if (state && state !== 'PENDING') return false; return POLLING_INTERVAL; }, @@ -66,7 +66,7 @@ export const useUserRegistrationStatus = return { verificationState, - isLoading, + isLoading: isLoading && isFetching, isError: !!error, error: error ? getErrorMessage(error) : null, startPolling, diff --git a/app/components/UI/DeepLinkModal/DeepLinkModal.test.tsx b/app/components/UI/DeepLinkModal/DeepLinkModal.test.tsx index 04a92f1869a..9474317cbcd 100644 --- a/app/components/UI/DeepLinkModal/DeepLinkModal.test.tsx +++ b/app/components/UI/DeepLinkModal/DeepLinkModal.test.tsx @@ -2,9 +2,10 @@ import { renderScreen } from '../../../util/test/renderWithProvider'; import { DeepLinkModal } from './'; import { fireEvent, act } from '@testing-library/react-native'; import { useParams } from '../../../util/navigation/navUtils'; -import { useMetrics } from '../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; +import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock'; import { setDeepLinkModalDisabled } from '../../../actions/settings'; -import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; +import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; import { useNavigation } from '@react-navigation/native'; import { Linking, Platform } from 'react-native'; import { createDeepLinkUsedEventBuilder } from '../../../core/DeeplinkManager/util/deeplinks/deepLinkAnalytics'; @@ -29,21 +30,14 @@ jest.mock('react-native/Libraries/Linking/Linking', () => ({ })); const mockTrackEvent = jest.fn(); -jest.mock('../../../components/hooks/useMetrics'); - -(useMetrics as jest.MockedFn).mockReturnValue({ - trackEvent: mockTrackEvent, - createEventBuilder: MetricsEventBuilder.createEventBuilder, - enable: jest.fn(), - addTraitsToUser: jest.fn(), - createDataDeletionTask: jest.fn(), - checkDataDeleteStatus: jest.fn(), - getDeleteRegulationCreationDate: jest.fn(), - getDeleteRegulationId: jest.fn(), - isDataRecorded: jest.fn(), - isEnabled: jest.fn(), - getMetaMetricsId: jest.fn(), -}); +jest.mock('../../../components/hooks/useAnalytics/useAnalytics'); + +jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: AnalyticsEventBuilder.createEventBuilder, + }), +); jest.mock('../../../util/metrics', () => jest.fn().mockReturnValue({ deviceProp: 'Device value' }), diff --git a/app/components/UI/DeleteWalletModal/index.tsx b/app/components/UI/DeleteWalletModal/index.tsx index f2085f29a70..4be88b77a7c 100644 --- a/app/components/UI/DeleteWalletModal/index.tsx +++ b/app/components/UI/DeleteWalletModal/index.tsx @@ -33,7 +33,7 @@ import Button, { import { useSignOut } from '../../../util/identity/hooks/useAuthentication'; import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; -import { useMetrics } from '../../hooks/useMetrics'; +import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import ButtonIcon, { ButtonIconSizes, } from '../../../component-library/components/Buttons/ButtonIcon'; @@ -46,7 +46,7 @@ const DeleteWalletModal: React.FC = () => { const navigation = useNavigation(); const route = useRoute(); const { colors } = useTheme(); - const { isEnabled } = useMetrics(); + const { isEnabled } = useAnalytics(); const styles = createStyles(colors); const isResetWalletFromParams = diff --git a/app/components/UI/FundActionMenu/FundActionMenu.test.tsx b/app/components/UI/FundActionMenu/FundActionMenu.test.tsx index b0299062642..ea3067610a0 100644 --- a/app/components/UI/FundActionMenu/FundActionMenu.test.tsx +++ b/app/components/UI/FundActionMenu/FundActionMenu.test.tsx @@ -10,7 +10,8 @@ import { WalletActionsBottomSheetSelectorsIDs } from '../../Views/WalletActions/ import { RampType } from '../../../reducers/fiatOrders/types'; // Internal dependencies. -import { useMetrics } from '../../hooks/useMetrics'; +import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; +import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock'; import useRampNetwork from '../Ramp/Aggregator/hooks/useRampNetwork'; import useDepositEnabled from '../Ramp/Deposit/hooks/useDepositEnabled'; import useRampsUnifiedV1Enabled from '../Ramp/hooks/useRampsUnifiedV1Enabled'; @@ -60,7 +61,7 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), connect: jest.fn(() => (component: React.ComponentType) => component), })); -jest.mock('../../hooks/useMetrics'); +jest.mock('../../hooks/useAnalytics/useAnalytics'); jest.mock('../Ramp/Aggregator/hooks/useRampNetwork'); jest.mock('../Ramp/Deposit/hooks/useDepositEnabled'); jest.mock('../Ramp/hooks/useRampsUnifiedV1Enabled'); @@ -95,7 +96,7 @@ const mockUseNavigation = useNavigation as jest.MockedFunction< >; const mockUseRoute = useRoute as jest.MockedFunction; const mockUseSelector = useSelector as jest.MockedFunction; -const mockUseMetrics = useMetrics as jest.MockedFunction; +const mockUseAnalytics = jest.mocked(useAnalytics); const mockUseRampNetwork = useRampNetwork as jest.MockedFunction< typeof useRampNetwork >; @@ -155,10 +156,12 @@ describe('FundActionMenu', () => { build: mockBuild, }); - mockUseMetrics.mockReturnValue({ - trackEvent: mockTrackEvent, - createEventBuilder: mockCreateEventBuilder, - } as never); + mockUseAnalytics.mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + ); mockUseRampNetwork.mockReturnValue([true, true]); mockUseDepositEnabled.mockReturnValue({ isDepositEnabled: true }); diff --git a/app/components/UI/FundActionMenu/FundActionMenu.tsx b/app/components/UI/FundActionMenu/FundActionMenu.tsx index 56aa7f88caa..0d735da6ae7 100644 --- a/app/components/UI/FundActionMenu/FundActionMenu.tsx +++ b/app/components/UI/FundActionMenu/FundActionMenu.tsx @@ -17,7 +17,7 @@ import { WalletActionsBottomSheetSelectorsIDs } from '../../Views/WalletActions/ import { strings } from '../../../../locales/i18n'; // Internal dependencies -import { useMetrics } from '../../hooks/useMetrics'; +import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import { trace, TraceName } from '../../../util/trace'; import { selectCanSignTransactions } from '../../../selectors/accountsController'; import { RampType } from '../../../reducers/fiatOrders/types'; @@ -45,7 +45,7 @@ const FundActionMenu = () => { const [isNetworkRampSupported] = useRampNetwork(); const { isDepositEnabled } = useDepositEnabled(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const canSignTransactions = useSelector(selectCanSignTransactions); const rampGeodetectedRegion = useSelector(getDetectedGeolocation); const rampUnifiedV1Enabled = useRampsUnifiedV1Enabled(); diff --git a/app/components/UI/Perps/Perps.testIds.ts b/app/components/UI/Perps/Perps.testIds.ts index 9740c90ea71..20b4d81bcb9 100644 --- a/app/components/UI/Perps/Perps.testIds.ts +++ b/app/components/UI/Perps/Perps.testIds.ts @@ -551,6 +551,10 @@ export const PerpsOrderViewSelectorsIDs = { export const PerpsLimitPriceBottomSheetSelectorsIDs = { PRICE_DISPLAY: 'perps-limit-price-display', CONFIRM_BUTTON: 'perps-limit-price-confirm-button', + PRESET_MID: 'perps-limit-price-preset-mid', + PRESET_BID: 'perps-limit-price-preset-bid', + PRESET_ASK: 'perps-limit-price-preset-ask', + PRESET_PERCENT: 'perps-limit-price-preset-', }; // ======================================== diff --git a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx index 3cc3536fda6..660927196e6 100644 --- a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx +++ b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx @@ -33,6 +33,7 @@ import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, PERPS_CONSTANTS, + DECIMAL_PRECISION_CONFIG, } from '@metamask/perps-controller'; import { usePerpsLivePrices } from '../../hooks/stream'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; @@ -74,6 +75,7 @@ const PerpsTPSLView: React.FC = () => { const [isUpdating, setIsUpdating] = useState(false); const { colors } = useTheme(); const styles = createStyles(colors); + const scrollViewRef = useRef(null); // Keypad state management @@ -108,6 +110,21 @@ const PerpsTPSLView: React.FC = () => { orderType === 'limit' && limitPrice && parseFloat(limitPrice) > 0; const currentPrice = hasValidLimitPrice ? parseFloat(limitPrice) : spotPrice; + // Compute keypad decimal places from current price so low-value assets + // (e.g. PUMP at ~$0.002) get enough decimal places to enter a trigger price. + // Formula: floor(-log10(price)) + MaxSignificantFigures, clamped to [2, MaxPriceDecimals]. + const keypadDecimals = + currentPrice > 0 && isFinite(currentPrice) + ? Math.min( + Math.max( + 2, + Math.floor(-Math.log10(currentPrice)) + + DECIMAL_PRECISION_CONFIG.MaxSignificantFigures, + ), + DECIMAL_PRECISION_CONFIG.MaxPriceDecimals, + ) + : DECIMAL_PRECISION_CONFIG.MaxPriceDecimals; + // Determine the entry price based on order type // For limit orders, use the limit price as entry price if available // For market orders or when limit price is not set, use spot price @@ -868,7 +885,12 @@ const PerpsTPSLView: React.FC = () => { })()} onChange={handleKeypadChange} currency={TP_SL_VIEW_CONFIG.KeypadCurrencyCode} - decimals={TP_SL_VIEW_CONFIG.KeypadDecimals} + decimals={ + focusedInput === 'takeProfitPercentage' || + focusedInput === 'stopLossPercentage' + ? TP_SL_VIEW_CONFIG.KeypadDecimals + : keypadDecimals + } /> diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx index 44e9f158912..cde51274589 100644 --- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx @@ -528,6 +528,69 @@ describe('PerpsLimitPriceBottomSheet', () => { }); }); + describe('Preset decimal precision (TAT-2399)', () => { + const xrpProps = { + ...defaultProps, + asset: 'XRP', + currentPrice: 2.3418, + }; + + beforeEach(() => { + const { usePerpsTopOfBook } = jest.requireMock('../../hooks/stream'); + usePerpsTopOfBook.mockReturnValue({ + bestBid: '2.3412', + bestAsk: '2.3426', + }); + }); + + it('Mid preset uses 5 significant figures (not 4) for XRP-range prices', () => { + render(); + + const midButton = screen.getByText( + 'perps.order.limit_price_modal.mid_price', + ); + fireEvent.press(midButton); + + // With 5 sig figs: 2.3418 → 4 decimals (intDig=1, dec=4) + // With 4 sig figs (bug): 2.3418 → 3 decimals (2.342) + expect(screen.getByTestId('keypad-value')).toHaveTextContent('2.3418'); + }); + + it('Bid preset uses 5 significant figures for XRP-range prices', () => { + render(); + + const bidButton = screen.getByText( + 'perps.order.limit_price_modal.bid_price', + ); + fireEvent.press(bidButton); + + // bestBid='2.3412' → parseFloat → 2.3412 → 5 sig figs → 4 decimals + expect(screen.getByTestId('keypad-value')).toHaveTextContent('2.3412'); + }); + + it('Ask preset uses 5 significant figures for XRP-range prices', () => { + render(); + + const askButton = screen.getByText( + 'perps.order.limit_price_modal.ask_price', + ); + fireEvent.press(askButton); + + // bestAsk='2.3426' → parseFloat → 2.3426 → 5 sig figs → 4 decimals + expect(screen.getByTestId('keypad-value')).toHaveTextContent('2.3426'); + }); + + it('Percentage preset uses 5 significant figures for XRP-range prices', () => { + render(); + + const pctButton = screen.getByText('-1%'); + fireEvent.press(pctButton); + + // BigNumber mock returns base price (2.3418) → 5 sig figs → 4 decimals + expect(screen.getByTestId('keypad-value')).toHaveTextContent('2.3418'); + }); + }); + describe('Validation and Error States', () => { it('shows muted text style for placeholder limit price', () => { // Act diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx index 9bb97f4e39d..33a3cc9b1e3 100644 --- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx @@ -22,6 +22,7 @@ import { PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import { + DECIMAL_PRECISION_CONFIG, getPerpsDisplaySymbol, PERPS_CONSTANTS, PERPS_EVENT_PROPERTY, @@ -374,11 +375,15 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ {/* Mid price button - uses currentPrice which is the mid price from allMids stream */} { if (currentPrice) { setLimitPrice( - formatWithSignificantDigits(currentPrice, 4).value.toString(), + formatWithSignificantDigits( + currentPrice, + DECIMAL_PRECISION_CONFIG.MaxSignificantFigures, + ).value.toString(), ); setInputMethod(PERPS_EVENT_VALUE.INPUT_METHOD.PRESET); } @@ -394,6 +399,7 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ <> {/* Bid price button */} { const price = bidPrice || currentPriceData?.price; @@ -401,7 +407,7 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ setLimitPrice( formatWithSignificantDigits( parseFloat(price), - 4, + DECIMAL_PRECISION_CONFIG.MaxSignificantFigures, ).value.toString(), ); setInputMethod(PERPS_EVENT_VALUE.INPUT_METHOD.PRESET); @@ -417,6 +423,7 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ {LIMIT_PRICE_CONFIG.LongPresets.map((percentage) => ( { const calculatedPrice = @@ -425,7 +432,7 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ setLimitPrice( formatWithSignificantDigits( parseFloat(calculatedPrice), - 4, + DECIMAL_PRECISION_CONFIG.MaxSignificantFigures, ).value.toString(), ); setInputMethod( @@ -446,6 +453,7 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ <> {/* Ask price button */} { const price = askPrice || currentPriceData?.price; @@ -453,7 +461,7 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ setLimitPrice( formatWithSignificantDigits( parseFloat(price), - 4, + DECIMAL_PRECISION_CONFIG.MaxSignificantFigures, ).value.toString(), ); setInputMethod(PERPS_EVENT_VALUE.INPUT_METHOD.PRESET); @@ -469,6 +477,7 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ {LIMIT_PRICE_CONFIG.ShortPresets.map((percentage) => ( { const calculatedPrice = @@ -477,7 +486,7 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ setLimitPrice( formatWithSignificantDigits( parseFloat(calculatedPrice), - 4, + DECIMAL_PRECISION_CONFIG.MaxSignificantFigures, ).value.toString(), ); setInputMethod( diff --git a/app/components/UI/Perps/components/PerpsTransactionItem/PerpsTransactionItem.test.tsx b/app/components/UI/Perps/components/PerpsTransactionItem/PerpsTransactionItem.test.tsx index 561ea86ae71..de9b8b821c6 100644 --- a/app/components/UI/Perps/components/PerpsTransactionItem/PerpsTransactionItem.test.tsx +++ b/app/components/UI/Perps/components/PerpsTransactionItem/PerpsTransactionItem.test.tsx @@ -7,7 +7,7 @@ import { PerpsOrderTransactionStatus, PerpsOrderTransactionStatusType, } from '../../types/transactionHistory'; -import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts index 86ad423933e..8c85ba26ea6 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts @@ -976,6 +976,71 @@ describe('usePerpsHomeData', () => { }); }); + it('preserves multi-fill trades with same orderId and timestamp but different size/price', async () => { + // Simulate a SL execution split across multiple fills (same orderId + timestamp) + const multiFill1 = createMockOrderFill({ + orderId: 'sl-order-1', + symbol: 'ETH', + timestamp: 1234567890, + size: '0.3', + price: '49000', + pnl: '-15.00', + direction: 'Close Long', + }); + const multiFill2 = createMockOrderFill({ + orderId: 'sl-order-1', + symbol: 'ETH', + timestamp: 1234567890, + size: '0.2', + price: '49000', + pnl: '-10.00', + direction: 'Close Long', + }); + + // REST fills return both fills + const mockGetOrderFills = jest + .fn() + .mockResolvedValue([multiFill1, multiFill2]); + ( + Engine.context.PerpsController.getActiveProviderOrNull as jest.Mock + ).mockReturnValue({ + getOrderFills: mockGetOrderFills, + }); + + mockUsePerpsConnection.mockReturnValue({ + isConnected: true, + isInitialized: true, + isConnecting: false, + error: null, + connect: jest.fn(), + disconnect: jest.fn(), + resetError: jest.fn(), + } as never); + + // WebSocket returns same fills + mockUsePerpsLiveFills.mockReturnValue({ + fills: [multiFill1, multiFill2], + isInitialLoading: false, + }); + + const { result } = renderHook(() => + usePerpsHomeData({ activityLimit: 10 }), + ); + + await act(async () => { + // Wait for REST fills useEffect to run + }); + + // Both fills are preserved in mergedFills, then aggregated by + // transformFillsToTransactions into 1 transaction with combined size. + // With the old dedup key (orderId-timestamp), one fill was lost, + // resulting in wrong PnL and size. + const activity = result.current.recentActivity; + expect(activity.length).toBeGreaterThan(0); + // The aggregated transaction should reflect the combined 0.5 total size + expect(activity[0].fill?.size).toBe('0.5'); + }); + it('preserves detailedOrderType from REST fill when WS fill lacks it', async () => { // Arrange — REST fill has enriched detailedOrderType const restFill = createMockOrderFill({ diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.ts index 72df7f435e9..2a69843bd11 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.ts @@ -131,14 +131,14 @@ export const usePerpsHomeData = ({ // Add REST fills first for (const fill of restFills) { - const key = `${fill.orderId}-${fill.timestamp}`; + const key = `${fill.orderId}-${fill.timestamp}-${fill.size}-${fill.price}`; fillsMap.set(key, fill); } // Add live fills (overwrites duplicates from REST - live data is fresher) // Preserve detailedOrderType from REST fills since WS fills lack it for (const fill of liveFills) { - const key = `${fill.orderId}-${fill.timestamp}`; + const key = `${fill.orderId}-${fill.timestamp}-${fill.size}-${fill.price}`; const existing = fillsMap.get(key); if (existing?.detailedOrderType && !fill.detailedOrderType) { fillsMap.set(key, { diff --git a/app/components/UI/Perps/hooks/usePerpsMarketFills.test.ts b/app/components/UI/Perps/hooks/usePerpsMarketFills.test.ts index 9243a3663a2..a0125172760 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketFills.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketFills.test.ts @@ -224,19 +224,21 @@ describe('usePerpsMarketFills', () => { }); }); - it('prefers WebSocket data over REST data for duplicates', async () => { - // Arrange - same orderId+timestamp but different prices + it('prefers WebSocket data over REST data for exact duplicates', async () => { + // Arrange - identical fill in both sources (same orderId, timestamp, size, price) const restFill = createMockFill({ orderId: 'order-1', symbol: 'BTC', timestamp: 1640995200000, - price: '49000', // REST has older price + size: '0.5', + price: '50000', }); const wsFill = createMockFill({ orderId: 'order-1', symbol: 'BTC', timestamp: 1640995200000, - price: '50000', // WebSocket has fresher price + size: '0.5', + price: '50000', }); mockUsePerpsLiveFills.mockReturnValue({ @@ -250,13 +252,54 @@ describe('usePerpsMarketFills', () => { usePerpsMarketFills({ symbol: 'BTC' }), ); - // Assert - should use WebSocket price + // Assert - should deduplicate to single fill await waitFor(() => { expect(result.current.fills).toHaveLength(1); }); expect(result.current.fills[0].price).toBe('50000'); }); + it('preserves multi-fill trades with same orderId and timestamp but different size/price', async () => { + // Arrange - SL order split into 2 fills at same timestamp + const fill1 = createMockFill({ + orderId: 'sl-order-1', + symbol: 'BTC', + timestamp: 1640995200000, + size: '0.3', + price: '49000', + pnl: '-15.00', + direction: 'Close Long', + }); + const fill2 = createMockFill({ + orderId: 'sl-order-1', + symbol: 'BTC', + timestamp: 1640995200000, + size: '0.2', + price: '49000', + pnl: '-10.00', + direction: 'Close Long', + }); + + mockUsePerpsLiveFills.mockReturnValue({ + fills: [], + isInitialLoading: false, + }); + mockProvider.getOrderFills.mockResolvedValue([fill1, fill2]); + + // Act + const { result } = renderHook(() => + usePerpsMarketFills({ symbol: 'BTC' }), + ); + + // Assert - both fills should be preserved (not collapsed by dedup) + await waitFor(() => { + expect(result.current.fills).toHaveLength(2); + }); + const sizes = result.current.fills.map((f) => f.size); + expect(sizes).toContain('0.3'); + expect(sizes).toContain('0.2'); + }); + it('combines unique fills from both sources', async () => { // Arrange const wsFill = createMockFill({ diff --git a/app/components/UI/Perps/hooks/usePerpsMarketFills.ts b/app/components/UI/Perps/hooks/usePerpsMarketFills.ts index caa64409e5e..985e12ff15d 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketFills.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketFills.ts @@ -142,7 +142,7 @@ export const usePerpsMarketFills = ({ for (const fill of restFills) { // Only add fills for the requested symbol if (fill.symbol === symbol) { - const key = `${fill.orderId}-${fill.timestamp}`; + const key = `${fill.orderId}-${fill.timestamp}-${fill.size}-${fill.price}`; fillsMap.set(key, fill); } } @@ -151,7 +151,7 @@ export const usePerpsMarketFills = ({ for (const fill of liveFills) { // Only add fills for the requested symbol if (fill.symbol === symbol) { - const key = `${fill.orderId}-${fill.timestamp}`; + const key = `${fill.orderId}-${fill.timestamp}-${fill.size}-${fill.price}`; fillsMap.set(key, fill); } } diff --git a/app/components/UI/Perps/hooks/usePerpsTPSLForm.test.ts b/app/components/UI/Perps/hooks/usePerpsTPSLForm.test.ts index f70ed7714c5..50517c82134 100644 --- a/app/components/UI/Perps/hooks/usePerpsTPSLForm.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsTPSLForm.test.ts @@ -246,6 +246,33 @@ describe('usePerpsTPSLForm', () => { expect(result.current.formState.takeProfitPrice).toBe(''); }); + it('accept 6-decimal take profit price for low-price assets like PUMP', () => { + // PUMP trades at ~$0.00186 — requires 6 decimal places for valid TP prices + const pumpParams = { ...defaultParams, currentPrice: 0.00186 }; + const { result } = renderHook(() => usePerpsTPSLForm(pumpParams), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.handlers.handleTakeProfitPriceChange('0.001234'); + }); + + expect(result.current.formState.takeProfitPrice).toBe('0.001234'); + }); + + it('accept 6-decimal stop loss price for low-price assets like PUMP', () => { + const pumpParams = { ...defaultParams, currentPrice: 0.00186 }; + const { result } = renderHook(() => usePerpsTPSLForm(pumpParams), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.handlers.handleStopLossPriceChange('0.001234'); + }); + + expect(result.current.formState.stopLossPrice).toBe('0.001234'); + }); + it('calculate percentage when price is entered', () => { const { result } = renderHook(() => usePerpsTPSLForm(defaultParams), { wrapper: createWrapper(), diff --git a/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts b/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts index 2a2e759e6ef..8410dbeed5d 100644 --- a/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts @@ -335,7 +335,10 @@ export function usePerpsTPSLForm( return; if ( - hasExceededSignificantFigures(sanitized) && + hasExceededSignificantFigures( + sanitized, + DECIMAL_PRECISION_CONFIG.MaxPriceDecimals, + ) && sanitized.length >= takeProfitPrice.length ) return; @@ -407,8 +410,11 @@ export function usePerpsTPSLForm( leverage, entryPrice, }); - // Round to 5 significant figures to match input validation - const roundedPrice = roundToSignificantFigures(price.toString()); + // Round to MaxPriceDecimals significant figures to match input validation + const roundedPrice = roundToSignificantFigures( + price.toString(), + DECIMAL_PRECISION_CONFIG.MaxPriceDecimals, + ); setTakeProfitPrice(roundedPrice); setSelectedTpPercentage(roeValue); } else if (!finalValue) { @@ -442,7 +448,10 @@ export function usePerpsTPSLForm( return; if ( - hasExceededSignificantFigures(sanitized) && + hasExceededSignificantFigures( + sanitized, + DECIMAL_PRECISION_CONFIG.MaxPriceDecimals, + ) && sanitized.length >= stopLossPrice.length ) return; @@ -515,8 +524,11 @@ export function usePerpsTPSLForm( leverage, entryPrice, }); - // Round to 5 significant figures to match input validation - const roundedPrice = roundToSignificantFigures(price.toString()); + // Round to MaxPriceDecimals significant figures to match input validation + const roundedPrice = roundToSignificantFigures( + price.toString(), + DECIMAL_PRECISION_CONFIG.MaxPriceDecimals, + ); setStopLossPrice(roundedPrice); setSelectedSlPercentage(roeValue); // Store absolute value for button comparison } else if (!finalValue) { @@ -576,9 +588,10 @@ export function usePerpsTPSLForm( entryPrice, }); if (zeroRoePrice && zeroRoePrice !== takeProfitPrice) { - // Round to 5 significant figures to match input validation + // Round to MaxPriceDecimals significant figures to match input validation const roundedPrice = roundToSignificantFigures( zeroRoePrice.toString(), + DECIMAL_PRECISION_CONFIG.MaxPriceDecimals, ); setTakeProfitPrice(roundedPrice); } @@ -615,8 +628,11 @@ export function usePerpsTPSLForm( leverage, entryPrice, }); - // Round to 5 significant figures to match input validation - const roundedPrice = roundToSignificantFigures(price.toString()); + // Round to MaxPriceDecimals significant figures to match input validation + const roundedPrice = roundToSignificantFigures( + price.toString(), + DECIMAL_PRECISION_CONFIG.MaxPriceDecimals, + ); setTakeProfitPrice(roundedPrice); } }, [ @@ -667,9 +683,10 @@ export function usePerpsTPSLForm( entryPrice, }); if (zeroRoePrice && zeroRoePrice !== stopLossPrice) { - // Round to 5 significant figures to match input validation + // Round to MaxPriceDecimals significant figures to match input validation const roundedPrice = roundToSignificantFigures( zeroRoePrice.toString(), + DECIMAL_PRECISION_CONFIG.MaxPriceDecimals, ); setStopLossPrice(roundedPrice); } @@ -706,8 +723,11 @@ export function usePerpsTPSLForm( leverage, entryPrice, }); - // Round to 5 significant figures to match input validation - const roundedPrice = roundToSignificantFigures(price.toString()); + // Round to MaxPriceDecimals significant figures to match input validation + const roundedPrice = roundToSignificantFigures( + price.toString(), + DECIMAL_PRECISION_CONFIG.MaxPriceDecimals, + ); setStopLossPrice(roundedPrice); } }, [stopLossPercentage, leverage, currentPrice, actualDirection, entryPrice]); @@ -740,8 +760,11 @@ export function usePerpsTPSLForm( // Only set values if we got a valid price if (price && price !== '' && Number.parseFloat(price) > 0) { - // Round to 5 significant figures to match input validation - const roundedPrice = roundToSignificantFigures(price.toString()); + // Round to MaxPriceDecimals significant figures to match input validation + const roundedPrice = roundToSignificantFigures( + price.toString(), + DECIMAL_PRECISION_CONFIG.MaxPriceDecimals, + ); const formattedPriceString = formatPerpsFiat(roundedPrice, { ranges: PRICE_RANGES_UNIVERSAL, }); @@ -794,8 +817,11 @@ export function usePerpsTPSLForm( // Only set values if we got a valid price if (price && price !== '' && Number.parseFloat(price) > 0) { - // Round to 5 significant figures to match input validation - const roundedPrice = roundToSignificantFigures(price.toString()); + // Round to MaxPriceDecimals significant figures to match input validation + const roundedPrice = roundToSignificantFigures( + price.toString(), + DECIMAL_PRECISION_CONFIG.MaxPriceDecimals, + ); const formattedPriceString = formatPerpsFiat(roundedPrice, { ranges: PRICE_RANGES_UNIVERSAL, }); diff --git a/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.test.tsx b/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.test.tsx index ca6acbdeb7a..6bb877f8785 100644 --- a/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.test.tsx +++ b/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.test.tsx @@ -7,6 +7,8 @@ import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { PREDICT_GTM_MODAL_SHOWN } from '../../../../../constants/storage'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../util/test/initial-root-state'; +import { useAnalytics } from '../../../../../components/hooks/useAnalytics/useAnalytics'; +import { createMockUseAnalyticsHook } from '../../../../../util/test/analyticsMock'; jest.mock('../../../../../util/theme', () => { const { mockTheme } = jest.requireActual('../../../../../util/theme'); @@ -41,12 +43,7 @@ const mockCreateEventBuilder = jest.fn().mockReturnValue({ addProperties: jest.fn().mockReturnThis(), build: jest.fn().mockReturnValue({}), }); -jest.mock('../../../../../components/hooks/useMetrics', () => ({ - useMetrics: () => ({ - trackEvent: mockTrackEvent, - createEventBuilder: mockCreateEventBuilder, - }), -})); +jest.mock('../../../../../components/hooks/useAnalytics/useAnalytics'); jest.mock('../../../../../util/metrics', () => ({ __esModule: true, @@ -65,7 +62,13 @@ const initialState = { describe('PredictGTMModal', () => { beforeEach(() => { jest.clearAllMocks(); - (StorageWrapper.getItem as jest.Mock).mockResolvedValue('false'); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + ); + jest.mocked(StorageWrapper.getItem).mockResolvedValue('false'); }); it('renders correctly with all main elements', async () => { diff --git a/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.tsx b/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.tsx index 85e664582f9..fe31f0e12aa 100644 --- a/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.tsx +++ b/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.tsx @@ -17,7 +17,7 @@ import Button, { import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; -import { useMetrics } from '../../../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../../../components/hooks/useAnalytics/useAnalytics'; import Routes from '../../../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import PredictMarketingImage from '../../../../../images/predict-marketing.png'; @@ -36,7 +36,7 @@ import { import { PREDICT_GTM_MODAL_TEST_IDS } from './PredictGTMModal.testIds'; const PredictGTMModal = () => { - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const { navigate } = useNavigation(); const theme = useTheme(); const [imageLoaded, setImageLoaded] = useState(false); diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 149d674852f..2ef87af077a 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -1661,6 +1661,9 @@ export class PredictController extends BaseController< state.pendingClaims[signer.address] = 'pending'; }); + // Invalidate query cache (to avoid nonce issues) + await this.invalidateQueryCache(provider.chainId); + // Prepare claim transaction - can fail if safe address not found, signing fails, etc. const prepareClaimResult = await provider.prepareClaim({ positions: claimablePositions, @@ -2716,6 +2719,9 @@ export class PredictController extends BaseController< numberToHex(chainId), ); + // Invalidate query cache (to avoid nonce issues) + await this.invalidateQueryCache(chainId); + const { callData, amount } = await provider.signWithdraw({ callData: withdrawTransaction?.data as Hex, signer, diff --git a/app/components/UI/Predict/hooks/usePredictActivity.test.ts b/app/components/UI/Predict/hooks/usePredictActivity.test.ts index c154ca76af7..14bcd85ef90 100644 --- a/app/components/UI/Predict/hooks/usePredictActivity.test.ts +++ b/app/components/UI/Predict/hooks/usePredictActivity.test.ts @@ -41,7 +41,7 @@ jest.mock('react-redux', () => ({ const createWrapper = () => { const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + defaultOptions: { queries: { retry: false, cacheTime: Infinity } }, }); const Wrapper = ({ children }: { children: React.ReactNode }) => diff --git a/app/components/UI/Predict/hooks/usePredictPositions.test.ts b/app/components/UI/Predict/hooks/usePredictPositions.test.ts index 0b334885b30..084ae652829 100644 --- a/app/components/UI/Predict/hooks/usePredictPositions.test.ts +++ b/app/components/UI/Predict/hooks/usePredictPositions.test.ts @@ -83,7 +83,7 @@ const createPosition = ( const createWrapper = () => { const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + defaultOptions: { queries: { retry: false, cacheTime: Infinity } }, }); const Wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); @@ -219,7 +219,7 @@ describe('usePredictPositions', () => { expect(mockGetPositions).not.toHaveBeenCalled(); expect(result.current.data).toBeUndefined(); - expect(result.current.isLoading).toBe(false); + expect(result.current.isFetching).toBe(false); }); it('returns query error message when query fails', async () => { diff --git a/app/components/UI/Predict/hooks/useUnrealizedPnL.test.tsx b/app/components/UI/Predict/hooks/useUnrealizedPnL.test.tsx index 2bc2f950ad4..7a752c23c70 100644 --- a/app/components/UI/Predict/hooks/useUnrealizedPnL.test.tsx +++ b/app/components/UI/Predict/hooks/useUnrealizedPnL.test.tsx @@ -66,7 +66,7 @@ describe('useUnrealizedPnL', () => { }); expect(result.current.data).toBeUndefined(); - expect(result.current.isLoading).toBe(false); + expect(result.current.isFetching).toBe(false); expect(result.current.error).toBeNull(); expect(mockGetUnrealizedPnL).not.toHaveBeenCalled(); }); diff --git a/app/components/UI/Predict/queries/accountState.ts b/app/components/UI/Predict/queries/accountState.ts index 2163c6a9603..56b3668b71b 100644 --- a/app/components/UI/Predict/queries/accountState.ts +++ b/app/components/UI/Predict/queries/accountState.ts @@ -91,7 +91,7 @@ export const predictAccountStateKeys = { }; export const predictAccountStateOptions = () => - queryOptions({ + queryOptions({ queryKey: predictAccountStateKeys.all(), queryFn: async (): Promise => { await ensurePolygonNetwork(); diff --git a/app/components/UI/Predict/queries/activity.ts b/app/components/UI/Predict/queries/activity.ts index 6f4b305bda7..fafe3aabb6c 100644 --- a/app/components/UI/Predict/queries/activity.ts +++ b/app/components/UI/Predict/queries/activity.ts @@ -9,7 +9,7 @@ export const predictActivityKeys = { }; export const predictActivityOptions = ({ address }: { address: string }) => - queryOptions({ + queryOptions({ queryKey: predictActivityKeys.byAddress(address), queryFn: async (): Promise => Engine.context.PredictController.getActivity({ address }), diff --git a/app/components/UI/Predict/queries/balance.ts b/app/components/UI/Predict/queries/balance.ts index d401ae6cecc..dae19051478 100644 --- a/app/components/UI/Predict/queries/balance.ts +++ b/app/components/UI/Predict/queries/balance.ts @@ -15,7 +15,7 @@ export const predictBalanceKeys = { }; export const predictBalanceOptions = ({ address = '' }: GetBalanceParams) => - queryOptions({ + queryOptions({ queryKey: predictBalanceKeys.detail(address), queryFn: async (): Promise => { const balance = await Engine.context.PredictController.getBalance({ diff --git a/app/components/UI/Predict/queries/market.ts b/app/components/UI/Predict/queries/market.ts index 6df69c9239a..1370963d014 100644 --- a/app/components/UI/Predict/queries/market.ts +++ b/app/components/UI/Predict/queries/market.ts @@ -14,7 +14,7 @@ export const predictMarketKeys = { }; export const predictMarketOptions = ({ marketId }: { marketId: string }) => - queryOptions({ + queryOptions({ queryKey: predictMarketKeys.detail(marketId), queryFn: async (): Promise => { const controller = Engine.context.PredictController; diff --git a/app/components/UI/Predict/queries/orderPreview.ts b/app/components/UI/Predict/queries/orderPreview.ts index bd3cf70dcaf..b33f64f6520 100644 --- a/app/components/UI/Predict/queries/orderPreview.ts +++ b/app/components/UI/Predict/queries/orderPreview.ts @@ -1,4 +1,4 @@ -import { queryOptions, keepPreviousData } from '@tanstack/react-query'; +import { queryOptions } from '@tanstack/react-query'; import Engine from '../../../../core/Engine'; import type { OrderPreview, PreviewOrderParams } from '../types'; @@ -24,7 +24,7 @@ export const predictOrderPreviewOptions = ({ size, positionId, }: PreviewOrderParams) => - queryOptions({ + queryOptions({ queryKey: predictOrderPreviewKeys.detail({ marketId, outcomeId, @@ -43,5 +43,5 @@ export const predictOrderPreviewOptions = ({ positionId, }), retry: false, - placeholderData: keepPreviousData, + keepPreviousData: true, }); diff --git a/app/components/UI/Predict/queries/positions.ts b/app/components/UI/Predict/queries/positions.ts index 058d7788367..d6d618975e6 100644 --- a/app/components/UI/Predict/queries/positions.ts +++ b/app/components/UI/Predict/queries/positions.ts @@ -9,7 +9,7 @@ export const predictPositionsKeys = { }; export const predictPositionsOptions = ({ address }: { address: string }) => - queryOptions({ + queryOptions({ queryKey: predictPositionsKeys.byAddress(address), queryFn: async (): Promise => Engine.context.PredictController.getPositions({ address }), diff --git a/app/components/UI/Predict/queries/unrealizedPnL.ts b/app/components/UI/Predict/queries/unrealizedPnL.ts index 98f02cb25d3..b71637276a4 100644 --- a/app/components/UI/Predict/queries/unrealizedPnL.ts +++ b/app/components/UI/Predict/queries/unrealizedPnL.ts @@ -10,7 +10,7 @@ export const predictUnrealizedPnLKeys = { }; export const predictUnrealizedPnLOptions = ({ address }: { address: string }) => - queryOptions({ + queryOptions({ queryKey: predictUnrealizedPnLKeys.byAddress(address), queryFn: async (): Promise => { const result = await Engine.context.PredictController.getUnrealizedPnL({ diff --git a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts index 432b76a11c0..06949517b0a 100644 --- a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts +++ b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts @@ -100,7 +100,7 @@ export function useRampsPaymentMethods(): UseRampsPaymentMethodsResult { if (!queryEnabled) { return 'idle'; } - if (paymentMethodsQuery.isPending) { + if (paymentMethodsQuery.isLoading) { return 'loading'; } if (paymentMethodsQuery.isError) { @@ -109,7 +109,7 @@ export function useRampsPaymentMethods(): UseRampsPaymentMethodsResult { return 'success'; }, [ paymentMethodsQuery.isError, - paymentMethodsQuery.isPending, + paymentMethodsQuery.isLoading, queryEnabled, ]); diff --git a/app/components/UI/Ramp/hooks/useRampsQuotes.ts b/app/components/UI/Ramp/hooks/useRampsQuotes.ts index dfed6f18d40..00168a16405 100644 --- a/app/components/UI/Ramp/hooks/useRampsQuotes.ts +++ b/app/components/UI/Ramp/hooks/useRampsQuotes.ts @@ -67,14 +67,14 @@ export function useRampsQuotes( if (!queryEnabled) { return 'idle'; } - if (quotesQuery.isPending) { + if (quotesQuery.isLoading) { return 'loading'; } if (quotesQuery.isError) { return 'error'; } return 'success'; - }, [queryEnabled, quotesQuery.isError, quotesQuery.isPending]); + }, [queryEnabled, quotesQuery.isError, quotesQuery.isLoading]); return { getQuotes, diff --git a/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx b/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx index 68cded2bef7..b3dd730745e 100644 --- a/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx +++ b/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx @@ -187,7 +187,7 @@ describe('CampaignMechanicsView', () => { howItWorks: { title: 'How it works', description: 'Earn rewards', - phases: [], + steps: [], }, }, }), @@ -244,7 +244,7 @@ describe('CampaignMechanicsView', () => { howItWorks: { title: 'How it works', description: 'Earn rewards', - phases: [], + steps: [], notes: richTextNotes, }, }, @@ -266,7 +266,7 @@ describe('CampaignMechanicsView', () => { howItWorks: { title: 'How it works', description: 'Earn rewards', - phases: [], + steps: [], notes: null, }, }, @@ -288,7 +288,7 @@ describe('CampaignMechanicsView', () => { howItWorks: { title: 'How it works', description: 'Earn rewards', - phases: [], + steps: [], }, }, }), @@ -309,7 +309,7 @@ describe('CampaignMechanicsView', () => { howItWorks: { title: 'How it works', description: 'Earn rewards', - phases: [], + steps: [], notes: { title: 'Only title' }, }, }, @@ -331,7 +331,7 @@ describe('CampaignMechanicsView', () => { howItWorks: { title: 'How it works', description: 'Earn rewards', - phases: [], + steps: [], notes: 'just a string', }, }, diff --git a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx index ea1e9b88bfd..468fffa0a09 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx @@ -132,14 +132,29 @@ jest.mock('../components/Campaigns/OndoLeaderboard', () => { }; }); +const mockOndoLeaderboardPosition = jest.fn(); jest.mock('../components/Campaigns/OndoLeaderboardPosition', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: Record) => { + mockOndoLeaderboardPosition(props); + return ReactActual.createElement(View, { + testID: 'ondo-leaderboard-position', + }); + }, + }; +}); + +jest.mock('../components/Campaigns/OndoPortfolio', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { __esModule: true, default: () => ReactActual.createElement(View, { - testID: 'ondo-leaderboard-position', + testID: 'ondo-campaign-portfolio', }), }; }); @@ -254,6 +269,7 @@ const hookDefaults = { describe('OndoCampaignDetailsView', () => { beforeEach(() => { jest.clearAllMocks(); + mockOndoLeaderboardPosition.mockReset(); mockUseRewardCampaigns.mockReturnValue(hookDefaults); mockUseGetCampaignParticipantStatus.mockReturnValue({ status: null, @@ -265,6 +281,7 @@ describe('OndoCampaignDetailsView', () => { leaderboard: null, isLoading: false, hasError: false, + isLeaderboardNotYetComputed: false, tierNames: [], selectedTier: null, selectedTierData: null, @@ -358,7 +375,7 @@ describe('OndoCampaignDetailsView', () => { howItWorks: { title: 'How it works', description: 'Description', - phases: [], + steps: [], }, }, }), diff --git a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx index 1c62b6281d7..3f1cc8eba23 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx @@ -5,6 +5,7 @@ import { Box, BoxAlignItems, BoxFlexDirection, + BoxJustifyContent, Icon, IconName, IconSize, @@ -20,6 +21,7 @@ import CampaignStatus from '../components/Campaigns/CampaignStatus'; import CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks'; import OndoLeaderboard from '../components/Campaigns/OndoLeaderboard'; import OndoLeaderboardPosition from '../components/Campaigns/OndoLeaderboardPosition'; +import OndoPortfolio from '../components/Campaigns/OndoPortfolio'; import CampaignJoinCTA from '../components/Campaigns/CampaignJoinCTA'; import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; @@ -82,6 +84,7 @@ const OndoCampaignDetailsView: React.FC = () => { setSelectedTier, isLoading: isLeaderboardLoading, hasError: hasLeaderboardError, + isLeaderboardNotYetComputed, refetch: refetchLeaderboard, } = useGetOndoLeaderboard(leaderboardCampaignId); @@ -160,48 +163,74 @@ const OndoCampaignDetailsView: React.FC = () => { {(isOptedIn || Boolean(leaderboardCampaignId)) && ( <> - - {isOptedIn && ( - - navigation.navigate( - Routes.REWARDS_ONDO_CAMPAIGN_LEADERBOARD, - { campaignId }, - ) - } - > - - - {strings('rewards.ondo_campaign_leaderboard.title')} - - - - - )} + {isOptedIn ? ( - + <> + + navigation.navigate( + Routes.REWARDS_ONDO_CAMPAIGN_LEADERBOARD as never, + { campaignId }, + ) + } + > + + + + {strings( + 'rewards.ondo_campaign_leaderboard.title', + )} + + + + + + + ) : ( - + <> + + )} )} + + {isOptedIn && ( + <> + + + + + + )} )} diff --git a/app/components/UI/Rewards/Views/OndoLeaderboardView.test.tsx b/app/components/UI/Rewards/Views/OndoLeaderboardView.test.tsx index eb47e6eff84..96d30746f39 100644 --- a/app/components/UI/Rewards/Views/OndoLeaderboardView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoLeaderboardView.test.tsx @@ -115,9 +115,10 @@ const hookDefaults = { leaderboard: null, isLoading: false, hasError: false, + isLeaderboardNotYetComputed: false, tierNames: ['STARTER', 'MID'], selectedTier: 'STARTER', - selectedTierData: { entries: [], total_participants: 10 }, + selectedTierData: { entries: [], totalParticipants: 10 }, computedAt: '2024-03-20T12:00:00.000Z', setSelectedTier: jest.fn(), refetch: jest.fn(), @@ -178,4 +179,13 @@ describe('OndoLeaderboardView', () => { const { getByTestId } = render(); expect(getByTestId(ONDO_LEADERBOARD_VIEW_TEST_IDS.CONTAINER)).toBeDefined(); }); + + it('renders when leaderboard is not yet computed', () => { + mockUseGetOndoLeaderboard.mockReturnValue({ + ...hookDefaults, + isLeaderboardNotYetComputed: true, + }); + const { getByTestId } = render(); + expect(getByTestId(ONDO_LEADERBOARD_VIEW_TEST_IDS.CONTAINER)).toBeDefined(); + }); }); diff --git a/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx index 81dacdeded2..5c03fa5c613 100644 --- a/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx +++ b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx @@ -36,6 +36,7 @@ const OndoLeaderboardView: React.FC = () => { setSelectedTier, isLoading: isLeaderboardLoading, hasError: hasLeaderboardError, + isLeaderboardNotYetComputed, refetch: refetchLeaderboard, } = useGetOndoLeaderboard(campaignId); @@ -70,10 +71,11 @@ const OndoLeaderboardView: React.FC = () => { selectedTier={selectedTier} onTierChange={setSelectedTier} entries={selectedTierData?.entries ?? []} - totalParticipants={selectedTierData?.total_participants ?? 0} + totalParticipants={selectedTierData?.totalParticipants ?? 0} computedAt={computedAt} isLoading={isLeaderboardLoading} hasError={hasLeaderboardError} + isLeaderboardNotYetComputed={isLeaderboardNotYetComputed} onRetry={refetchLeaderboard} /> diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.test.tsx index 7a28eb08707..27c6944a4e7 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.test.tsx @@ -40,18 +40,11 @@ const createHowItWorks = ( ): OndoCampaignHowItWorks => ({ title: 'How it works', description: 'Hold tokens to earn rewards', - phases: [ + steps: [ { - name: 'Phase 1', - daysLabel: 'Days 1-30', - sortOrder: 1, - steps: [ - { - iconName: 'star', - title: 'Step 1', - description: 'Do step 1', - }, - ], + iconName: 'star', + title: 'Step 1', + description: 'Do step 1', }, ], ...overrides, @@ -74,96 +67,44 @@ describe('CampaignHowItWorks', () => { ); }); - it('renders a phase chip with daysLabel', () => { - const { getByTestId } = render( - , - ); - expect( - getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE_CHIP}-0`), - ).toHaveTextContent('Days 1-30'); - }); - it('renders a step title and description', () => { const { getByTestId } = render( , ); expect( - getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_TITLE}-0-0`), + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_TITLE}-0`), ).toHaveTextContent('Step 1'); expect( - getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_DESCRIPTION}-0-0`), + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_DESCRIPTION}-0`), ).toHaveTextContent('Do step 1'); }); - it('sorts phases by sortOrder ascending', () => { + it('renders multiple steps', () => { const howItWorks = createHowItWorks({ - phases: [ - { name: 'Phase B', daysLabel: 'Days 31-60', sortOrder: 2, steps: [] }, - { name: 'Phase A', daysLabel: 'Days 1-30', sortOrder: 1, steps: [] }, - ], - }); - const { getByTestId } = render( - , - ); - expect( - getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE_CHIP}-0`), - ).toHaveTextContent('Days 1-30'); - expect( - getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE_CHIP}-1`), - ).toHaveTextContent('Days 31-60'); - }); - - it('renders multiple phases', () => { - const howItWorks = createHowItWorks({ - phases: [ - { name: 'Phase 1', daysLabel: 'Days 1-30', sortOrder: 1, steps: [] }, - { name: 'Phase 2', daysLabel: 'Days 31-60', sortOrder: 2, steps: [] }, - ], - }); - const { getByTestId } = render( - , - ); - expect( - getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE}-0`), - ).toBeDefined(); - expect( - getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE}-1`), - ).toBeDefined(); - }); - - it('renders multiple steps in a phase', () => { - const howItWorks = createHowItWorks({ - phases: [ - { - name: 'Phase 1', - daysLabel: 'Days 1-30', - sortOrder: 1, - steps: [ - { iconName: 'star', title: 'Step A', description: 'Desc A' }, - { iconName: 'circle', title: 'Step B', description: 'Desc B' }, - ], - }, + steps: [ + { iconName: 'star', title: 'Step A', description: 'Desc A' }, + { iconName: 'circle', title: 'Step B', description: 'Desc B' }, ], }); const { getByTestId } = render( , ); expect( - getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_TITLE}-0-0`), + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_TITLE}-0`), ).toHaveTextContent('Step A'); expect( - getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_TITLE}-0-1`), + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_TITLE}-1`), ).toHaveTextContent('Step B'); }); - it('renders gracefully with no phases', () => { - const howItWorks = createHowItWorks({ phases: [] }); + it('renders gracefully with no steps', () => { + const howItWorks = createHowItWorks({ steps: [] }); const { getByTestId, queryByTestId } = render( , ); expect(getByTestId(CAMPAIGN_HOW_IT_WORKS_TEST_IDS.CONTAINER)).toBeDefined(); expect( - queryByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE}-0`), + queryByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP}-0`), ).toBeNull(); }); @@ -172,7 +113,7 @@ describe('CampaignHowItWorks', () => { , ); expect( - getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_ICON}-0-0`), + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_ICON}-0`), ).toBeDefined(); }); }); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.tsx index bc68a1957fa..ba64193ae63 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { Box, BoxFlexDirection, @@ -10,18 +10,13 @@ import { IconSize, FontWeight, } from '@metamask/design-system-react-native'; -import type { - OndoCampaignHowItWorks, - OndoCampaignPhase, -} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import type { OndoCampaignHowItWorks } from '../../../../../core/Engine/controllers/rewards-controller/types'; import { strings } from '../../../../../../locales/i18n'; import { getIconName } from '../../utils/formatUtils'; export const CAMPAIGN_HOW_IT_WORKS_TEST_IDS = { CONTAINER: 'campaign-how-it-works-container', TITLE: 'campaign-how-it-works-title', - PHASE: 'campaign-how-it-works-phase', - PHASE_CHIP: 'campaign-how-it-works-phase-chip', STEP: 'campaign-how-it-works-step', STEP_ICON: 'campaign-how-it-works-step-icon', STEP_TITLE: 'campaign-how-it-works-step-title', @@ -34,76 +29,51 @@ interface CampaignHowItWorksProps { const CampaignHowItWorks: React.FC = ({ howItWorks, -}) => { - const sortedPhases = useMemo( - () => [...howItWorks.phases].sort((a, b) => a.sortOrder - b.sortOrder), - [howItWorks.phases], - ); +}) => ( + + + {strings('rewards.campaign_details.how_it_works')} + - return ( - - ( + - {strings('rewards.campaign_details.how_it_works')} - - - {sortedPhases.map((phase: OndoCampaignPhase, phaseIndex: number) => ( - - - - {phase.daysLabel} - - - - {phase.steps.map((step, stepIndex) => ( - - - - - - - {step.title} - - - {step.description} - - - - ))} + + + + + + {step.title} + + + {step.description} + - ))} - - ); -}; + + ))} + +); export default CampaignHowItWorks; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx index 97f91b67531..22dbf07392b 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx @@ -195,7 +195,7 @@ describe('CampaignTile', () => { it('does not render enter-now when status is upcoming', () => { (getCampaignStatusInfo as jest.Mock).mockReturnValue({ status: 'upcoming', - statusLabel: 'Up next', + statusLabel: 'Coming soon', dateLabel: 'Starts June 1', dateLabelIcon: 'Speed', }); @@ -320,6 +320,72 @@ describe('CampaignTile', () => { }); }); + describe('participant status hook call conditions', () => { + it('calls hook with campaign.id when campaign is active and ONDO_HOLDING type', () => { + const campaign = createTestCampaign({ + id: 'ondo-active', + type: CampaignType.ONDO_HOLDING, + }); + + render(); + + expect(mockUseGetCampaignParticipantStatus).toHaveBeenCalledWith( + 'ondo-active', + ); + }); + + it('calls hook with undefined when campaign is upcoming', () => { + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Coming soon', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + const campaign = createTestCampaign({ + id: 'ondo-upcoming', + type: CampaignType.ONDO_HOLDING, + }); + + render(); + + expect(mockUseGetCampaignParticipantStatus).toHaveBeenCalledWith( + undefined, + ); + }); + + it('calls hook with undefined when campaign is complete', () => { + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'complete', + statusLabel: 'Complete', + dateLabel: 'December 31', + dateLabelIcon: 'Confirmation', + }); + const campaign = createTestCampaign({ + id: 'ondo-complete', + type: CampaignType.ONDO_HOLDING, + }); + + render(); + + expect(mockUseGetCampaignParticipantStatus).toHaveBeenCalledWith( + undefined, + ); + }); + + it('calls hook with undefined when campaign is active but not ONDO_HOLDING type', () => { + const campaign = createTestCampaign({ + id: 'season-active', + type: CampaignType.SEASON_1, + }); + + render(); + + expect(mockUseGetCampaignParticipantStatus).toHaveBeenCalledWith( + undefined, + ); + }); + }); + describe('navigation', () => { it('navigates to Ondo campaign details for ONDO_HOLDING type', () => { const campaign = createTestCampaign({ @@ -401,7 +467,7 @@ describe('CampaignTile', () => { it('does not navigate for any campaign type when status is upcoming', () => { (getCampaignStatusInfo as jest.Mock).mockReturnValue({ status: 'upcoming', - statusLabel: 'Up next', + statusLabel: 'Coming soon', dateLabel: 'Starts June 1', dateLabelIcon: 'Speed', }); @@ -419,7 +485,7 @@ describe('CampaignTile', () => { it('does not call onPress for any campaign type when status is upcoming', () => { (getCampaignStatusInfo as jest.Mock).mockReturnValue({ status: 'upcoming', - statusLabel: 'Up next', + statusLabel: 'Coming soon', dateLabel: 'Starts June 1', dateLabelIcon: 'Speed', }); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx index e3490ff7ff0..259a98d8fd0 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx @@ -53,10 +53,6 @@ const CampaignTile: React.FC = ({ campaign, onPress }) => { const colorScheme = useColorScheme(); const navigation = useNavigation(); - const { status: participantStatus } = useGetCampaignParticipantStatus( - campaign.id, - ); - const participantCount = useSelector( selectCampaignParticipantCount(campaign.id), ); @@ -68,6 +64,12 @@ const CampaignTile: React.FC = ({ campaign, onPress }) => { dateLabelIcon, */ } = useMemo(() => getCampaignStatusInfo(campaign), [campaign]); + const { status: participantStatus } = useGetCampaignParticipantStatus( + campaignStatus === 'active' && campaign.type === CampaignType.ONDO_HOLDING + ? campaign.id + : undefined, + ); + const isInteractive = campaignStatus !== 'upcoming' && (onPress != null || isCampaignTypeSupported(campaign.type)); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts index abec4ec03e0..9d4e5c875b8 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts @@ -193,7 +193,7 @@ describe('CampaignTile.utils', () => { }); describe('getCampaignPillLabel', () => { - it('returns pill_up_next for upcoming status', () => { + it('returns pill_up_next (Coming soon) for upcoming status', () => { const result = getCampaignPillLabel('upcoming'); expect(strings).toHaveBeenCalledWith('rewards.campaign.pill_up_next'); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.test.tsx index 16783667db5..74ffaaf730d 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.test.tsx @@ -110,8 +110,8 @@ const createMockEntry = ( overrides: Partial = {}, ): CampaignLeaderboardEntry => ({ rank: 1, - referral_code: 'ABC123', - rate_of_return: 0.15, + referralCode: 'ABC123', + rateOfReturn: 0.15, ...overrides, }); @@ -120,12 +120,12 @@ const defaultProps = { selectedTier: 'STARTER', onTierChange: jest.fn(), entries: [ - createMockEntry({ rank: 1, referral_code: 'AAA111', rate_of_return: 0.2 }), - createMockEntry({ rank: 2, referral_code: 'BBB222', rate_of_return: 0.15 }), + createMockEntry({ rank: 1, referralCode: 'AAA111', rateOfReturn: 0.2 }), + createMockEntry({ rank: 2, referralCode: 'BBB222', rateOfReturn: 0.15 }), createMockEntry({ rank: 3, - referral_code: 'CCC333', - rate_of_return: -0.05, + referralCode: 'CCC333', + rateOfReturn: -0.05, }), ], totalParticipants: 150, @@ -193,6 +193,39 @@ describe('OndoLeaderboard', () => { }); }); + describe('not yet computed state', () => { + it('renders info banner when leaderboard not yet computed and no data', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.NOT_YET_COMPUTED), + ).toBeDefined(); + }); + + it('does not render info banner when still loading', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(CAMPAIGN_LEADERBOARD_TEST_IDS.NOT_YET_COMPUTED), + ).toBeNull(); + }); + }); + describe('empty state', () => { it('renders empty state when no tier names', () => { const { getByTestId } = render( @@ -337,7 +370,7 @@ describe('OndoLeaderboard', () => { describe('rate of return formatting', () => { it('formats positive rate of return with plus sign', () => { - const entries = [createMockEntry({ rank: 1, rate_of_return: 0.1523 })]; + const entries = [createMockEntry({ rank: 1, rateOfReturn: 0.1523 })]; const { getByText } = render( , ); @@ -346,7 +379,7 @@ describe('OndoLeaderboard', () => { }); it('formats negative rate of return without plus sign', () => { - const entries = [createMockEntry({ rank: 1, rate_of_return: -0.0832 })]; + const entries = [createMockEntry({ rank: 1, rateOfReturn: -0.0832 })]; const { getByText } = render( , ); @@ -355,7 +388,7 @@ describe('OndoLeaderboard', () => { }); it('formats zero rate of return with plus sign', () => { - const entries = [createMockEntry({ rank: 1, rate_of_return: 0 })]; + const entries = [createMockEntry({ rank: 1, rateOfReturn: 0 })]; const { getByText } = render( , ); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx index 31d508ebb02..06e717d0896 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx @@ -16,6 +16,7 @@ import TabsBar from '../../../../../component-library/components-temp/Tabs/TabsB import type { CampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types'; import { strings } from '../../../../../../locales/i18n'; import RewardsErrorBanner from '../RewardsErrorBanner'; +import RewardsInfoBanner from '../RewardsInfoBanner'; import { formatRateOfReturn, formatComputedAt } from './OndoLeaderboard.utils'; const ListSeparator = () => ; @@ -29,6 +30,7 @@ export const CAMPAIGN_LEADERBOARD_TEST_IDS = { LOADING: 'campaign-leaderboard-loading', ERROR: 'campaign-leaderboard-error', EMPTY: 'campaign-leaderboard-empty', + NOT_YET_COMPUTED: 'campaign-leaderboard-not-yet-computed', } as const; interface CampaignLeaderboardProps { @@ -40,6 +42,7 @@ interface CampaignLeaderboardProps { computedAt: string | null; isLoading: boolean; hasError: boolean; + isLeaderboardNotYetComputed?: boolean; onRetry?: () => void; currentUserReferralCode?: string | null; } @@ -76,19 +79,19 @@ const LeaderboardEntryRow: React.FC<{ fontWeight={isCurrentUser ? FontWeight.Bold : undefined} color={isCurrentUser ? TextColor.SuccessDefault : undefined} > - {entry.referral_code} + {entry.referralCode} = 0 + entry.rateOfReturn >= 0 ? TextColor.SuccessDefault : TextColor.ErrorDefault } > - {formatRateOfReturn(entry.rate_of_return)} + {formatRateOfReturn(entry.rateOfReturn)} ); @@ -165,6 +168,7 @@ const OndoLeaderboard: React.FC = ({ computedAt, isLoading, hasError, + isLeaderboardNotYetComputed = false, onRetry, currentUserReferralCode, }) => { @@ -187,13 +191,13 @@ const OndoLeaderboard: React.FC = ({ entry={item} isCurrentUser={ !!currentUserReferralCode && - item.referral_code === currentUserReferralCode + item.referralCode === currentUserReferralCode } /> ); const keyExtractor = (item: CampaignLeaderboardEntry) => - `${item.rank}-${item.referral_code}`; + `${item.rank}-${item.referralCode}`; if (isLoading && entries.length === 0) { return ; @@ -213,20 +217,26 @@ const OndoLeaderboard: React.FC = ({ ); } + if (isLeaderboardNotYetComputed && !isLoading && entries.length === 0) { + return ( + } + description={strings( + 'rewards.ondo_campaign_leaderboard.not_yet_computed', + )} + testID={CAMPAIGN_LEADERBOARD_TEST_IDS.NOT_YET_COMPUTED} + /> + ); + } + if (tierNames.length === 0) { return ( - } + description={strings('rewards.ondo_campaign_leaderboard.no_data')} + showInfoIcon testID={CAMPAIGN_LEADERBOARD_TEST_IDS.EMPTY} - > - - {strings('rewards.ondo_campaign_leaderboard.no_data')} - - + /> ); } @@ -242,9 +252,9 @@ const OndoLeaderboard: React.FC = ({ {strings('rewards.ondo_campaign_leaderboard.title')} - {computedAt && ( + {computedAt ? ( @@ -252,7 +262,7 @@ const OndoLeaderboard: React.FC = ({ time: formatComputedAt(computedAt), })} - )} + ) : null} {/* Tier selector */} diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts index fe6109a9a4e..6c9237b8033 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts @@ -52,12 +52,7 @@ describe('OndoLeaderboard.utils', () => { }); it('returns empty string for an unparseable value', () => { - // new Date('not-a-date').toLocaleTimeString() returns 'Invalid Date', - // but our function catches and returns '' only on thrown errors. - // For invalid dates, toLocaleTimeString may return 'Invalid Date' without throwing. - // We only assert it does not throw and returns a string. - const result = formatComputedAt('not-a-date'); - expect(typeof result).toBe('string'); + expect(formatComputedAt('not-a-date')).toBe(''); }); }); }); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts index d31fe607f94..d4b03305dd1 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts @@ -13,14 +13,10 @@ export const formatRateOfReturn = (rate: number): string => { */ export const formatComputedAt = (isoString: string | null): string => { if (!isoString) return ''; - try { - const date = new Date(isoString); - return date.toLocaleTimeString(undefined, { - hour: '2-digit', - minute: '2-digit', - timeZoneName: 'shortOffset', - }); - } catch { - return ''; - } + const date = new Date(isoString); + if (isNaN(date.getTime())) return ''; + const h = date.getHours().toString().padStart(2, '0'); + const m = date.getMinutes().toString().padStart(2, '0'); + const s = date.getSeconds().toString().padStart(2, '0'); + return `${h}:${m}:${s}`; }; diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.test.tsx index b3a6402a1d4..337b234ca05 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.test.tsx @@ -105,7 +105,6 @@ jest.mock('../../../../../../locales/i18n', () => ({ 'Total Deposited', 'rewards.ondo_campaign_leaderboard_position.current_value': 'Current Value', - 'rewards.ondo_campaign_leaderboard_position.updated_at': `Updated ${params?.time ?? ''}`, 'rewards.ondo_campaign_leaderboard_position.not_found': 'Not on the leaderboard yet', 'rewards.ondo_campaign_leaderboard_position.error_loading': @@ -121,13 +120,13 @@ const mockRefetch = jest.fn(); const MOCK_POSITION: CampaignLeaderboardPositionDto = { rank: 5, - projected_tier: 'MID', - rate_of_return: 0.15, - total_usd_deposited: 10000.0, - current_usd_value: 12500.5, - computed_at: '2024-03-20T12:00:00.000Z', - total_in_tier: 150, - net_deposit: 8500.0, + projectedTier: 'MID', + rateOfReturn: 0.15, + totalUsdDeposited: 10000.0, + currentUsdValue: 12500.5, + computedAt: '2024-03-20T12:00:00.000Z', + totalInTier: 150, + netDeposit: 8500.0, }; describe('OndoLeaderboardPosition', () => { @@ -390,7 +389,7 @@ describe('OndoLeaderboardPosition', () => { it('renders negative rate of return without plus sign', () => { mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: { ...MOCK_POSITION, rate_of_return: -0.05 }, + position: { ...MOCK_POSITION, rateOfReturn: -0.05 }, isLoading: false, hasError: false, hasFetched: true, @@ -404,16 +403,6 @@ describe('OndoLeaderboardPosition', () => { expect(getByText('-5.00%')).toBeDefined(); }); - it('renders computed_at timestamp', () => { - const { getByTestId } = render( - , - ); - - expect( - getByTestId(ONDO_LEADERBOARD_POSITION_TEST_IDS.COMPUTED_AT), - ).toBeDefined(); - }); - it('renders total deposited and current value stat cells', () => { const { getByTestId } = render( , diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.tsx b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.tsx index 35815af8647..282dcb82d17 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboardPosition.tsx @@ -13,7 +13,7 @@ import { useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; import { useGetOndoLeaderboardPosition } from '../../hooks/useGetOndoLeaderboardPosition'; import RewardsErrorBanner from '../RewardsErrorBanner'; -import { formatRateOfReturn, formatComputedAt } from './OndoLeaderboard.utils'; +import { formatRateOfReturn } from './OndoLeaderboard.utils'; import formatFiat from '../../../../../util/formatFiat'; import { BigNumber } from 'bignumber.js'; import { @@ -28,7 +28,6 @@ export const ONDO_LEADERBOARD_POSITION_TEST_IDS = { RATE_OF_RETURN: 'ondo-leaderboard-position-rate-of-return', TOTAL_DEPOSITED: 'ondo-leaderboard-position-total-deposited', CURRENT_VALUE: 'ondo-leaderboard-position-current-value', - COMPUTED_AT: 'ondo-leaderboard-position-computed-at', LOADING: 'ondo-leaderboard-position-loading', ERROR: 'ondo-leaderboard-position-error', NOT_FOUND: 'ondo-leaderboard-position-not-found', @@ -45,15 +44,9 @@ const PositionSkeleton: React.FC = () => { const tw = useTailwind(); return ( - {/* Title row */} - - {strings('rewards.ondo_campaign_leaderboard_position.title')} - - {/* Divider */} - {/* Row 1: Rank | Tier */} @@ -150,10 +143,7 @@ const OndoLeaderboardPosition: React.FC = ({ if (!position) { if (hasFetched) { return ( - + {strings('rewards.ondo_campaign_leaderboard_position.not_found')} @@ -164,36 +154,18 @@ const OndoLeaderboardPosition: React.FC = ({ } const rorColor = - position.rate_of_return >= 0 + position.rateOfReturn >= 0 ? TextColor.SuccessDefault : TextColor.ErrorDefault; return ( - {/* Row 1: Title + Last Updated */} - - - {strings('rewards.ondo_campaign_leaderboard_position.title')} - - - {strings('rewards.ondo_campaign_leaderboard_position.updated_at', { - time: formatComputedAt(position.computed_at), - })} - - - - {/* Divider */} - + + {strings('rewards.ondo_campaign_leaderboard_position.title')} + {/* Grid row 1: Rank | Tier | (empty) */} @@ -205,7 +177,7 @@ const OndoLeaderboardPosition: React.FC = ({ /> @@ -218,7 +190,7 @@ const OndoLeaderboardPosition: React.FC = ({ label={strings( 'rewards.ondo_campaign_leaderboard_position.total_deposited', )} - value={formatUsd(position.total_usd_deposited)} + value={formatUsd(position.totalUsdDeposited)} style={CELL_STYLE} testID={ONDO_LEADERBOARD_POSITION_TEST_IDS.TOTAL_DEPOSITED} /> @@ -226,7 +198,7 @@ const OndoLeaderboardPosition: React.FC = ({ label={strings( 'rewards.ondo_campaign_leaderboard_position.current_value', )} - value={formatUsd(position.current_usd_value)} + value={formatUsd(position.currentUsdValue)} style={CELL_STYLE} testID={ONDO_LEADERBOARD_POSITION_TEST_IDS.CURRENT_VALUE} /> @@ -234,7 +206,7 @@ const OndoLeaderboardPosition: React.FC = ({ label={strings( 'rewards.ondo_campaign_leaderboard_position.rate_of_return', )} - value={formatRateOfReturn(position.rate_of_return)} + value={formatRateOfReturn(position.rateOfReturn)} valueColor={rorColor} style={CELL_STYLE} testID={ONDO_LEADERBOARD_POSITION_TEST_IDS.RATE_OF_RETURN} diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx new file mode 100644 index 00000000000..17177f87c16 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx @@ -0,0 +1,491 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import OndoPortfolio, { ONDO_PORTFOLIO_TEST_IDS } from './OndoPortfolio'; +import { useGetOndoPortfolioPosition } from '../../hooks/useGetOndoPortfolioPosition'; +import type { + OndoGmPortfolioDto, + OndoGmPortfolioPositionDto, + OndoGmPortfolioSummaryDto, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('../../hooks/useGetOndoPortfolioPosition'); + +const mockUseGetOndoPortfolioPosition = + useGetOndoPortfolioPosition as jest.MockedFunction< + typeof useGetOndoPortfolioPosition + >; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: jest.fn(), dispatch: jest.fn() }), + StackActions: { push: jest.fn((name: string) => ({ type: 'push', name })) }, +})); + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onConfirm, + confirmButtonLabel, + testID, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID }, + ReactActual.createElement(Text, null, title), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +jest.mock('../RewardsInfoBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onConfirm, + confirmButtonLabel, + testID, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID }, + ReactActual.createElement(Text, null, title), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +jest.mock('../../../../../util/formatFiat', () => ({ + __esModule: true, + default: (amount: { toFixed: (dp: number) => string }) => + `$${Number(amount.toFixed(2)).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`, +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string, params?: Record) => { + const translations: Record = { + 'rewards.ondo_campaign_portfolio.positions_heading': 'Your Positions', + 'rewards.ondo_campaign_portfolio.empty': 'No positions yet', + 'rewards.ondo_campaign_portfolio.empty_description': + 'Start investing to see your positions', + 'rewards.ondo_campaign_portfolio.empty_cta': 'Explore tokens', + 'rewards.ondo_campaign_portfolio.error_loading': 'Failed to load', + 'rewards.ondo_campaign_portfolio.error_loading_description': + 'Please try again', + 'rewards.ondo_campaign_portfolio.retry': 'Retry', + 'rewards.ondo_campaign_portfolio.updated_at': `Updated: ${params?.time ?? ''}`, + 'rewards.ondo_campaign_portfolio.position_units': `${params?.units ?? ''} units`, + }; + return translations[key] ?? key; + }, +})); + +jest.mock('../../../AssetOverview/Balance/Balance', () => ({ + NetworkBadgeSource: jest.fn(() => ({ uri: 'https://mock.icon' })), +})); + +jest.mock('../../../Trending/components/TrendingTokenLogo', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => ReactActual.createElement(View, null), + }; +}); + +jest.mock('../../../Trending/utils/getTrendingTokenImageUrl', () => ({ + getTrendingTokenImageUrl: jest.fn(() => 'https://mock.token.image'), +})); + +jest.mock('../../../../../util/ondoGeoRestrictions', () => ({ + isGeoRestricted: jest.fn(() => false), +})); + +jest.mock('./OndoLeaderboard.utils', () => ({ + formatComputedAt: jest.fn(() => '1 hour ago'), +})); + +const CAMPAIGN_ID = 'campaign-123'; +const mockRefetch = jest.fn(); + +const MOCK_POSITION: OndoGmPortfolioPositionDto = { + tokenSymbol: 'AAPLon', + tokenName: 'Apple Inc.', + tokenAsset: 'eip155:1/erc20:0x14c3abf95cb9c93a8b82c1cdcb76d72cb87b2d4c', + units: '45.2', + costBasis: '9040.000000', + avgCostPerUnit: '200.000000', + currentPrice: '215.500000', + currentValue: '9740.600000', + unrealizedPnl: '700.600000', + unrealizedPnlPercent: '0.0775', +}; + +const MOCK_SUMMARY: OndoGmPortfolioSummaryDto = { + totalCurrentValue: '9740.600000', + totalCostBasis: '9040.000000', + totalUsdDeposited: '9040.000000', + netDeposit: '9040.000000', + portfolioPnl: '700.600000', + portfolioPnlPercent: '0.0775', +}; + +const MOCK_PORTFOLIO: OndoGmPortfolioDto = { + positions: [MOCK_POSITION], + summary: MOCK_SUMMARY, + computedAt: '2026-03-20T12:00:00.000Z', +}; + +describe('OndoPortfolio', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('loading state', () => { + it('renders skeleton when loading before first fetch with no data', () => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: null, + isLoading: true, + hasError: false, + hasFetched: false, + refetch: mockRefetch, + }); + + const { getByTestId } = render( + , + ); + + expect(getByTestId(ONDO_PORTFOLIO_TEST_IDS.LOADING)).toBeDefined(); + }); + + it('does not render skeleton when loading but portfolio data already present', () => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: MOCK_PORTFOLIO, + isLoading: true, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(ONDO_PORTFOLIO_TEST_IDS.LOADING)).toBeNull(); + }); + + it('renders skeleton during retry (isLoading=true, hasFetched=true, no portfolio)', () => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: null, + isLoading: true, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(ONDO_PORTFOLIO_TEST_IDS.LOADING)).toBeDefined(); + expect(queryByTestId(ONDO_PORTFOLIO_TEST_IDS.EMPTY)).toBeNull(); + }); + }); + + describe('error state', () => { + it('renders error banner when has error and no data', () => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: null, + isLoading: false, + hasError: true, + hasFetched: true, + refetch: mockRefetch, + }); + + const { getByTestId } = render( + , + ); + + expect(getByTestId(ONDO_PORTFOLIO_TEST_IDS.ERROR)).toBeDefined(); + }); + + it('does not show empty banner on error even after fetch', () => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: null, + isLoading: false, + hasError: true, + hasFetched: true, + refetch: mockRefetch, + }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(ONDO_PORTFOLIO_TEST_IDS.EMPTY)).toBeNull(); + }); + + it('shows cached portfolio data instead of error banner when portfolio exists', () => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: MOCK_PORTFOLIO, + isLoading: false, + hasError: true, + hasFetched: true, + refetch: mockRefetch, + }); + + const { queryByTestId, getByTestId } = render( + , + ); + + expect(queryByTestId(ONDO_PORTFOLIO_TEST_IDS.ERROR)).toBeNull(); + expect(getByTestId(ONDO_PORTFOLIO_TEST_IDS.CONTAINER)).toBeDefined(); + }); + }); + + describe('empty state', () => { + it('renders empty banner when fetch completed with no portfolio', () => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + + const { getByTestId } = render( + , + ); + + expect(getByTestId(ONDO_PORTFOLIO_TEST_IDS.EMPTY)).toBeDefined(); + }); + + it('does not render empty banner when portfolio data is present', () => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: MOCK_PORTFOLIO, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(ONDO_PORTFOLIO_TEST_IDS.EMPTY)).toBeNull(); + }); + }); + + describe('initial/unfetched state', () => { + it('renders nothing before any fetch has completed', () => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: null, + isLoading: false, + hasError: false, + hasFetched: false, + refetch: mockRefetch, + }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(ONDO_PORTFOLIO_TEST_IDS.LOADING)).toBeNull(); + expect(queryByTestId(ONDO_PORTFOLIO_TEST_IDS.ERROR)).toBeNull(); + expect(queryByTestId(ONDO_PORTFOLIO_TEST_IDS.EMPTY)).toBeNull(); + expect(queryByTestId(ONDO_PORTFOLIO_TEST_IDS.CONTAINER)).toBeNull(); + }); + }); + + describe('portfolio data display', () => { + beforeEach(() => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: MOCK_PORTFOLIO, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + }); + + it('renders portfolio container', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(ONDO_PORTFOLIO_TEST_IDS.CONTAINER)).toBeDefined(); + }); + + it('renders the positions heading', () => { + const { getByText } = render(); + + expect(getByText('Your Positions')).toBeDefined(); + }); + + it('renders the token name', () => { + const { getByText } = render(); + + expect(getByText('Apple Inc.')).toBeDefined(); + }); + }); + + describe('hook integration', () => { + it('passes campaignId to hook', () => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: null, + isLoading: false, + hasError: false, + hasFetched: false, + refetch: mockRefetch, + }); + + render(); + + expect(mockUseGetOndoPortfolioPosition).toHaveBeenCalledWith(CAMPAIGN_ID); + }); + }); + + describe('navigation', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: MOCK_PORTFOLIO, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + }); + + it('shows arrow icon in section header when there are positions', () => { + const { getByText } = render(); + fireEvent.press(getByText('Your Positions')); + // Component handles the press without throwing + expect(getByText('Your Positions')).toBeDefined(); + }); + + it('pressing a position row does not throw', () => { + const { getByText } = render(); + fireEvent.press(getByText('Apple Inc.')); + expect(getByText('Apple Inc.')).toBeDefined(); + }); + + it('renders portfolio with no positions (no arrow icon, no position rows)', () => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: { ...MOCK_PORTFOLIO, positions: [] }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + + const { getByTestId, queryByText } = render( + , + ); + + expect(getByTestId(ONDO_PORTFOLIO_TEST_IDS.CONTAINER)).toBeDefined(); + expect(queryByText('Apple Inc.')).toBeNull(); + }); + }); + + describe('position rendering details', () => { + beforeEach(() => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: MOCK_PORTFOLIO, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + }); + + it('renders the units text', () => { + const { getByText } = render(); + expect(getByText('45.2 units')).toBeDefined(); + }); + + it('renders the updated at text', () => { + const { getByText } = render(); + expect(getByText('Updated: 1 hour ago')).toBeDefined(); + }); + + it('renders positive PnL percent in green', () => { + const { getByText } = render(); + expect(getByText('+7.75%')).toBeDefined(); + }); + + it('renders negative PnL percent for loss position', () => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: { + ...MOCK_PORTFOLIO, + positions: [{ ...MOCK_POSITION, unrealizedPnlPercent: '-0.05' }], + }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + + const { getByText } = render(); + expect(getByText('-5.00%')).toBeDefined(); + }); + + it('does not render PnL percent when value is non-numeric', () => { + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: { + ...MOCK_PORTFOLIO, + positions: [{ ...MOCK_POSITION, unrealizedPnlPercent: '—' }], + }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: mockRefetch, + }); + + const { queryByText } = render( + , + ); + expect(queryByText('—')).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.tsx b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.tsx new file mode 100644 index 00000000000..526f8c459e0 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.tsx @@ -0,0 +1,299 @@ +import React, { useMemo } from 'react'; +import { TouchableOpacity } from 'react-native'; +import { + BadgeWrapper, + BadgeWrapperPosition, + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Icon, + IconColor, + IconName, + IconSize, + Skeleton, + Text, + TextColor, + TextVariant, + FontWeight, +} from '@metamask/design-system-react-native'; +import { StackActions, useNavigation } from '@react-navigation/native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { strings } from '../../../../../../locales/i18n'; +import formatFiat from '../../../../../util/formatFiat'; +import Badge, { + BadgeVariant, +} from '../../../../../component-library/components/Badges/Badge'; +import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; +import { NetworkBadgeSource } from '../../../AssetOverview/Balance/Balance'; +import { parseCAIP19AssetId } from '../../../Ramp/Aggregator/utils/parseCaip19AssetId'; +import TrendingTokenLogo from '../../../Trending/components/TrendingTokenLogo'; +import { getTrendingTokenImageUrl } from '../../../Trending/utils/getTrendingTokenImageUrl'; +import { TokenDetailsSource } from '../../../TokenDetails/constants/constants'; +import { useGetOndoPortfolioPosition } from '../../hooks/useGetOndoPortfolioPosition'; +import Routes from '../../../../../constants/navigation/Routes'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import RewardsInfoBanner from '../RewardsInfoBanner'; +import { + groupPortfolioPositionsByAsset, + formatPnlPercent, + isPnlNonNegative, +} from './OndoPortfolio.utils'; +import { formatComputedAt } from './OndoLeaderboard.utils'; + +const getChainHex = (caip19: string): Hex | undefined => { + const parsed = parseCAIP19AssetId(caip19); + if (!parsed || parsed.namespace !== 'eip155') return undefined; + return `0x${parseInt(parsed.chainId, 10).toString(16)}` as Hex; +}; + +const getAssetNavParams = ( + tokenAsset: string, + tokenSymbol: string, + tokenName: string, +) => { + const parsed = parseCAIP19AssetId(tokenAsset); + if (!parsed || parsed.namespace !== 'eip155') return null; + const chainId = `0x${parseInt(parsed.chainId, 10).toString(16)}` as Hex; + return { + chainId, + address: parsed.assetReference, + symbol: tokenSymbol, + name: tokenName, + image: getTrendingTokenImageUrl(tokenAsset), + isFromTrending: true, + source: TokenDetailsSource.Trending, + }; +}; + +export const ONDO_PORTFOLIO_TEST_IDS = { + CONTAINER: 'ondo-campaign-portfolio-container', + LOADING: 'ondo-campaign-portfolio-loading', + ERROR: 'ondo-campaign-portfolio-error', + EMPTY: 'ondo-campaign-portfolio-empty', +} as const; + +const formatUsd = (value: string): string => { + try { + return formatFiat(new BigNumber(value), 'USD'); + } catch { + return value; + } +}; + +interface OndoPortfolioProps { + campaignId: string; +} + +const OndoPortfolio: React.FC = ({ campaignId }) => { + const tw = useTailwind(); + const navigation = useNavigation(); + const { portfolio, isLoading, hasError, hasFetched, refetch } = + useGetOndoPortfolioPosition(campaignId); + + const grouped = useMemo( + () => + portfolio ? groupPortfolioPositionsByAsset(portfolio.positions) : [], + [portfolio], + ); + + const showSkeleton = isLoading && !portfolio; + + if (hasError && !portfolio) { + return ( + + + {strings('rewards.ondo_campaign_portfolio.positions_heading')} + + + + ); + } + + if (showSkeleton) { + return ( + + + + + + ); + } + + if (hasFetched && !portfolio) { + return ( + + + {strings('rewards.ondo_campaign_portfolio.positions_heading')} + + + navigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW as never) + } + confirmButtonLabel={strings( + 'rewards.ondo_campaign_portfolio.empty_cta', + )} + /> + + ); + } + + if (!portfolio) { + return null; + } + + return ( + + {/* Section header */} + 0 + ? () => + navigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW as never) + : undefined + } + activeOpacity={grouped.length > 0 ? 0.7 : 1} + > + + + {strings('rewards.ondo_campaign_portfolio.positions_heading')} + + {grouped.length > 0 && ( + + )} + + + {strings('rewards.ondo_campaign_portfolio.updated_at', { + time: formatComputedAt(portfolio.computedAt), + })} + + + + + + {/* Positions */} + + {grouped.map((row) => { + const rowPnlColor = isPnlNonNegative(row.unrealizedPnlPercent) + ? TextColor.SuccessDefault + : TextColor.ErrorDefault; + const rowPnlPercent = formatPnlPercent(row.unrealizedPnlPercent); + const assetNavParams = getAssetNavParams( + row.tokenAsset, + row.tokenSymbol, + row.tokenName, + ); + return ( + { + if (assetNavParams) { + navigation.dispatch( + StackActions.push('Asset', assetNavParams), + ); + } + }} + activeOpacity={assetNavParams ? 0.7 : 1} + > + + + ) : null + } + > + + + + + + {row.tokenName} + + + {formatUsd(row.currentValue)} + + + + + {strings( + 'rewards.ondo_campaign_portfolio.position_units', + { + units: row.units, + }, + )} + + {rowPnlPercent ? ( + + {rowPnlPercent} + + ) : null} + + + + + ); + })} + + + ); +}; + +export default OndoPortfolio; diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.test.ts new file mode 100644 index 00000000000..d64e1e5cffb --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.test.ts @@ -0,0 +1,113 @@ +import { + groupPortfolioPositionsByAsset, + formatPnlPercent, + isPnlNonNegative, +} from './OndoPortfolio.utils'; + +describe('groupPortfolioPositionsByAsset', () => { + it('merges rows with the same tokenAsset', () => { + const merged = groupPortfolioPositionsByAsset([ + { + tokenSymbol: 'A', + tokenName: 'A', + tokenAsset: 'eip155:1/erc20:0xabc', + units: '10', + costBasis: '100', + avgCostPerUnit: '10', + currentPrice: '11', + currentValue: '110', + unrealizedPnl: '10', + unrealizedPnlPercent: '0.1', + }, + { + tokenSymbol: 'A', + tokenName: 'A', + tokenAsset: 'eip155:1/erc20:0xabc', + units: '5', + costBasis: '50', + avgCostPerUnit: '10', + currentPrice: '11', + currentValue: '55', + unrealizedPnl: '5', + unrealizedPnlPercent: '0.1', + }, + ]); + + expect(merged).toHaveLength(1); + expect(merged[0].units).toBe('15'); + expect(merged[0].costBasis).toBe('150.000000'); + expect(merged[0].currentValue).toBe('165.000000'); + expect(merged[0].unrealizedPnl).toBe('15.000000'); + }); + + it('returns one row per distinct tokenAsset', () => { + const merged = groupPortfolioPositionsByAsset([ + { + tokenSymbol: 'A', + tokenName: 'A', + tokenAsset: 'eip155:1/erc20:0xaaa', + units: '1', + costBasis: '1', + avgCostPerUnit: '1', + currentPrice: '1', + currentValue: '1', + unrealizedPnl: '0', + unrealizedPnlPercent: '0', + }, + { + tokenSymbol: 'B', + tokenName: 'B', + tokenAsset: 'eip155:1/erc20:0xbbb', + units: '2', + costBasis: '2', + avgCostPerUnit: '1', + currentPrice: '1', + currentValue: '2', + unrealizedPnl: '0', + unrealizedPnlPercent: '0', + }, + ]); + + expect(merged).toHaveLength(2); + }); +}); + +describe('formatPnlPercent', () => { + it('formats a positive decimal as a signed percent', () => { + expect(formatPnlPercent('0.0775')).toBe('+7.75%'); + }); + + it('formats a negative decimal with minus sign', () => { + expect(formatPnlPercent('-0.05')).toBe('-5.00%'); + }); + + it('formats zero as +0.00%', () => { + expect(formatPnlPercent('0')).toBe('+0.00%'); + }); + + it('returns empty string for non-numeric value', () => { + expect(formatPnlPercent('—')).toBe(''); + }); + + it('returns empty string for empty string input', () => { + expect(formatPnlPercent('')).toBe(''); + }); +}); + +describe('isPnlNonNegative', () => { + it('returns true for a positive value', () => { + expect(isPnlNonNegative('0.1')).toBe(true); + }); + + it('returns true for zero', () => { + expect(isPnlNonNegative('0')).toBe(true); + }); + + it('returns false for a negative value', () => { + expect(isPnlNonNegative('-0.05')).toBe(false); + }); + + it('returns false for non-parseable value (BigNumber NaN is not >= 0)', () => { + expect(isPnlNonNegative('—')).toBe(false); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.ts b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.ts new file mode 100644 index 00000000000..650a3259e0a --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.utils.ts @@ -0,0 +1,81 @@ +import { BigNumber } from 'bignumber.js'; +import type { OndoGmPortfolioPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; + +/** + * Merges positions that share the same CAIP-19 `tokenAsset` by summing numeric fields. + * Recomputes average cost per unit and unrealized PnL percent from merged totals when possible. + */ +export function groupPortfolioPositionsByAsset( + positions: OndoGmPortfolioPositionDto[], +): OndoGmPortfolioPositionDto[] { + const map = new Map(); + + for (const p of positions) { + const existing = map.get(p.tokenAsset); + if (!existing) { + map.set(p.tokenAsset, { ...p }); + continue; + } + + const units = new BigNumber(existing.units).plus(p.units); + const costBasis = new BigNumber(existing.costBasis).plus(p.costBasis); + const currentValue = new BigNumber(existing.currentValue).plus( + p.currentValue, + ); + const unrealizedPnl = new BigNumber(existing.unrealizedPnl).plus( + p.unrealizedPnl, + ); + + const avgCostPerUnit = units.gt(0) ? costBasis.div(units).toFixed(6) : '—'; + + const unrealizedPnlPercent = costBasis.gt(0) + ? unrealizedPnl.div(costBasis).toFixed(6) + : '—'; + + const currentPrice = units.gt(0) + ? currentValue.div(units).toFixed(6) + : p.currentPrice; + + map.set(p.tokenAsset, { + tokenSymbol: existing.tokenSymbol, + tokenName: existing.tokenName, + tokenAsset: existing.tokenAsset, + units: units.toFixed(), + costBasis: costBasis.toFixed(6), + avgCostPerUnit, + currentPrice, + currentValue: currentValue.toFixed(6), + unrealizedPnl: unrealizedPnl.toFixed(6), + unrealizedPnlPercent, + }); + } + + return Array.from(map.values()); +} + +/** + * Formats a PnL percent string (e.g. "0.0775") as "+7.75%" / "-5.00%". + * Returns '' for non-parseable values (e.g. "—"). + */ +export function formatPnlPercent(pnlPercent: string): string { + try { + const n = new BigNumber(pnlPercent); + if (n.isNaN()) return ''; + const percentage = n.multipliedBy(100); + const sign = percentage.gte(0) ? '+' : ''; + return `${sign}${percentage.toFixed(2)}%`; + } catch { + return ''; + } +} + +/** + * Returns true if the given PnL percent string represents a non-negative value. + */ +export function isPnlNonNegative(pnlPercent: string): boolean { + try { + return new BigNumber(pnlPercent).gte(0); + } catch { + return false; + } +} diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.test.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.test.ts index 17957474134..7e0e09fd0e8 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.test.ts @@ -61,19 +61,19 @@ const mockUseDispatch = useDispatch as jest.MockedFunction; const CAMPAIGN_ID = 'campaign-123'; const MOCK_LEADERBOARD: CampaignLeaderboardDto = { - campaign_id: CAMPAIGN_ID, - computed_at: '2024-03-20T12:00:00.000Z', + campaignId: CAMPAIGN_ID, + computedAt: '2024-03-20T12:00:00.000Z', tiers: { STARTER: { entries: [ - { rank: 1, referral_code: 'ABC123', rate_of_return: 0.15 }, - { rank: 2, referral_code: 'DEF456', rate_of_return: 0.1 }, + { rank: 1, referralCode: 'ABC123', rateOfReturn: 0.15 }, + { rank: 2, referralCode: 'DEF456', rateOfReturn: 0.1 }, ], - total_participants: 50, + totalParticipants: 50, }, MID: { - entries: [{ rank: 1, referral_code: 'GHI789', rate_of_return: 0.2 }], - total_participants: 30, + entries: [{ rank: 1, referralCode: 'GHI789', rateOfReturn: 0.2 }], + totalParticipants: 30, }, }, }; @@ -351,4 +351,65 @@ describe('useGetOndoLeaderboard', () => { expect(result.current.hasError).toBe(true); }); + + it('returns isLeaderboardNotYetComputed as false initially', () => { + const { result } = renderHook(() => useGetOndoLeaderboard(undefined)); + + expect(result.current.isLeaderboardNotYetComputed).toBe(false); + }); + + it('returns isLeaderboardNotYetComputed as false after successful fetch', async () => { + mockCall.mockResolvedValueOnce(MOCK_LEADERBOARD as never); + + const { result } = renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isLeaderboardNotYetComputed).toBe(false); + }); + + it('returns isLeaderboardNotYetComputed as true on 404 error', async () => { + mockCall.mockRejectedValueOnce( + new Error('Get campaign leaderboard failed: 404') as never, + ); + + const { result } = renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isLeaderboardNotYetComputed).toBe(true); + expect(mockDispatch).not.toHaveBeenCalledWith( + setOndoCampaignLeaderboardError(true), + ); + }); + + it('dispatches setOndoCampaignLeaderboardError(true) on non-404 error', async () => { + mockCall.mockRejectedValueOnce(new Error('Server error') as never); + + renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignLeaderboardError(true), + ); + }); + + it('returns isLeaderboardNotYetComputed as false when error is not a 404', async () => { + mockCall.mockRejectedValueOnce(new Error('Server error') as never); + + const { result } = renderHook(() => useGetOndoLeaderboard(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isLeaderboardNotYetComputed).toBe(false); + }); }); diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.ts index e6a0ff78c91..e238cf666b4 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboard.ts @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useCallback, useRef } from 'react'; +import { useMemo, useEffect, useCallback, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { @@ -31,6 +31,8 @@ export interface UseGetOndoLeaderboardResult { isLoading: boolean; /** Whether there was an error fetching the leaderboard */ hasError: boolean; + /** Whether the leaderboard hasn't been computed yet by the backend (404) */ + isLeaderboardNotYetComputed: boolean; /** List of available tier names (e.g., ['STARTER', 'MID', 'UPPER']) */ tierNames: string[]; /** Currently selected tier name */ @@ -63,6 +65,8 @@ export const useGetOndoLeaderboard = ( const leaderboard = useSelector(selectOndoCampaignLeaderboard); const isLoading = useSelector(selectOndoCampaignLeaderboardLoading); const hasError = useSelector(selectOndoCampaignLeaderboardError); + const [isLeaderboardNotYetComputed, setIsLeaderboardNotYetComputed] = + useState(false); const tierNames = useSelector(selectOndoCampaignLeaderboardTierNames); const selectedTier = useSelector(selectOndoCampaignLeaderboardSelectedTier); @@ -73,19 +77,28 @@ export const useGetOndoLeaderboard = ( if (!campaignId) { dispatch(setOndoCampaignLeaderboardLoading(false)); dispatch(setOndoCampaignLeaderboardError(false)); + setIsLeaderboardNotYetComputed(false); return; } try { dispatch(setOndoCampaignLeaderboardLoading(true)); dispatch(setOndoCampaignLeaderboardError(false)); + setIsLeaderboardNotYetComputed(false); const result = await Engine.controllerMessenger.call( 'RewardsController:getOndoCampaignLeaderboard', campaignId, ); dispatch(setOndoCampaignLeaderboard(result)); - } catch { - dispatch(setOndoCampaignLeaderboardError(true)); + } catch (error) { + const is404 = + error instanceof Error && + error.message.includes('Get campaign leaderboard failed: 404'); + if (is404) { + setIsLeaderboardNotYetComputed(true); + } else { + dispatch(setOndoCampaignLeaderboardError(true)); + } } finally { dispatch(setOndoCampaignLeaderboardLoading(false)); } @@ -126,10 +139,11 @@ export const useGetOndoLeaderboard = ( leaderboard, isLoading, hasError, + isLeaderboardNotYetComputed, tierNames, selectedTier, selectedTierData, - computedAt: leaderboard?.computed_at ?? null, + computedAt: leaderboard?.computedAt ?? null, setSelectedTier, refetch: fetchLeaderboard, }; diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts index 95767881519..d7c39d90477 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts @@ -61,14 +61,14 @@ const mockSelectCampaignParticipantOptedIn = const CAMPAIGN_ID = 'campaign-123'; const SUBSCRIPTION_ID = 'sub-456'; const MOCK_POSITION: CampaignLeaderboardPositionDto = { - projected_tier: 'MID', + projectedTier: 'MID', rank: 5, - total_in_tier: 150, - rate_of_return: 0.15, - current_usd_value: 12500.5, - total_usd_deposited: 10000.0, - net_deposit: 8500.0, - computed_at: '2024-03-20T12:00:00.000Z', + totalInTier: 150, + rateOfReturn: 0.15, + currentUsdValue: 12500.5, + totalUsdDeposited: 10000.0, + netDeposit: 8500.0, + computedAt: '2024-03-20T12:00:00.000Z', }; interface SelectorState { diff --git a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts new file mode 100644 index 00000000000..d667c06353e --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts @@ -0,0 +1,182 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react-native'; +import { useSelector, useDispatch } from 'react-redux'; +import { useGetOndoPortfolioPosition } from './useGetOndoPortfolioPosition'; +import Engine from '../../../../core/Engine'; +import { + selectRewardsSubscriptionId, + selectCampaignParticipantOptedIn, +} from '../../../../selectors/rewards'; +import { selectOndoCampaignPortfolioById } from '../../../../reducers/rewards/selectors'; +import { setOndoCampaignPortfolioPosition } from '../../../../reducers/rewards'; +import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; +import type { OndoGmPortfolioDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('./useInvalidateByRewardEvents', () => ({ + useInvalidateByRewardEvents: jest.fn(), +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), + selectCampaignParticipantOptedIn: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectOndoCampaignPortfolioById: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setOndoCampaignPortfolioPosition: jest.fn((payload) => ({ + type: 'rewards/setOndoCampaignPortfolioPosition', + payload, + })), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseInvalidateByRewardEvents = + useInvalidateByRewardEvents as jest.MockedFunction< + typeof useInvalidateByRewardEvents + >; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockSelectOndoCampaignPortfolioById = + selectOndoCampaignPortfolioById as jest.MockedFunction< + typeof selectOndoCampaignPortfolioById + >; +const mockSelectCampaignParticipantOptedIn = + selectCampaignParticipantOptedIn as jest.MockedFunction< + typeof selectCampaignParticipantOptedIn + >; + +const CAMPAIGN_ID = 'campaign-123'; +const SUBSCRIPTION_ID = 'sub-456'; +const MOCK_PORTFOLIO: OndoGmPortfolioDto = { + positions: [], + summary: { + totalCurrentValue: '0', + totalCostBasis: '0', + totalUsdDeposited: '0', + netDeposit: '0', + portfolioPnl: '0', + portfolioPnlPercent: '0', + }, + computedAt: '2024-03-20T12:00:00.000Z', +}; + +interface SelectorState { + subscriptionId: string | null; + portfolio: OndoGmPortfolioDto | null; + isOptedIn?: boolean; +} + +function setupSelectors(state: SelectorState) { + const isOptedIn = state.isOptedIn ?? true; + const mockPortfolioSelector = jest.fn().mockReturnValue(state.portfolio); + const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + mockSelectOndoCampaignPortfolioById.mockReturnValue(mockPortfolioSelector); + mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return state.subscriptionId; + if (selector === mockPortfolioSelector) return state.portfolio; + if (selector === mockOptedInSelector) return isOptedIn; + return undefined; + }); +} + +describe('useGetOndoPortfolioPosition', () => { + const mockDispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + setupSelectors({ + subscriptionId: SUBSCRIPTION_ID, + portfolio: null, + }); + }); + + it('does not fetch when subscriptionId is missing', async () => { + setupSelectors({ + subscriptionId: null, + portfolio: null, + }); + + const { result } = renderHook(() => + useGetOndoPortfolioPosition(CAMPAIGN_ID), + ); + + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + }); + + it('fetches portfolio and dispatches result', async () => { + mockCall.mockResolvedValueOnce(MOCK_PORTFOLIO as never); + + const { result } = renderHook(() => + useGetOndoPortfolioPosition(CAMPAIGN_ID), + ); + + await waitFor(() => { + expect(result.current.hasFetched).toBe(true); + }); + + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getOndoCampaignPortfolioPosition', + CAMPAIGN_ID, + SUBSCRIPTION_ID, + ); + expect(mockDispatch).toHaveBeenCalledWith( + setOndoCampaignPortfolioPosition({ + subscriptionId: SUBSCRIPTION_ID, + campaignId: CAMPAIGN_ID, + portfolio: MOCK_PORTFOLIO, + }), + ); + expect(result.current.hasFetched).toBe(true); + expect(result.current.hasError).toBe(false); + }); + + it('subscribes to RewardsController:portfolioPositionInvalidated to auto-refetch', async () => { + mockCall.mockResolvedValue(MOCK_PORTFOLIO); + + renderHook(() => useGetOndoPortfolioPosition(CAMPAIGN_ID)); + + await waitFor(() => { + expect(mockUseInvalidateByRewardEvents).toHaveBeenCalled(); + }); + + expect(mockUseInvalidateByRewardEvents).toHaveBeenCalledWith( + expect.arrayContaining([ + 'RewardsController:portfolioPositionInvalidated', + ]), + expect.any(Function), + ); + }); + + it('calls selectOndoCampaignPortfolioById with subscriptionId and campaignId', async () => { + mockCall.mockResolvedValue(MOCK_PORTFOLIO); + + renderHook(() => useGetOndoPortfolioPosition(CAMPAIGN_ID)); + + await waitFor(() => { + expect(mockSelectOndoCampaignPortfolioById).toHaveBeenCalled(); + }); + + expect(mockSelectOndoCampaignPortfolioById).toHaveBeenCalledWith( + SUBSCRIPTION_ID, + CAMPAIGN_ID, + ); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts new file mode 100644 index 00000000000..9d53f39cf84 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { + selectRewardsSubscriptionId, + selectCampaignParticipantOptedIn, +} from '../../../../selectors/rewards'; +import { selectOndoCampaignPortfolioById } from '../../../../reducers/rewards/selectors'; +import { setOndoCampaignPortfolioPosition } from '../../../../reducers/rewards'; +import type { OndoGmPortfolioDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; + +export interface UseGetOndoPortfolioPositionResult { + /** User's portfolio, or null when not found/not yet loaded */ + portfolio: OndoGmPortfolioDto | null; + /** Whether the portfolio is being fetched */ + isLoading: boolean; + /** Whether there was an error fetching the portfolio */ + hasError: boolean; + /** Whether at least one fetch attempt has completed (success or error) */ + hasFetched: boolean; + /** Manually re-fetch the portfolio */ + refetch: () => Promise; +} + +/** + * Hook to fetch the current user's Ondo GM portfolio for a campaign. + * This is an authenticated endpoint. + * Results are cached for 5 minutes by the RewardsController. + */ +export const useGetOndoPortfolioPosition = ( + campaignId: string | undefined, +): UseGetOndoPortfolioPositionResult => { + const dispatch = useDispatch(); + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isOptedIn = useSelector( + selectCampaignParticipantOptedIn(subscriptionId, campaignId), + ); + const portfolio = useSelector( + selectOndoCampaignPortfolioById(subscriptionId ?? undefined, campaignId), + ); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + + const fetchPortfolio = useCallback(async (): Promise => { + if (!subscriptionId || !campaignId || !isOptedIn) { + setIsLoading(false); + setHasError(false); + setHasFetched(false); + return; + } + + try { + setIsLoading(true); + setHasError(false); + const result = await Engine.controllerMessenger.call( + 'RewardsController:getOndoCampaignPortfolioPosition', + campaignId, + subscriptionId, + ); + dispatch( + setOndoCampaignPortfolioPosition({ + subscriptionId, + campaignId, + portfolio: result, + }), + ); + } catch { + setHasError(true); + } finally { + setIsLoading(false); + setHasFetched(true); + } + }, [dispatch, subscriptionId, campaignId, isOptedIn]); + + useEffect(() => { + fetchPortfolio(); + }, [fetchPortfolio]); + + const invalidationEvents = useMemo( + () => ['RewardsController:portfolioPositionInvalidated'] as const, + [], + ); + useInvalidateByRewardEvents(invalidationEvents, fetchPortfolio); + + return { + portfolio, + isLoading, + hasError, + hasFetched, + refetch: fetchPortfolio, + }; +}; + +export default useGetOndoPortfolioPosition; diff --git a/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts b/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts index 7f9ea8bc1b5..e1debbe7256 100644 --- a/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts +++ b/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts @@ -262,6 +262,32 @@ describe('useRewardCampaigns', () => { expect(mockDispatch).toHaveBeenCalledWith(mockSetCampaignsLoading(false)); }); + it('dispatches setCampaignsLoading(true) when campaigns have not been loaded before', async () => { + setupSelectorMocks({ hasLoaded: false }); + mockEngineCall.mockResolvedValueOnce([]); + + const { result } = renderHook(() => useRewardCampaigns()); + + await act(async () => { + await result.current.fetchCampaigns(); + }); + + expect(mockSetCampaignsLoading).toHaveBeenCalledWith(true); + }); + + it('does not dispatch setCampaignsLoading(true) when campaigns were already loaded', async () => { + setupSelectorMocks({ hasLoaded: true }); + mockEngineCall.mockResolvedValueOnce([]); + + const { result } = renderHook(() => useRewardCampaigns()); + + await act(async () => { + await result.current.fetchCampaigns(); + }); + + expect(mockSetCampaignsLoading).not.toHaveBeenCalledWith(true); + }); + it('does not fetch when subscriptionId is null', async () => { setupSelectorMocks({ subscriptionId: null }); diff --git a/app/components/UI/Rewards/hooks/useRewardCampaigns.ts b/app/components/UI/Rewards/hooks/useRewardCampaigns.ts index e954f067112..09f7a9ed71f 100644 --- a/app/components/UI/Rewards/hooks/useRewardCampaigns.ts +++ b/app/components/UI/Rewards/hooks/useRewardCampaigns.ts @@ -51,6 +51,8 @@ export const useRewardCampaigns = (): UseRewardCampaignsReturn => { const hasLoaded = useSelector(selectCampaignsHasLoaded); const dispatch = useDispatch(); const isLoadingRef = useRef(false); + const hasLoadedRef = useRef(hasLoaded); + hasLoadedRef.current = hasLoaded; const fetchCampaigns = useCallback(async (): Promise => { if (!subscriptionId) { @@ -66,7 +68,9 @@ export const useRewardCampaigns = (): UseRewardCampaignsReturn => { try { isLoadingRef.current = true; - dispatch(setCampaignsLoading(true)); + if (!hasLoadedRef.current) { + dispatch(setCampaignsLoading(true)); + } dispatch(setCampaignsError(false)); const campaignsData = await Engine.controllerMessenger.call( diff --git a/app/components/UI/SimulationDetails/useSimulationMetrics.test.ts b/app/components/UI/SimulationDetails/useSimulationMetrics.test.ts index 57663cfd84b..140e0d7025b 100644 --- a/app/components/UI/SimulationDetails/useSimulationMetrics.test.ts +++ b/app/components/UI/SimulationDetails/useSimulationMetrics.test.ts @@ -8,7 +8,8 @@ import { renderHook } from '@testing-library/react-hooks'; import { useDispatch } from 'react-redux'; import { updateConfirmationMetric } from '../../../core/redux/slices/confirmationMetrics'; -import { useMetrics } from '../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; +import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { @@ -23,7 +24,7 @@ import { useSimulationMetrics, } from './useSimulationMetrics'; import useLoadingTime from './useLoadingTime'; -import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; +import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -41,21 +42,14 @@ jest.mock('react', () => ({ jest.mock('./useLoadingTime'); jest.mock('../../hooks/DisplayName/useDisplayName'); jest.mock('../../../core/redux/slices/confirmationMetrics'); -jest.mock('../../../components/hooks/useMetrics'); +jest.mock('../../../components/hooks/useAnalytics/useAnalytics'); const mockTrackEvent = jest.fn(); -(useMetrics as jest.MockedFn).mockReturnValue({ - trackEvent: mockTrackEvent, - createEventBuilder: MetricsEventBuilder.createEventBuilder, - enable: jest.fn(), - addTraitsToUser: jest.fn(), - createDataDeletionTask: jest.fn(), - checkDataDeleteStatus: jest.fn(), - getDeleteRegulationCreationDate: jest.fn(), - getDeleteRegulationId: jest.fn(), - isDataRecorded: jest.fn(), - isEnabled: jest.fn(), - getMetaMetricsId: jest.fn(), -}); +jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: AnalyticsEventBuilder.createEventBuilder, + }), +); jest.mock('../../../selectors/networkController'); @@ -396,7 +390,7 @@ describe('useSimulationMetrics', () => { expect(mockTrackEvent).toHaveBeenCalledTimes(1); expect(mockTrackEvent).toHaveBeenCalledWith( - MetricsEventBuilder.createEventBuilder( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.INCOMPLETE_ASSET_DISPLAYED, ) .addProperties({ diff --git a/app/components/UI/SimulationDetails/useSimulationMetrics.ts b/app/components/UI/SimulationDetails/useSimulationMetrics.ts index a6d7f29c48b..5b7b1e2987c 100644 --- a/app/components/UI/SimulationDetails/useSimulationMetrics.ts +++ b/app/components/UI/SimulationDetails/useSimulationMetrics.ts @@ -5,7 +5,7 @@ import { SimulationErrorCode, } from '@metamask/transaction-controller'; -import { useMetrics } from '../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { updateConfirmationMetric } from '../../../core/redux/slices/confirmationMetrics'; import { @@ -117,7 +117,7 @@ function useIncompleteAssetEvent( balanceChanges: BalanceChange[], displayNamesByAddress: { [address: string]: UseDisplayNameResponse }, ) { - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const [processedAssets, setProcessedAssets] = useState([]); for (const change of balanceChanges) { diff --git a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.test.tsx b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.test.tsx index eaefe91d061..32af30cc1b3 100644 --- a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.test.tsx +++ b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.test.tsx @@ -65,10 +65,9 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ }), })); -// Mock useMetrics -jest.mock('../../hooks/useMetrics/useMetrics', () => ({ +jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({ __esModule: true, - default: jest.fn(() => ({ + useAnalytics: jest.fn(() => ({ addTraitsToUser: jest.fn(), })), })); diff --git a/app/components/Views/ImportFromSecretRecoveryPhrase/__snapshots__/index.test.tsx.snap b/app/components/Views/ImportFromSecretRecoveryPhrase/__snapshots__/index.test.tsx.snap index bef922e52a0..aa512efeed4 100644 --- a/app/components/Views/ImportFromSecretRecoveryPhrase/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/ImportFromSecretRecoveryPhrase/__snapshots__/index.test.tsx.snap @@ -799,45 +799,91 @@ exports[`ImportFromSecretRecoveryPhrase Import a wallet UI render matches snapsh ] } > - Continue - + diff --git a/app/components/Views/ImportFromSecretRecoveryPhrase/index.js b/app/components/Views/ImportFromSecretRecoveryPhrase/index.js index e0ecdc17adc..898a33906d4 100644 --- a/app/components/Views/ImportFromSecretRecoveryPhrase/index.js +++ b/app/components/Views/ImportFromSecretRecoveryPhrase/index.js @@ -50,6 +50,9 @@ import { BoxAlignItems, BoxFlexDirection, BoxJustifyContent, + Button, + ButtonSize, + ButtonVariant, FontWeight, Label, Text, @@ -65,10 +68,8 @@ import { ChoosePasswordSelectorsIDs } from '../ChoosePassword/ChoosePassword.tes import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; import Checkbox from '../../../component-library/components/Checkbox'; -import Button, { +import OldButton, { ButtonVariants, - ButtonWidthTypes, - ButtonSize, } from '../../../component-library/components/Buttons/Button'; import Icon, { IconName, @@ -782,7 +783,7 @@ const ImportFromSecretRecoveryPhrase = ({ style={tw.style('items-start')} testID={ChoosePasswordSelectorsIDs.I_UNDERSTAND_CHECKBOX_ID} /> - )} @@ -833,14 +834,15 @@ const ImportFromSecretRecoveryPhrase = ({ {currentStep === 0 && ( )} {isSrpWordSuggestionsEnabled && diff --git a/app/components/Views/ImportFromSecretRecoveryPhrase/index.test.tsx b/app/components/Views/ImportFromSecretRecoveryPhrase/index.test.tsx index 7d6bca5d83b..1f13179dbcc 100644 --- a/app/components/Views/ImportFromSecretRecoveryPhrase/index.test.tsx +++ b/app/components/Views/ImportFromSecretRecoveryPhrase/index.test.tsx @@ -220,7 +220,7 @@ describe('ImportFromSecretRecoveryPhrase', () => { ); const continueButton = getByRole('button', { name: 'Continue' }); - expect(continueButton.props.disabled).toBe(true); + expect(continueButton).toBeDisabled(); }); it('renders paste button when no seed phrase is entered', () => { @@ -335,7 +335,7 @@ describe('ImportFromSecretRecoveryPhrase', () => { // Wait for continue button to be enabled await waitFor( () => { - expect(continueButton.props.disabled).toBe(false); + expect(continueButton).toBeEnabled(); }, { timeout: 3000 }, ); @@ -513,7 +513,7 @@ describe('ImportFromSecretRecoveryPhrase', () => { // Verify continue button is still disabled (since it's not a complete seed phrase) const continueButton = getByRole('button', { name: 'Continue' }); - expect(continueButton.props.disabled).toBe(false); + expect(continueButton).toBeEnabled(); }); it('on backspace key press, the input field length is updated', async () => { diff --git a/app/components/Views/ImportNewSecretRecoveryPhrase/index.test.tsx b/app/components/Views/ImportNewSecretRecoveryPhrase/index.test.tsx index 5c21a3af9db..9987408b472 100644 --- a/app/components/Views/ImportNewSecretRecoveryPhrase/index.test.tsx +++ b/app/components/Views/ImportNewSecretRecoveryPhrase/index.test.tsx @@ -250,7 +250,7 @@ describe('ImportNewSecretRecoveryPhrase', () => { await waitFor(() => { const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); - expect(importButton.props.disabled).toBe(false); + expect(importButton).toBeEnabled(); }); const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); @@ -286,7 +286,7 @@ describe('ImportNewSecretRecoveryPhrase', () => { await waitFor(() => { const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); - expect(importButton.props.disabled).toBe(false); + expect(importButton).toBeEnabled(); }); const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); @@ -313,7 +313,7 @@ describe('ImportNewSecretRecoveryPhrase', () => { ); const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); - expect(importButton.props.disabled).toBe(true); + expect(importButton).toBeDisabled(); }); it('disables import button when SRP length is invalid', async () => { @@ -332,7 +332,7 @@ describe('ImportNewSecretRecoveryPhrase', () => { }); const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); - expect(importButton.props.disabled).toBe(true); + expect(importButton).toBeDisabled(); }); it('shows clear button after pasting SRP', async () => { @@ -409,7 +409,7 @@ describe('ImportNewSecretRecoveryPhrase', () => { await waitFor(() => { const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); - expect(importButton.props.disabled).toBe(false); + expect(importButton).toBeEnabled(); }); const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); @@ -455,7 +455,7 @@ describe('ImportNewSecretRecoveryPhrase', () => { await waitFor(() => { const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); - expect(importButton.props.disabled).toBe(false); + expect(importButton).toBeEnabled(); }); const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); @@ -566,7 +566,7 @@ describe('ImportNewSecretRecoveryPhrase', () => { await waitFor(() => { const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); - expect(importButton.props.disabled).toBe(false); + expect(importButton).toBeEnabled(); }); const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); @@ -609,7 +609,7 @@ describe('ImportNewSecretRecoveryPhrase', () => { await waitFor(() => { const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); - expect(importButton.props.disabled).toBe(false); + expect(importButton).toBeEnabled(); }); const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); @@ -650,7 +650,7 @@ describe('ImportNewSecretRecoveryPhrase', () => { await waitFor(() => { const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); - expect(importButton.props.disabled).toBe(false); + expect(importButton).toBeEnabled(); }); const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); @@ -691,7 +691,7 @@ describe('ImportNewSecretRecoveryPhrase', () => { await waitFor(() => { const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); - expect(importButton.props.disabled).toBe(false); + expect(importButton).toBeEnabled(); }); const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); @@ -765,7 +765,7 @@ describe('ImportNewSecretRecoveryPhrase', () => { }); const importButton = getByTestId(ImportSRPIDs.IMPORT_BUTTON); - expect(importButton.props.disabled).toBe(true); + expect(importButton).toBeDisabled(); }); it('handles empty string in textarea', async () => { diff --git a/app/components/Views/ImportNewSecretRecoveryPhrase/index.tsx b/app/components/Views/ImportNewSecretRecoveryPhrase/index.tsx index 11b2aeb82b7..25483da44d7 100644 --- a/app/components/Views/ImportNewSecretRecoveryPhrase/index.tsx +++ b/app/components/Views/ImportNewSecretRecoveryPhrase/index.tsx @@ -9,11 +9,6 @@ import React, { import { Alert, Platform } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; -import Button, { - ButtonVariants, - ButtonSize, - ButtonWidthTypes, -} from '../../../component-library/components/Buttons/Button'; import { ScreenshotDeterrent } from '../../UI/ScreenshotDeterrent'; import { KeyboardAwareScrollView, @@ -27,7 +22,10 @@ import { Box, BoxAlignItems, BoxFlexDirection, + Button, ButtonIcon, + ButtonSize, + ButtonVariant, IconName, IconColor, Text, @@ -320,15 +318,16 @@ const ImportNewSecretRecoveryPhrase = () => { {isSrpWordSuggestionsEnabled && isKeyboardVisible && ( { ); const [seedPhraseHidden, setSeedPhraseHidden] = useState(true); - const [password, setPassword] = useState(undefined); + const [password, setPassword] = useState(''); const [warningIncorrectPassword, setWarningIncorrectPassword] = useState< string | undefined >(undefined); diff --git a/app/components/Views/NetworksManagement/NetworksManagementView.test.tsx b/app/components/Views/NetworksManagement/NetworksManagementView.test.tsx index 9c7016c31c8..deb352cc5bf 100644 --- a/app/components/Views/NetworksManagement/NetworksManagementView.test.tsx +++ b/app/components/Views/NetworksManagement/NetworksManagementView.test.tsx @@ -51,9 +51,8 @@ jest.mock('./hooks/useNetworkManagementData', () => ({ mockSections(params), })); -// Mock useMetrics -jest.mock('../../hooks/useMetrics', () => ({ - useMetrics: () => ({ +jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ trackEvent: jest.fn(), createEventBuilder: jest.fn(() => ({ addProperties: jest.fn().mockReturnThis(), diff --git a/app/components/Views/OAuthRehydration/index.test.tsx b/app/components/Views/OAuthRehydration/index.test.tsx index 736439227b1..4b8164b3425 100644 --- a/app/components/Views/OAuthRehydration/index.test.tsx +++ b/app/components/Views/OAuthRehydration/index.test.tsx @@ -648,7 +648,7 @@ describe('OAuthRehydration', () => { it('disables login button when password is empty', () => { const { getByTestId } = renderWithProvider(); const loginButton = getByTestId(LoginViewSelectors.LOGIN_BUTTON_ID); - expect(loginButton.props.disabled).toBe(true); + expect(loginButton).toBeDisabled(); }); it('enables login button when password is entered', () => { @@ -657,14 +657,15 @@ describe('OAuthRehydration', () => { const loginButton = getByTestId(LoginViewSelectors.LOGIN_BUTTON_ID); fireEvent.changeText(passwordInput, 'password123'); - expect(loginButton.props.disabled).toBe(false); + expect(loginButton).toBeEnabled(); }); it('shows loading state when isDeletingInProgress is true', () => { mockIsDeletingInProgress.mockReturnValue(true); const { getByTestId } = renderWithProvider(); const loginButton = getByTestId(LoginViewSelectors.LOGIN_BUTTON_ID); - expect(loginButton.props.loading).toBe(true); + expect(loginButton).toBeDisabled(); + expect(loginButton.props.accessibilityState.busy).toBe(true); }); it('does not submit when already loading', async () => { diff --git a/app/components/Views/OAuthRehydration/index.tsx b/app/components/Views/OAuthRehydration/index.tsx index b5af1054076..793154aeb91 100644 --- a/app/components/Views/OAuthRehydration/index.tsx +++ b/app/components/Views/OAuthRehydration/index.tsx @@ -20,10 +20,9 @@ import Text, { TextColor, } from '../../../component-library/components/Texts/Text'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; -import Button, { - ButtonSize, +import OldButton, { ButtonVariants, - ButtonWidthTypes, + ButtonSize as OldButtonSize, } from '../../../component-library/components/Buttons/Button'; import { strings } from '../../../../locales/i18n'; import FadeOutOverlay from '../../UI/FadeOutOverlay'; @@ -82,6 +81,9 @@ import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import FOX_LOGO from '../../../images/branding/fox.png'; import METAMASK_NAME from '../../../images/branding/metamask-name.png'; import { + Button, + ButtonSize, + ButtonVariant, Label, FontWeight, TextColor as DSTextColor, @@ -789,28 +791,29 @@ const OAuthRehydration: React.FC = ({ {isSeedlessPasswordOutdated ? ( - diff --git a/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.test.tsx b/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.test.tsx new file mode 100644 index 00000000000..882c33a2c15 --- /dev/null +++ b/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.test.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import ExploreSectionResultsFullView from './ExploreSectionResultsFullView'; +import { analytics } from '../../../../../util/analytics/analytics'; +import { AnalyticsEventBuilder } from '../../../../../util/analytics/AnalyticsEventBuilder'; + +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); +const mockTokenData = [ + { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, + { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, + { assetId: '3', symbol: 'SOL', name: 'Solana' }, + { assetId: '4', symbol: 'USDC', name: 'USD Coin' }, +]; + +const mockRouteParams: { + sectionId: string; + title: string; + searchQuery: string; + data: unknown[]; +} = { + sectionId: 'tokens', + title: 'Trending tokens', + searchQuery: 'bitcoin', + data: mockTokenData, +}; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + goBack: mockGoBack, + navigate: mockNavigate, + }), + useRoute: () => ({ + params: mockRouteParams, + }), +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), +})); + +const mockBuild = jest.fn().mockReturnValue({}); +const mockAddProperties = jest.fn().mockReturnThis(); + +jest.mock('../../../../../util/analytics/analytics', () => { + const { createAnalyticsMockModule } = jest.requireActual( + '../../../../../util/test/analyticsMock', + ); + return createAnalyticsMockModule(); +}); + +jest.mock('../../../../../util/analytics/AnalyticsEventBuilder', () => ({ + AnalyticsEventBuilder: { + createEventBuilder: jest.fn().mockReturnValue({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), + }), + }, +})); + +const mockAnalyticsTrackEvent = analytics.trackEvent as jest.MockedFunction< + typeof analytics.trackEvent +>; +const mockCreateEventBuilder = + AnalyticsEventBuilder.createEventBuilder as jest.MockedFunction< + typeof AnalyticsEventBuilder.createEventBuilder + >; + +jest.mock('../../sections.config', () => { + const { View } = jest.requireActual('react-native'); + const MockRowItem = ({ item }: { item: unknown }) => ( + + ); + + return { + SECTIONS_CONFIG: { + tokens: { + id: 'tokens', + title: 'Trending tokens', + RowItem: MockRowItem, + getItemIdentifier: (item: unknown) => + (item as { assetId: string }).assetId, + }, + }, + }; +}); + +jest.mock( + '../../../../UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem', + () => () => null, +); + +describe('ExploreSectionResultsFullView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRouteParams.sectionId = 'tokens'; + mockRouteParams.title = 'Trending tokens'; + mockRouteParams.searchQuery = 'bitcoin'; + mockRouteParams.data = mockTokenData; + + mockAddProperties.mockReturnThis(); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + } as never); + }); + + it('renders the title from route params', () => { + const { getByText } = render(); + + expect(getByText('Trending tokens')).toBeOnTheScreen(); + }); + + it('navigates back when back button is pressed', () => { + const { getByLabelText } = render(); + + fireEvent.press(getByLabelText('Go back')); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('renders all items from the section data', () => { + const { getByTestId } = render(); + + expect(getByTestId('row-item-1')).toBeOnTheScreen(); + expect(getByTestId('row-item-2')).toBeOnTheScreen(); + expect(getByTestId('row-item-3')).toBeOnTheScreen(); + expect(getByTestId('row-item-4')).toBeOnTheScreen(); + }); + + it('renders empty list when section data is empty', () => { + mockRouteParams.data = []; + + const { queryByTestId } = render(); + + expect(queryByTestId('row-item-1')).toBeNull(); + }); + + it('fires analytics event when an item is tapped', () => { + const { getByTestId } = render(); + + const item = getByTestId('row-item-1'); + fireEvent(item, 'touchStart', { nativeEvent: { pageY: 100 } }); + fireEvent(item, 'touchEnd', {}); + + expect(mockCreateEventBuilder).toHaveBeenCalled(); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: 'view_all_item_clicked', + search_query: 'bitcoin', + section_name: 'Trending tokens', + item_clicked: '1', + }), + ); + expect(mockAnalyticsTrackEvent).toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.tsx b/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.tsx new file mode 100644 index 00000000000..3652de9d111 --- /dev/null +++ b/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.tsx @@ -0,0 +1,124 @@ +import React, { useCallback } from 'react'; +import { Platform } from 'react-native'; +import { FlashList, ListRenderItem } from '@shopify/flash-list'; +import { + useNavigation, + useRoute, + RouteProp, + NavigationProp, +} from '@react-navigation/native'; +import type { RootStackParamList } from '../../../../../core/NavigationService/types'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + Text, + TextVariant, + ButtonIcon, + ButtonIconSize, + IconName, + BoxFlexDirection, + BoxAlignItems, + FontWeight, +} from '@metamask/design-system-react-native'; +import { SECTIONS_CONFIG, type SectionId } from '../../sections.config'; +import { TrackedRowItem, useScrollTracking } from '../../utils/exploreSearch'; + +interface SectionContentProps { + sectionId: SectionId; + searchQuery: string; + data: unknown[]; +} + +const SectionContent: React.FC = ({ + sectionId, + searchQuery, + data, +}) => { + const tw = useTailwind(); + const section = SECTIONS_CONFIG[sectionId]; + + const { onScrollBeginDrag } = useScrollTracking( + 'view_all_scrolled', + searchQuery, + { section_name: section.title }, + ); + + const renderItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + ), + [section, searchQuery], + ); + + const keyExtractor = useCallback( + (item: unknown, index: number) => + `${sectionId}-${section.getItemIdentifier(item) || index}`, + [sectionId, section], + ); + + return ( + + ); +}; + +const ExploreSectionResultsFullView: React.FC = () => { + const insets = useSafeAreaInsets(); + const navigation = useNavigation>(); + const route = + useRoute>(); + + const { sectionId, title, searchQuery, data } = route.params; + const section = SECTIONS_CONFIG[sectionId]; + const Wrapper = section.SectionWrapper ?? React.Fragment; + + const handleGoBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + return ( + + + + + {title} + + + + + + + + ); +}; + +export default ExploreSectionResultsFullView; diff --git a/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.test.tsx b/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.test.tsx index 7ccc8b84a2c..1b5cfd03f7a 100644 --- a/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.test.tsx +++ b/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.test.tsx @@ -1,14 +1,16 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { render, fireEvent } from '@testing-library/react-native'; import ExploreSearchResults from './ExploreSearchResults'; import { useExploreSearch } from '../../hooks/useExploreSearch'; import { useSelector } from 'react-redux'; import { selectBasicFunctionalityEnabled } from '../../../../../selectors/settings'; +import Routes from '../../../../../constants/navigation/Routes'; +const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ - navigate: jest.fn(), + navigate: mockNavigate, }), })); @@ -110,10 +112,10 @@ describe('ExploreSearchResults', () => { , ); - expect(getByTestId('trending-search-results-list')).toBeDefined(); - expect(getByText('Trending tokens')).toBeDefined(); - expect(getByText('Perps')).toBeDefined(); - expect(getByText('Predictions')).toBeDefined(); + expect(getByTestId('trending-search-results-list')).toBeOnTheScreen(); + expect(getByText('Trending tokens')).toBeOnTheScreen(); + expect(getByText('Perps')).toBeOnTheScreen(); + expect(getByText('Predictions')).toBeOnTheScreen(); }); it('only shows sections with data or loading state', () => { @@ -139,7 +141,7 @@ describe('ExploreSearchResults', () => { , ); - expect(getByText('Trending tokens')).toBeDefined(); + expect(getByText('Trending tokens')).toBeOnTheScreen(); expect(queryByText('Perps')).toBeNull(); expect(queryByText('Predictions')).toBeNull(); }); @@ -199,8 +201,8 @@ describe('ExploreSearchResults', () => { ); // Assert - FlashList renders with data and search query is passed to hook - expect(getByTestId('trending-search-results-list')).toBeDefined(); - expect(getByText('Trending tokens')).toBeDefined(); + expect(getByTestId('trending-search-results-list')).toBeOnTheScreen(); + expect(getByText('Trending tokens')).toBeOnTheScreen(); expect(mockUseExploreSearch).toHaveBeenCalledWith('bitcoin'); }); @@ -228,7 +230,7 @@ describe('ExploreSearchResults', () => { const { getByTestId } = render(); // Assert - list renders, empty query means footer won't render - expect(getByTestId('trending-search-results-list')).toBeDefined(); + expect(getByTestId('trending-search-results-list')).toBeOnTheScreen(); expect(mockUseExploreSearch).toHaveBeenCalledWith(''); }); }); @@ -260,7 +262,7 @@ describe('ExploreSearchResults', () => { ); // Assert - shows header for loading section - expect(getByText('Trending tokens')).toBeDefined(); + expect(getByText('Trending tokens')).toBeOnTheScreen(); }); it('hides section when not loading and has no data', () => { @@ -363,7 +365,113 @@ describe('ExploreSearchResults', () => { // Act & Assert - should not throw const { getByText } = render(); - expect(getByText('Trending tokens')).toBeDefined(); + expect(getByText('Trending tokens')).toBeOnTheScreen(); + }); + }); + + describe('view all and item limit', () => { + it('shows "View all" when a section has more than 3 items', () => { + mockUseExploreSearch.mockReturnValue({ + data: { + tokens: [ + { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, + { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, + { assetId: '3', symbol: 'SOL', name: 'Solana' }, + { assetId: '4', symbol: 'USDC', name: 'USD Coin' }, + ], + perps: [], + predictions: [], + stocks: [], + sites: [], + }, + isLoading: { + tokens: false, + perps: false, + predictions: false, + stocks: false, + sites: false, + }, + sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], + }); + + const { getByText } = render(); + + expect(getByText('View all')).toBeOnTheScreen(); + }); + + it('does not show "View all" when a section has 3 or fewer items', () => { + mockUseExploreSearch.mockReturnValue({ + data: { + tokens: [ + { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, + { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, + ], + perps: [], + predictions: [], + stocks: [], + sites: [], + }, + isLoading: { + tokens: false, + perps: false, + predictions: false, + stocks: false, + sites: false, + }, + sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], + }); + + const { queryByText } = render( + , + ); + + expect(queryByText('View all')).toBeNull(); + }); + + it('navigates to full view with section params when "View all" is pressed', () => { + mockUseExploreSearch.mockReturnValue({ + data: { + tokens: [ + { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, + { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, + { assetId: '3', symbol: 'SOL', name: 'Solana' }, + { assetId: '4', symbol: 'USDC', name: 'USD Coin' }, + ], + perps: [], + predictions: [], + stocks: [], + sites: [], + }, + isLoading: { + tokens: false, + perps: false, + predictions: false, + stocks: false, + sites: false, + }, + sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], + }); + + const { getByText } = render( + , + ); + + fireEvent.press(getByText('View all')); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.EXPLORE_SECTION_RESULTS_FULL_VIEW, + { + sectionId: 'tokens', + title: 'Trending tokens', + searchQuery: 'bitcoin', + data: [ + { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, + { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, + { assetId: '3', symbol: 'SOL', name: 'Solana' }, + { assetId: '4', symbol: 'USDC', name: 'USD Coin' }, + ], + }, + ); }); }); }); diff --git a/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.tsx b/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.tsx index b912452afe1..24b17f5fb89 100644 --- a/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.tsx +++ b/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.tsx @@ -1,15 +1,39 @@ import React, { useMemo, useCallback, useRef, useEffect } from 'react'; import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list'; -import { useNavigation } from '@react-navigation/native'; -import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import type { RootStackParamList } from '../../../../../core/NavigationService/types'; +import { + Box, + Text, + TextVariant, + TextColor, + Icon, + IconName, + IconSize, + IconColor, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, +} from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { Pressable } from 'react-native'; import { SECTIONS_CONFIG, type SectionId } from '../../sections.config'; +import { MetaMetricsEvents } from '../../../../../core/Analytics/MetaMetrics.events'; +import { + TrackedRowItem, + trackExploreEvent, + useScrollTracking, +} from '../../utils/exploreSearch'; import { useExploreSearch } from '../../hooks/useExploreSearch'; import { selectBasicFunctionalityEnabled } from '../../../../../selectors/settings'; import SitesSearchFooter from '../../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter'; import { useSelector } from 'react-redux'; import { useSearchTracking } from '../../../../UI/Trending/hooks/useSearchTracking/useSearchTracking'; import { TimeOption } from '../../../../UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet'; +import Routes from '../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../locales/i18n'; + +const MAX_ITEMS_PER_SECTION = 3; interface ExploreSearchResultsProps { searchQuery: string; @@ -17,7 +41,9 @@ interface ExploreSearchResultsProps { interface ListItemHeader { type: 'header'; - data: string; + sectionId: SectionId; + title: string; + hasMore: boolean; } interface ListItemData { @@ -37,7 +63,7 @@ type FlatListItem = ListItemHeader | ListItemData | ListItemSkeleton; const ExploreSearchResults: React.FC = ({ searchQuery, }) => { - const navigation = useNavigation(); + const navigation = useNavigation>(); const tw = useTailwind(); const { data, isLoading, sectionsOrder } = useExploreSearch(searchQuery); const flashListRef = useRef>(null); @@ -45,22 +71,77 @@ const ExploreSearchResults: React.FC = ({ selectBasicFunctionalityEnabled, ); + const { onScrollBeginDrag, resetScrollTracking } = useScrollTracking( + 'scrolled', + searchQuery, + ); + + useEffect(() => { + resetScrollTracking(); + }, [searchQuery, resetScrollTracking]); + + const handleViewMore = useCallback( + (sectionId: SectionId, title: string) => { + trackExploreEvent(MetaMetricsEvents.EXPLORE_SEARCH_INTERACTED, { + interaction_type: 'view_all_clicked', + search_query: searchQuery, + section_name: title, + }); + navigation.navigate(Routes.EXPLORE_SECTION_RESULTS_FULL_VIEW, { + sectionId, + title, + searchQuery, + data: data[sectionId], + }); + }, + [navigation, searchQuery, data], + ); + const renderSectionHeader = useCallback( - (title: string) => ( - + (item: ListItemHeader) => ( + - {title} + {item.title} + {item.hasMore && ( + handleViewMore(item.sectionId, item.title)} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={`${strings('trending.view_all')} ${item.title}`} + style={({ pressed }) => + tw.style( + 'flex-row items-center gap-1 rounded px-1', + pressed && 'opacity-50', + ) + } + > + + {strings('trending.view_all')} + + + + )} ), - [], + [handleViewMore, tw], ); - // Build flat list data with sections const flatData = useMemo(() => { const result: FlatListItem[] = []; - // Filter sections based on basic functionality toggle const sectionIdsToShow = isBasicFunctionalityEnabled ? sectionsOrder : []; sectionIdsToShow.forEach((sectionId) => { @@ -70,17 +151,19 @@ const ExploreSearchResults: React.FC = ({ const items = data[sectionId]; const sectionIsLoading = isLoading[sectionId]; - // Show section if it has items or is loading if ((items && items.length > 0) || sectionIsLoading) { - // Add section header + const hasMore = + !sectionIsLoading && (items?.length ?? 0) > MAX_ITEMS_PER_SECTION; + result.push({ type: 'header', - data: section.title, + sectionId, + title: section.title, + hasMore, }); if (sectionIsLoading) { - // Show 3 skeleton items while loading - for (let i = 0; i < 3; i++) { + for (let i = 0; i < MAX_ITEMS_PER_SECTION; i++) { result.push({ type: 'skeleton', sectionId, @@ -88,8 +171,8 @@ const ExploreSearchResults: React.FC = ({ }); } } else { - // Add section items - items.forEach((item) => { + const visibleItems = items.slice(0, MAX_ITEMS_PER_SECTION); + visibleItems.forEach((item) => { result.push({ type: 'item', sectionId, @@ -103,7 +186,6 @@ const ExploreSearchResults: React.FC = ({ return result; }, [data, isLoading, isBasicFunctionalityEnabled, sectionsOrder]); - // Scroll to top when search query changes useEffect(() => { if (flatData.length > 0) { flashListRef.current?.scrollToIndex({ @@ -113,7 +195,6 @@ const ExploreSearchResults: React.FC = ({ } }, [searchQuery, flatData.length]); - // Track search events for tokens section useSearchTracking({ searchQuery, resultsCount: data.tokens?.length || 0, @@ -132,7 +213,7 @@ const ExploreSearchResults: React.FC = ({ const renderFlatItem: ListRenderItem = useCallback( ({ item, index }) => { if (item.type === 'header') { - return renderSectionHeader(item.data); + return renderSectionHeader(item); } const section = SECTIONS_CONFIG[item.sectionId]; @@ -145,30 +226,21 @@ const ExploreSearchResults: React.FC = ({ return ; } - if (section.OverrideRowItemSearch) { - return ( - - ); - } - - // Cast navigation to 'never' to satisfy different navigation param list types return ( - ); }, - [navigation, renderSectionHeader], + [renderSectionHeader, searchQuery], ); const keyExtractor = useCallback((item: FlatListItem, index: number) => { - if (item.type === 'header') return `header-${item.data}`; + if (item.type === 'header') return `header-${item.sectionId}`; if (item.type === 'skeleton') return `skeleton-${item.sectionId}-${item.index}`; @@ -189,6 +261,7 @@ const ExploreSearchResults: React.FC = ({ keyboardShouldPersistTaps="handled" testID="trending-search-results-list" ListFooterComponent={renderFooter} + onScrollBeginDrag={onScrollBeginDrag} /> ); diff --git a/app/components/Views/TrendingView/sections.config.test.tsx b/app/components/Views/TrendingView/sections.config.test.tsx new file mode 100644 index 00000000000..4efb8858e42 --- /dev/null +++ b/app/components/Views/TrendingView/sections.config.test.tsx @@ -0,0 +1,197 @@ +/** + * Tests for sections.config.tsx + * Covers the getItemIdentifier functions added for analytics tracking. + */ + +// Mock all heavy dependencies before importing SECTIONS_CONFIG +jest.mock('../../../constants/navigation/Routes', () => ({ + __esModule: true, + default: { + WALLET: { + TRENDING_TOKENS_FULL_VIEW: 'TrendingTokensFullView', + RWA_TOKENS_FULL_VIEW: 'RwaTokensFullView', + }, + PERPS: { + ROOT: 'PerpsRoot', + MARKET_LIST: 'PerpsMarketList', + MARKET_DETAILS: 'PerpsMarketDetails', + }, + PREDICT: { + ROOT: 'PredictRoot', + MARKET_LIST: 'PredictMarketList', + }, + SITES_FULL_VIEW: 'SitesFullView', + }, +})); + +jest.mock('../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useContext: jest.fn(), +})); + +jest.mock( + '../../UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem', + () => () => null, +); +jest.mock( + '../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton', + () => () => null, +); +jest.mock('../../UI/Perps/components/PerpsMarketRowItem', () => () => null); +jest.mock('../../UI/Perps/hooks', () => ({ usePerpsMarkets: jest.fn() })); +jest.mock('@metamask/perps-controller', () => ({ + filterMarketsByQuery: jest.fn((markets: unknown[]) => markets), + PERPS_EVENT_VALUE: { SOURCE: { EXPLORE: 'explore' } }, +})); +jest.mock('../../UI/Predict/hooks/usePredictMarketData', () => ({ + usePredictMarketData: jest.fn(), +})); +jest.mock('../../UI/Predict/components/PredictMarketRowItem', () => () => null); +jest.mock( + '../../UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper', + () => () => null, +); +jest.mock( + '../../UI/Sites/components/SiteSkeleton/SiteSkeleton', + () => () => null, +); +jest.mock('../../UI/Sites/hooks/useSiteData/useSitesData', () => ({ + useSitesData: jest.fn(), +})); +jest.mock( + '../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch', + () => ({ useTrendingSearch: jest.fn() }), +); +jest.mock('../../UI/Trending/hooks/useRwaTokens/useRwaTokens', () => ({ + useRwaTokens: jest.fn(), +})); +jest.mock('../../UI/Perps', () => ({ selectPerpsEnabledFlag: jest.fn() })); +jest.mock('../../UI/Perps/providers/PerpsConnectionProvider', () => ({ + PerpsConnectionContext: {}, + PerpsConnectionProvider: ({ children }: { children: React.ReactNode }) => + children, +})); +jest.mock('../../UI/Perps/providers/PerpsStreamManager', () => ({ + PerpsStreamProvider: ({ children }: { children: React.ReactNode }) => + children, +})); +jest.mock('./components/Sections/SectionTypes/SectionCard', () => () => null); +jest.mock('fuse.js', () => + jest.fn().mockImplementation(() => ({ + search: jest.fn().mockReturnValue([]), + })), +); + +import { SECTIONS_CONFIG } from './sections.config'; + +describe('SECTIONS_CONFIG getItemIdentifier', () => { + describe('tokens section', () => { + it('extracts assetId from a token item', () => { + const item = { assetId: 'token-abc-123', symbol: 'BTC', name: 'Bitcoin' }; + + const result = SECTIONS_CONFIG.tokens.getItemIdentifier(item); + + expect(result).toBe('token-abc-123'); + }); + + it('extracts assetId with special characters', () => { + const item = { assetId: 'eip155:1/erc20:0xabc', symbol: 'USDC' }; + + const result = SECTIONS_CONFIG.tokens.getItemIdentifier(item); + + expect(result).toBe('eip155:1/erc20:0xabc'); + }); + }); + + describe('perps section', () => { + it('extracts symbol from a perps market item', () => { + const item = { symbol: 'BTC-USD', name: 'Bitcoin', price: 50000 }; + + const result = SECTIONS_CONFIG.perps.getItemIdentifier(item); + + expect(result).toBe('BTC-USD'); + }); + + it('extracts symbol with various market pairs', () => { + const item = { symbol: 'ETH-PERP', name: 'Ethereum Perpetual' }; + + const result = SECTIONS_CONFIG.perps.getItemIdentifier(item); + + expect(result).toBe('ETH-PERP'); + }); + }); + + describe('stocks section', () => { + it('extracts assetId from a stocks item', () => { + const item = { assetId: 'stock-aapl-456', symbol: 'AAPL', name: 'Apple' }; + + const result = SECTIONS_CONFIG.stocks.getItemIdentifier(item); + + expect(result).toBe('stock-aapl-456'); + }); + }); + + describe('predictions section', () => { + it('extracts id from a prediction market item', () => { + const item = { id: 'predict-market-789', title: 'Will BTC reach 100k?' }; + + const result = SECTIONS_CONFIG.predictions.getItemIdentifier(item); + + expect(result).toBe('predict-market-789'); + }); + + it('extracts id when item has additional fields', () => { + const item = { + id: 'market-42', + title: 'Election outcome', + description: 'Who will win?', + volume: 1000, + }; + + const result = SECTIONS_CONFIG.predictions.getItemIdentifier(item); + + expect(result).toBe('market-42'); + }); + }); + + describe('sites section', () => { + it('extracts url from a site item', () => { + const item = { + url: 'https://uniswap.org', + name: 'Uniswap', + displayUrl: 'uniswap.org', + }; + + const result = SECTIONS_CONFIG.sites.getItemIdentifier(item); + + expect(result).toBe('https://uniswap.org'); + }); + + it('extracts url with path components', () => { + const item = { + url: 'https://app.aave.com/markets', + name: 'Aave Markets', + }; + + const result = SECTIONS_CONFIG.sites.getItemIdentifier(item); + + expect(result).toBe('https://app.aave.com/markets'); + }); + }); + + describe('getItemIdentifier presence', () => { + it('is defined for all sections', () => { + const sectionIds = Object.keys( + SECTIONS_CONFIG, + ) as (keyof typeof SECTIONS_CONFIG)[]; + + sectionIds.forEach((sectionId) => { + expect(SECTIONS_CONFIG[sectionId].getItemIdentifier).toBeDefined(); + }); + }); + }); +}); diff --git a/app/components/Views/TrendingView/sections.config.tsx b/app/components/Views/TrendingView/sections.config.tsx index be3b525baa7..e0f5af590b6 100644 --- a/app/components/Views/TrendingView/sections.config.tsx +++ b/app/components/Views/TrendingView/sections.config.tsx @@ -50,11 +50,13 @@ interface SectionData { isLoading: boolean; } -interface SectionConfig { +export interface SectionConfig { id: SectionId; title: string; icon: SectionIcon; viewAllAction: (navigation: NavigationProp) => void; + /** Returns a stable identifier for an item (e.g. assetId, symbol, url) used in analytics */ + getItemIdentifier: (item: unknown) => string; RowItem: React.ComponentType<{ item: unknown; index: number; @@ -171,6 +173,7 @@ export const SECTIONS_CONFIG: Record = { viewAllAction: (navigation) => { navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW); }, + getItemIdentifier: (item) => (item as Partial).assetId ?? '', RowItem: ({ item, index }) => ( = { }, }); }, + getItemIdentifier: (item) => + (item as Partial).symbol ?? '', RowItem: ({ item, index: _index, navigation }) => ( = { viewAllAction: (navigation) => { navigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW); }, + getItemIdentifier: (item) => (item as Partial).assetId ?? '', RowItem: ({ item, index }) => ( = { screen: Routes.PREDICT.MARKET_LIST, }); }, + getItemIdentifier: (item) => (item as Partial).id ?? '', RowItem: ({ item, index: _index }) => ( ), @@ -335,6 +342,7 @@ export const SECTIONS_CONFIG: Record = { viewAllAction: (navigation) => { navigation.navigate(Routes.SITES_FULL_VIEW); }, + getItemIdentifier: (item) => (item as Partial).url ?? '', RowItem: ({ item, index: _index, navigation }) => ( ), diff --git a/app/components/Views/TrendingView/utils/exploreSearch.test.tsx b/app/components/Views/TrendingView/utils/exploreSearch.test.tsx new file mode 100644 index 00000000000..b0bb638fc4a --- /dev/null +++ b/app/components/Views/TrendingView/utils/exploreSearch.test.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { View, Text } from 'react-native'; +import { TapView, trackExploreEvent } from './exploreSearch'; +import { analytics } from '../../../../util/analytics/analytics'; +import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; + +const mockBuild = jest.fn().mockReturnValue({}); +const mockAddProperties = jest.fn().mockReturnThis(); +const mockBuilderInstance = { + addProperties: mockAddProperties, + addSensitiveProperties: jest.fn().mockReturnThis(), + removeProperties: jest.fn().mockReturnThis(), + removeSensitiveProperties: jest.fn().mockReturnThis(), + setSaveDataRecording: jest.fn().mockReturnThis(), + build: mockBuild, +}; + +jest.mock('../../../../util/analytics/analytics', () => { + const { createAnalyticsMockModule } = jest.requireActual( + '../../../../util/test/analyticsMock', + ); + return createAnalyticsMockModule(); +}); + +jest.mock('../../../../util/analytics/AnalyticsEventBuilder', () => ({ + AnalyticsEventBuilder: { + createEventBuilder: jest.fn(), + }, +})); + +const mockAnalyticsTrackEvent = analytics.trackEvent as jest.MockedFunction< + typeof analytics.trackEvent +>; +const mockCreateEventBuilderFn = + AnalyticsEventBuilder.createEventBuilder as jest.MockedFunction< + typeof AnalyticsEventBuilder.createEventBuilder + >; + +describe('TapView', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders children', () => { + const { getByText } = render( + + Child content + , + ); + + expect(getByText('Child content')).toBeOnTheScreen(); + }); + + it('calls onTap when touch ends without scroll movement', () => { + const onTap = jest.fn(); + const { getByTestId } = render( + + + , + ); + + const content = getByTestId('content'); + fireEvent(content, 'touchStart', { nativeEvent: { pageY: 100 } }); + fireEvent(content, 'touchEnd', {}); + + expect(onTap).toHaveBeenCalledTimes(1); + }); + + it('does not call onTap when vertical movement exceeds scroll threshold', () => { + const onTap = jest.fn(); + const { getByTestId } = render( + + + , + ); + + const content = getByTestId('content'); + fireEvent(content, 'touchStart', { nativeEvent: { pageY: 100 } }); + fireEvent(content, 'touchMove', { nativeEvent: { pageY: 120 } }); + fireEvent(content, 'touchEnd', {}); + + expect(onTap).not.toHaveBeenCalled(); + }); + + it('does not call onTap for upward scroll exceeding threshold', () => { + const onTap = jest.fn(); + const { getByTestId } = render( + + + , + ); + + const content = getByTestId('content'); + fireEvent(content, 'touchStart', { nativeEvent: { pageY: 100 } }); + fireEvent(content, 'touchMove', { nativeEvent: { pageY: 80 } }); + fireEvent(content, 'touchEnd', {}); + + expect(onTap).not.toHaveBeenCalled(); + }); + + it('calls onTap when vertical movement is within threshold (micro-jitter)', () => { + const onTap = jest.fn(); + const { getByTestId } = render( + + + , + ); + + const content = getByTestId('content'); + fireEvent(content, 'touchStart', { nativeEvent: { pageY: 100 } }); + fireEvent(content, 'touchMove', { nativeEvent: { pageY: 105 } }); + fireEvent(content, 'touchEnd', {}); + + expect(onTap).toHaveBeenCalledTimes(1); + }); + + it('resets scroll detection on each new touch start', () => { + const onTap = jest.fn(); + const { getByTestId } = render( + + + , + ); + + const content = getByTestId('content'); + + // First interaction: scroll (no tap) + fireEvent(content, 'touchStart', { nativeEvent: { pageY: 100 } }); + fireEvent(content, 'touchMove', { nativeEvent: { pageY: 120 } }); + fireEvent(content, 'touchEnd', {}); + expect(onTap).not.toHaveBeenCalled(); + + // Second interaction: tap (should fire) + fireEvent(content, 'touchStart', { nativeEvent: { pageY: 200 } }); + fireEvent(content, 'touchEnd', {}); + expect(onTap).toHaveBeenCalledTimes(1); + }); + + it('does not throw when onTap is not provided', () => { + const { getByTestId } = render( + + + , + ); + + const content = getByTestId('content'); + expect(() => { + fireEvent(content, 'touchStart', { nativeEvent: { pageY: 100 } }); + fireEvent(content, 'touchEnd', {}); + }).not.toThrow(); + }); +}); + +describe('trackExploreEvent', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCreateEventBuilderFn.mockReturnValue(mockBuilderInstance); + }); + + it('calls analytics.trackEvent with a built event', () => { + const mockEvent = { category: 'Explore', action: 'Click' }; + const properties = { search_query: 'bitcoin', section_name: 'Tokens' }; + + trackExploreEvent(mockEvent as never, properties); + + expect(mockCreateEventBuilderFn).toHaveBeenCalledWith(mockEvent); + expect(mockAddProperties).toHaveBeenCalledWith(properties); + expect(mockBuild).toHaveBeenCalled(); + expect(mockAnalyticsTrackEvent).toHaveBeenCalledWith( + mockBuild.mock.results[0].value, + ); + }); + + it('passes all properties to the event builder', () => { + const mockEvent = { category: 'Explore', action: 'Scroll' }; + const properties = { + search_query: 'eth', + section_name: 'Perps', + item_clicked: 'ETH-USD', + }; + + trackExploreEvent(mockEvent as never, properties); + + expect(mockAddProperties).toHaveBeenCalledWith(properties); + }); +}); diff --git a/app/components/Views/TrendingView/utils/exploreSearch.tsx b/app/components/Views/TrendingView/utils/exploreSearch.tsx new file mode 100644 index 00000000000..0cae65b6594 --- /dev/null +++ b/app/components/Views/TrendingView/utils/exploreSearch.tsx @@ -0,0 +1,140 @@ +import React, { useCallback, useRef } from 'react'; +import { Box } from '@metamask/design-system-react-native'; +import { + useNavigation, + type NavigationProp, + type ParamListBase, +} from '@react-navigation/native'; +import { analytics } from '../../../../util/analytics/analytics'; +import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; +import type { SectionConfig } from '../sections.config'; + +/** + * Minimum vertical movement (in pixels) to consider a touch gesture a scroll + * rather than a tap. Absorbs micro-jitter from real taps while staying far + * below any intentional scroll distance. + */ +const SCROLL_THRESHOLD = 8; + +/** + * Wraps children and fires `onTap` only when the touch ends without a scroll + * gesture. Uses raw touch events (onTouchStart/Move/End) which fire + * independently of the scroll responder system, so movement is reliably + * detected even while a parent FlashList is absorbing a scroll. + */ +export const TapView: React.FC<{ + onTap?: () => void; + children: React.ReactNode; +}> = ({ onTap, children }) => { + const startY = useRef(0); + const didScroll = useRef(false); + return ( + { + startY.current = e.nativeEvent.pageY; + didScroll.current = false; + }} + onTouchMove={(e) => { + if (Math.abs(e.nativeEvent.pageY - startY.current) > SCROLL_THRESHOLD) { + didScroll.current = true; + } + }} + onTouchEnd={() => { + if (!didScroll.current) onTap?.(); + }} + > + {children} + + ); +}; + +/** + * Thin wrapper around the analytics event builder pattern. + * Reduces the 5-line boilerplate at every call site to a single line. + */ +export const trackExploreEvent = ( + event: Parameters[0], + properties: Record, +): void => { + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder(event) + .addProperties(properties) + .build(), + ); +}; + +/** + * Returns a stable `onScrollBeginDrag` handler that fires a one-shot + * analytics event the first time the user begins scrolling. + * Uses a ref for `searchQuery` so the callback identity never changes. + */ +export const useScrollTracking = ( + interactionType: string, + searchQuery: string, + extraProperties?: Record, +) => { + const hasTracked = useRef(false); + const searchQueryRef = useRef(searchQuery); + searchQueryRef.current = searchQuery; + + const extraPropsRef = useRef(extraProperties); + extraPropsRef.current = extraProperties; + + const onScrollBeginDrag = useCallback(() => { + if (hasTracked.current) return; + hasTracked.current = true; + trackExploreEvent(MetaMetricsEvents.EXPLORE_SEARCH_INTERACTED, { + interaction_type: interactionType, + search_query: searchQueryRef.current, + ...extraPropsRef.current, + }); + }, [interactionType]); + + const resetScrollTracking = useCallback(() => { + hasTracked.current = false; + }, []); + + return { onScrollBeginDrag, resetScrollTracking }; +}; + +interface TrackedRowItemProps { + section: SectionConfig; + item: unknown; + index: number; + searchQuery: string; + interactionType: string; +} + +/** + * Renders a section RowItem (or its search override) wrapped in a TapView + * that fires an analytics event on tap with the item identifier. + */ +export const TrackedRowItem: React.FC = ({ + section, + item, + index, + searchQuery, + interactionType, +}) => { + const navigation = useNavigation>(); + const RowComponent = section.OverrideRowItemSearch ?? section.RowItem; + + const searchQueryRef = useRef(searchQuery); + searchQueryRef.current = searchQuery; + + const handleItemTouch = useCallback(() => { + trackExploreEvent(MetaMetricsEvents.EXPLORE_SEARCH_INTERACTED, { + interaction_type: interactionType, + search_query: searchQueryRef.current, + section_name: section.title, + item_clicked: section.getItemIdentifier(item), + }); + }, [interactionType, section, item]); + + return ( + + + + ); +}; diff --git a/app/components/Views/WalletCreationError/SRPErrorScreen.tsx b/app/components/Views/WalletCreationError/SRPErrorScreen.tsx index 7f114a90d44..e40ac399238 100644 --- a/app/components/Views/WalletCreationError/SRPErrorScreen.tsx +++ b/app/components/Views/WalletCreationError/SRPErrorScreen.tsx @@ -9,6 +9,9 @@ import { Dispatch } from 'redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, + Button, + ButtonSize, + ButtonVariant, Text, TextVariant, TextColor, @@ -27,10 +30,9 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; import { ITrackingEvent } from '../../../core/Analytics/MetaMetrics.types'; import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; -import Button, { +import OldButton, { ButtonVariants, - ButtonSize, - ButtonWidthTypes, + ButtonSize as OldButtonSize, } from '../../../component-library/components/Buttons/Button'; import { IconName as CLibIconName } from '../../../component-library/components/Icons/Icon'; @@ -192,9 +194,9 @@ const SRPErrorScreen = ({ {strings('wallet_creation_error.error_report')} - diff --git a/app/components/Views/WalletCreationError/SocialLoginErrorSheet.tsx b/app/components/Views/WalletCreationError/SocialLoginErrorSheet.tsx index 49572d68c74..96679efe723 100644 --- a/app/components/Views/WalletCreationError/SocialLoginErrorSheet.tsx +++ b/app/components/Views/WalletCreationError/SocialLoginErrorSheet.tsx @@ -8,6 +8,9 @@ import { Text, TextVariant, TextColor, + Button, + ButtonSize, + ButtonVariant, Icon, IconName, IconSize, @@ -16,11 +19,6 @@ import { import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../core/Analytics'; -import Button, { - ButtonVariants, - ButtonSize, - ButtonWidthTypes, -} from '../../../component-library/components/Buttons/Button'; import { strings } from '../../../../locales/i18n'; import Routes from '../../../constants/navigation/Routes'; @@ -116,12 +114,13 @@ const SocialLoginErrorSheet = ({ error }: SocialLoginErrorSheetProps) => { ); diff --git a/app/components/hooks/SeedlessHooks/usePromptSeedlessRelogin.test.tsx b/app/components/hooks/SeedlessHooks/usePromptSeedlessRelogin.test.tsx index c32e78a43f5..8bdc104c9bd 100644 --- a/app/components/hooks/SeedlessHooks/usePromptSeedlessRelogin.test.tsx +++ b/app/components/hooks/SeedlessHooks/usePromptSeedlessRelogin.test.tsx @@ -9,7 +9,7 @@ import { strings } from '../../../../locales/i18n'; import { clearHistory } from '../../../actions/browser'; // Mock dependencies -jest.mock('../useMetrics'); +jest.mock('../useAnalytics/useAnalytics'); jest.mock('../../../util/identity/hooks/useAuthentication'); jest.mock('../../../core/Authentication/Authentication', () => ({ Authentication: { @@ -31,11 +31,12 @@ jest.mock('@react-navigation/native', () => ({ })); // Mock imports -import { useMetrics } from '../useMetrics'; +import { useAnalytics } from '../useAnalytics/useAnalytics'; +import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock'; import { useSignOut } from '../../../util/identity/hooks/useAuthentication'; import { Authentication } from '../../../core/Authentication/Authentication'; -const mockUseMetrics = useMetrics as jest.MockedFunction; +const mockUseAnalytics = jest.mocked(useAnalytics); const mockUseSignOut = useSignOut as jest.MockedFunction; const mockClearHistory = clearHistory as jest.MockedFunction< typeof clearHistory @@ -47,19 +48,7 @@ const mockDeleteWallet = Authentication.deleteWallet as jest.MockedFunction< describe('usePromptSeedlessRelogin', () => { const mockStore = configureMockStore([thunk]); const mockSignOut = jest.fn(); - const mockMetrics = { - isEnabled: jest.fn().mockReturnValue(true), - trackEvent: jest.fn(), - enable: jest.fn(), - addTraitsToUser: jest.fn(), - createDataDeletionTask: jest.fn(), - checkDataDeleteStatus: jest.fn(), - getDeleteRegulationCreationDate: jest.fn(), - getDeleteRegulationId: jest.fn(), - isDataRecorded: jest.fn(), - getMetaMetricsId: jest.fn(), - createEventBuilder: jest.fn(), - }; + const mockMetrics = createMockUseAnalyticsHook(); const initialState = { security: { @@ -82,7 +71,7 @@ describe('usePromptSeedlessRelogin', () => { store.clearActions(); // Setup mocks - mockUseMetrics.mockReturnValue(mockMetrics); + mockUseAnalytics.mockReturnValue(mockMetrics); mockUseSignOut.mockReturnValue({ signOut: mockSignOut }); mockDeleteWallet.mockResolvedValue(undefined); mockClearHistory.mockReturnValue({ diff --git a/app/components/hooks/SeedlessHooks/usePromptSeedlessRelogin.ts b/app/components/hooks/SeedlessHooks/usePromptSeedlessRelogin.ts index f07bd51ce31..2dd0f841c23 100644 --- a/app/components/hooks/SeedlessHooks/usePromptSeedlessRelogin.ts +++ b/app/components/hooks/SeedlessHooks/usePromptSeedlessRelogin.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useMetrics } from '../useMetrics'; +import { useAnalytics } from '../useAnalytics/useAnalytics'; import { RootState } from '../../../reducers'; import { useSignOut } from '../../../util/identity/hooks/useAuthentication'; import Routes from '../../../constants/navigation/Routes'; @@ -12,7 +12,7 @@ import { Authentication } from '../../../core'; import Logger from '../../../util/Logger'; const usePromptSeedlessRelogin = () => { - const metrics = useMetrics(); + const metrics = useAnalytics(); const dispatch = useDispatch(); const [isDeletingInProgress, setIsDeletingInProgress] = useState(false); diff --git a/app/components/hooks/useBuildPortfolioUrl.test.ts b/app/components/hooks/useBuildPortfolioUrl.test.ts index 1e2b12a7246..22e975d0b19 100644 --- a/app/components/hooks/useBuildPortfolioUrl.test.ts +++ b/app/components/hooks/useBuildPortfolioUrl.test.ts @@ -1,7 +1,8 @@ import { renderHook } from '@testing-library/react-hooks'; import { useSelector } from 'react-redux'; import { useBuildPortfolioUrl } from './useBuildPortfolioUrl'; -import { useMetrics } from './useMetrics'; +import { useAnalytics } from './useAnalytics/useAnalytics'; +import { createMockUseAnalyticsHook } from '../../util/test/analyticsMock'; import { buildPortfolioUrl } from '../../util/browser'; // Mock dependencies @@ -9,9 +10,7 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -jest.mock('./useMetrics', () => ({ - useMetrics: jest.fn(), -})); +jest.mock('./useAnalytics/useAnalytics'); jest.mock('../../util/browser', () => ({ buildPortfolioUrl: jest.fn(), @@ -22,7 +21,7 @@ describe('useBuildPortfolioUrl', () => { const mockUseSelector = useSelector as jest.MockedFunction< typeof useSelector >; - const mockUseMetrics = useMetrics as jest.MockedFunction; + const mockUseAnalytics = jest.mocked(useAnalytics); const mockBuildPortfolioUrl = buildPortfolioUrl as jest.MockedFunction< typeof buildPortfolioUrl >; @@ -31,19 +30,11 @@ describe('useBuildPortfolioUrl', () => { jest.clearAllMocks(); // Setup default mocks - mockUseMetrics.mockReturnValue({ - isEnabled: mockIsEnabled, - trackEvent: jest.fn(), - enable: jest.fn(), - addTraitsToUser: jest.fn(), - createDataDeletionTask: jest.fn(), - checkDataDeleteStatus: jest.fn(), - getDeleteRegulationCreationDate: jest.fn(), - getDeleteRegulationId: jest.fn(), - isDataRecorded: jest.fn(), - getMetaMetricsId: jest.fn(), - createEventBuilder: jest.fn(), - }); + mockUseAnalytics.mockReturnValue( + createMockUseAnalyticsHook({ + isEnabled: mockIsEnabled, + }), + ); }); it('should build portfolio URL with metrics enabled and marketing enabled', () => { diff --git a/app/components/hooks/useBuildPortfolioUrl.ts b/app/components/hooks/useBuildPortfolioUrl.ts index d2179a79007..3b2b665e297 100644 --- a/app/components/hooks/useBuildPortfolioUrl.ts +++ b/app/components/hooks/useBuildPortfolioUrl.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { useSelector } from 'react-redux'; -import { useMetrics } from './useMetrics'; +import { useAnalytics } from './useAnalytics/useAnalytics'; import { buildPortfolioUrl } from '../../util/browser'; import type { RootState } from '../../reducers'; @@ -20,7 +20,7 @@ import type { RootState } from '../../reducers'; * }); */ export const useBuildPortfolioUrl = () => { - const { isEnabled } = useMetrics(); + const { isEnabled } = useAnalytics(); const isDataCollectionForMarketingEnabled = useSelector( (state: RootState) => state.security.dataCollectionForMarketing, ); diff --git a/app/components/hooks/useNftDetection.test.ts b/app/components/hooks/useNftDetection.test.ts index 2873335d0a1..4d2832cdad5 100644 --- a/app/components/hooks/useNftDetection.test.ts +++ b/app/components/hooks/useNftDetection.test.ts @@ -3,7 +3,9 @@ import { useSelector, useDispatch } from 'react-redux'; import { useNftDetection } from './useNftDetection'; import Engine from '../../core/Engine'; import { endTrace, trace } from '../../util/trace'; -import { MetaMetricsEvents, useMetrics } from './useMetrics'; +import { MetaMetricsEvents } from '../../core/Analytics'; +import { useAnalytics } from './useAnalytics/useAnalytics'; +import { createMockUseAnalyticsHook } from '../../util/test/analyticsMock'; import { useNftDetectionChainIds } from './useNftDetectionChainIds'; import { prepareNftDetectionEvents } from '../../util/assets'; import { getDecimalChainId } from '../../util/networks'; @@ -31,8 +33,8 @@ jest.mock('../../util/trace', () => ({ }, })); -jest.mock('./useMetrics', () => ({ - useMetrics: jest.fn(), +jest.mock('./useAnalytics/useAnalytics'); +jest.mock('../../core/Analytics', () => ({ MetaMetricsEvents: { COLLECTIBLE_ADDED: 'Collectible Added', }, @@ -73,7 +75,7 @@ describe('useNftDetection', () => { const mockUseDispatch = useDispatch as jest.MockedFunction< typeof useDispatch >; - const mockUseMetrics = useMetrics as jest.MockedFunction; + const mockUseAnalytics = jest.mocked(useAnalytics); const mockUseNftDetectionChainIds = useNftDetectionChainIds as jest.MockedFunction< typeof useNftDetectionChainIds @@ -134,7 +136,6 @@ describe('useNftDetection', () => { // Setup useSelector mock mockUseSelector.mockReturnValue(mockSelectedAddress); - // Setup useMetrics mock mockAddProperties.mockReturnThis(); mockBuild.mockReturnValue({ event: 'test-event', properties: {} }); mockCreateEventBuilder.mockReturnValue({ @@ -142,19 +143,12 @@ describe('useNftDetection', () => { build: mockBuild, }); - mockUseMetrics.mockReturnValue({ - trackEvent: mockTrackEvent, - createEventBuilder: mockCreateEventBuilder, - isEnabled: jest.fn(), - enable: jest.fn(), - addTraitsToUser: jest.fn(), - createDataDeletionTask: jest.fn(), - checkDataDeleteStatus: jest.fn(), - getDeleteRegulationCreationDate: jest.fn(), - getDeleteRegulationId: jest.fn(), - isDataRecorded: jest.fn(), - getMetaMetricsId: jest.fn(), - }); + mockUseAnalytics.mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + ); // Setup useNftDetectionChainIds mock mockUseNftDetectionChainIds.mockReturnValue(mockChainIds); diff --git a/app/components/hooks/useNftDetection.ts b/app/components/hooks/useNftDetection.ts index 6ecb5c77be2..aad2d2c171e 100644 --- a/app/components/hooks/useNftDetection.ts +++ b/app/components/hooks/useNftDetection.ts @@ -4,7 +4,8 @@ import { cloneDeep } from 'lodash'; import Engine from '../../core/Engine'; import { selectSelectedInternalAccountFormattedAddress } from '../../selectors/accountsController'; import { endTrace, trace, TraceName } from '../../util/trace'; -import { MetaMetricsEvents, useMetrics } from './useMetrics'; +import { MetaMetricsEvents } from '../../core/Analytics'; +import { useAnalytics } from './useAnalytics/useAnalytics'; import { useNftDetectionChainIds } from './useNftDetectionChainIds'; import { prepareNftDetectionEvents } from '../../util/assets'; import { getDecimalChainId } from '../../util/networks'; @@ -21,7 +22,7 @@ import { */ export const useNftDetection = () => { const dispatch = useDispatch(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const abortControllerRef = useRef(null); const selectedAddress = useSelector( diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 8da0d19d589..fc561b30564 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -110,6 +110,7 @@ const Routes = { WHATS_HAPPENING_DETAIL: 'WhatsHappeningDetailView', SITES_FULL_VIEW: 'SitesFullView', EXPLORE_SEARCH: 'ExploreSearch', + EXPLORE_SECTION_RESULTS_FULL_VIEW: 'ExploreSectionResultsFullView', REWARDS_ONBOARDING_FLOW: 'RewardsOnboardingFlow', REWARDS_ONBOARDING_INTRO: 'RewardsOnboardingIntro', REWARDS_ONBOARDING_1: 'RewardsOnboarding1', diff --git a/app/controllers/perps/constants/hyperLiquidConfig.test.ts b/app/controllers/perps/constants/hyperLiquidConfig.test.ts new file mode 100644 index 00000000000..e1c6ca30aae --- /dev/null +++ b/app/controllers/perps/constants/hyperLiquidConfig.test.ts @@ -0,0 +1,28 @@ +import { HIP3_ASSET_MARKET_TYPES } from './hyperLiquidConfig'; + +describe('HIP3_ASSET_MARKET_TYPES', () => { + it('classifies URNM as commodity (Sprott Uranium Miners ETF)', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:URNM']).toBe('commodity'); + }); + + it('classifies USAR as equity (US equity fund)', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:USAR']).toBe('equity'); + }); + + it('classifies known equities correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:TSLA']).toBe('equity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:NVDA']).toBe('equity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:AAPL']).toBe('equity'); + }); + + it('classifies known commodities correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:GOLD']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:SILVER']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:URANIUM']).toBe('commodity'); + }); + + it('classifies known forex pairs correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:EUR']).toBe('forex'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:JPY']).toBe('forex'); + }); +}); diff --git a/app/controllers/perps/constants/hyperLiquidConfig.ts b/app/controllers/perps/constants/hyperLiquidConfig.ts index 1159c2277c1..bdfc63934a2 100644 --- a/app/controllers/perps/constants/hyperLiquidConfig.ts +++ b/app/controllers/perps/constants/hyperLiquidConfig.ts @@ -340,13 +340,13 @@ export const HIP3_ASSET_MARKET_TYPES: Record< 'xyz:HYUNDAI': 'equity', 'xyz:KIOXIA': 'equity', 'xyz:HIMS': 'equity', - 'xyz:URNM': 'equity', 'xyz:EWY': 'equity', 'xyz:EWJ': 'equity', 'xyz:SP500': 'equity', 'xyz:JP225': 'equity', 'xyz:KR200': 'equity', 'xyz:VIX': 'equity', + 'xyz:USAR': 'equity', // xyz DEX - Commodities 'xyz:GOLD': 'commodity', @@ -355,7 +355,7 @@ export const HIP3_ASSET_MARKET_TYPES: Record< 'xyz:COPPER': 'commodity', 'xyz:ALUMINIUM': 'commodity', 'xyz:URANIUM': 'commodity', - 'xyz:USAR': 'commodity', + 'xyz:URNM': 'commodity', 'xyz:NATGAS': 'commodity', 'xyz:PLATINUM': 'commodity', 'xyz:PALLADIUM': 'commodity', diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 87b51185034..b9453dbcf22 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -626,6 +626,9 @@ enum EVENT_NAME { // Trending TRENDING_FEED_VIEWED = 'Trending Feed Viewed', + // Explore Search + EXPLORE_SEARCH_INTERACTED = 'Explore Search Interacted', + // Market Insights MARKET_INSIGHTS_CARD_SCROLLED_TO_VIEW = 'Market Insights Card Scrolled to View', MARKET_INSIGHTS_OPENED = 'Market Insights Opened', @@ -1669,6 +1672,8 @@ const events = { TRENDING_FEED_VIEWED: generateOpt(EVENT_NAME.TRENDING_FEED_VIEWED), + EXPLORE_SEARCH_INTERACTED: generateOpt(EVENT_NAME.EXPLORE_SEARCH_INTERACTED), + // Share SHARE_ACTION: generateOpt(EVENT_NAME.SHARE_ACTION), diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index bd86585b793..b137c052f63 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -15836,6 +15836,7 @@ describe('RewardsController', () => { "offDeviceSubscriptionAccounts": {}, "ondoCampaignLeaderboard": {}, "ondoCampaignLeaderboardPositions": {}, + "ondoCampaignPortfolio": {}, "pointsEstimateHistory": [], "pointsEvents": {}, "seasonStatuses": {}, @@ -15860,6 +15861,7 @@ describe('RewardsController', () => { "offDeviceSubscriptionAccounts": {}, "ondoCampaignLeaderboard": {}, "ondoCampaignLeaderboardPositions": {}, + "ondoCampaignPortfolio": {}, "pointsEstimateHistory": [], "pointsEvents": {}, "rewardsEnvUrl": null, @@ -15889,6 +15891,7 @@ describe('RewardsController', () => { "offDeviceSubscriptionAccounts": {}, "ondoCampaignLeaderboard": {}, "ondoCampaignLeaderboardPositions": {}, + "ondoCampaignPortfolio": {}, "pointsEvents": {}, "rewardsEnvUrl": null, "seasonStatuses": {}, @@ -16884,6 +16887,7 @@ describe('RewardsController', () => { termsAndConditions: null, excludedRegions: [], statusLabel: 'Active', + image: null, details: null, featured: false, }, @@ -16896,9 +16900,14 @@ describe('RewardsController', () => { lastFetched: Date.now(), } as CampaignParticipantStatusState; initialState.ondoCampaignLeaderboardPositions[campaignCompositeKey] = { - projected_tier: 'MID', + projectedTier: 'MID', rank: 5, - total_in_tier: 100, + totalInTier: 100, + rateOfReturn: 0, + currentUsdValue: 0, + totalUsdDeposited: 0, + netDeposit: 0, + computedAt: '', lastFetched: Date.now(), } as CampaignLeaderboardPositionState; @@ -16949,6 +16958,7 @@ describe('RewardsController', () => { termsAndConditions: null, excludedRegions: [], statusLabel: 'Active', + image: null, details: null, featured: false, }, @@ -16961,6 +16971,7 @@ describe('RewardsController', () => { termsAndConditions: null, excludedRegions: [], statusLabel: 'Active', + image: null, details: null, featured: false, }, @@ -16983,15 +16994,25 @@ describe('RewardsController', () => { lastFetched: Date.now(), } as CampaignParticipantStatusState; initialState.ondoCampaignLeaderboardPositions[campaignKey1] = { - projected_tier: 'STARTER', + projectedTier: 'STARTER', rank: 10, - total_in_tier: 200, + totalInTier: 200, + rateOfReturn: 0, + currentUsdValue: 0, + totalUsdDeposited: 0, + netDeposit: 0, + computedAt: '', lastFetched: Date.now(), } as CampaignLeaderboardPositionState; initialState.ondoCampaignLeaderboardPositions[otherCampaignKey] = { - projected_tier: 'UPPER', + projectedTier: 'UPPER', rank: 1, - total_in_tier: 50, + totalInTier: 50, + rateOfReturn: 0, + currentUsdValue: 0, + totalUsdDeposited: 0, + netDeposit: 0, + computedAt: '', lastFetched: Date.now(), } as CampaignLeaderboardPositionState; @@ -18883,7 +18904,7 @@ describe('RewardsController', () => { ...getRewardsControllerDefaultState(), campaigns: { CAMPAIGNS_CACHE_KEY: { - campaigns: [mockCachedCampaign], + campaigns: [{ ...mockCachedCampaign, image: null }], lastFetched: recentTime, }, }, @@ -18892,7 +18913,7 @@ describe('RewardsController', () => { const result = await controller.getCampaigns(mockSubscriptionId); - expect(result).toEqual([mockCachedCampaign]); + expect(result).toEqual([{ ...mockCachedCampaign, image: null }]); expect(mockMessenger.call).not.toHaveBeenCalled(); }); @@ -18907,7 +18928,7 @@ describe('RewardsController', () => { ...getRewardsControllerDefaultState(), campaigns: { CAMPAIGNS_CACHE_KEY: { - campaigns: [staleCampaign], + campaigns: [{ ...staleCampaign, image: null }], lastFetched: staleTime, }, }, @@ -19041,6 +19062,56 @@ describe('RewardsController', () => { await ctrl.optInToCampaign(mockCampaignId, mockSubscriptionId); expect(ctrl.state.campaignParticipantStatus[cacheKey]).toBeUndefined(); }); + + it('clears cached portfolio for the campaign on opt-in', async () => { + const cacheKey = `${mockSubscriptionId}:${mockCampaignId}`; + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: { + ...getRewardsControllerDefaultState(), + ondoCampaignPortfolio: { + [cacheKey]: { + positions: [], + summary: { + totalCurrentValue: '1', + totalCostBasis: '1', + totalUsdDeposited: '1', + netDeposit: '1', + portfolioPnl: '0', + portfolioPnlPercent: '0', + }, + computedAt: '2024-03-20T12:00:00.000Z', + lastFetched: Date.now(), + }, + }, + }, + }); + + mockMessenger.call.mockResolvedValue(mockStatus); + + await ctrl.optInToCampaign(mockCampaignId, mockSubscriptionId); + + expect(ctrl.state.ondoCampaignPortfolio[cacheKey]).toBeUndefined(); + }); + + it('publishes portfolioPositionInvalidated on first opt-in', async () => { + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + }); + + mockMessenger.call.mockResolvedValue(mockStatus); + + await ctrl.optInToCampaign(mockCampaignId, mockSubscriptionId); + + expect(mockMessenger.publish).toHaveBeenCalledWith( + 'RewardsController:portfolioPositionInvalidated', + { + campaignId: mockCampaignId, + subscriptionId: mockSubscriptionId, + }, + ); + }); }); describe('getCampaignParticipantStatus', () => { @@ -19189,10 +19260,10 @@ describe('RewardsController', () => { let mockMessenger: jest.Mocked; const mockCampaignId = 'campaign-ondo-123'; const mockLeaderboard = { - campaign_id: mockCampaignId, - computed_at: '2024-03-20T12:00:00.000Z', + campaignId: mockCampaignId, + computedAt: '2024-03-20T12:00:00.000Z', tiers: { - STARTER: { entries: [], total_participants: 100 }, + STARTER: { entries: [], totalParticipants: 100 }, }, }; @@ -19220,8 +19291,8 @@ describe('RewardsController', () => { await disabledController.getOndoCampaignLeaderboard(mockCampaignId); expect(result).toEqual({ - campaign_id: mockCampaignId, - computed_at: '', + campaignId: mockCampaignId, + computedAt: '', tiers: {}, }); expect(mockMessenger.call).not.toHaveBeenCalled(); @@ -19288,14 +19359,14 @@ describe('RewardsController', () => { const mockCampaignId = 'campaign-ondo-456'; const mockSubscriptionId = 'sub-789'; const mockPosition = { - projected_tier: 'MID', + projectedTier: 'MID', rank: 5, - total_in_tier: 150, - rate_of_return: 0.15, - current_usd_value: 12500.5, - total_usd_deposited: 10000.0, - net_deposit: 8500.0, - computed_at: '2024-03-20T12:00:00.000Z', + totalInTier: 150, + rateOfReturn: 0.15, + currentUsdValue: 12500.5, + totalUsdDeposited: 10000.0, + netDeposit: 8500.0, + computedAt: '2024-03-20T12:00:00.000Z', }; beforeEach(() => { @@ -19445,6 +19516,152 @@ describe('RewardsController', () => { }); }); + describe('getOndoCampaignPortfolioPosition', () => { + let mockMessenger: jest.Mocked; + const mockCampaignId = 'campaign-ondo-portfolio'; + const mockSubscriptionId = 'sub-portfolio-1'; + const mockPortfolio = { + positions: [ + { + tokenSymbol: 'AAPL', + tokenName: 'Apple Inc.', + tokenAsset: + 'eip155:1/erc20:0x14c3abf95cb9c93a8b82c1cdcb76d72cb87b2d4c', + units: '10', + costBasis: '1000.000000', + avgCostPerUnit: '100.000000', + currentPrice: '110.000000', + currentValue: '1100.000000', + unrealizedPnl: '100.000000', + unrealizedPnlPercent: '0.1', + }, + ], + summary: { + totalCurrentValue: '1100.000000', + totalCostBasis: '1000.000000', + totalUsdDeposited: '1000.000000', + netDeposit: '1000.000000', + portfolioPnl: '100.000000', + portfolioPnlPercent: '0.1', + }, + computedAt: '2024-03-20T12:00:00.000Z', + }; + + beforeEach(() => { + mockMessenger = { + subscribe: jest.fn(), + call: jest.fn(), + registerActionHandler: jest.fn(), + unregisterActionHandler: jest.fn(), + publish: jest.fn(), + clearEventSubscriptions: jest.fn(), + registerInitialEventPayload: jest.fn(), + unsubscribe: jest.fn(), + } as unknown as jest.Mocked; + }); + + it('returns null when rewards feature flag is disabled', async () => { + const disabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); + + const result = await disabledController.getOndoCampaignPortfolioPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + + it('fetches portfolio from API and caches result', async () => { + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + }); + + mockMessenger.call.mockResolvedValue(mockPortfolio); + + const result = await ctrl.getOndoCampaignPortfolioPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getOndoCampaignPortfolioPosition', + mockCampaignId, + mockSubscriptionId, + ); + expect(result).toEqual(mockPortfolio); + const cacheKey = `${mockSubscriptionId}:${mockCampaignId}`; + expect(ctrl.state.ondoCampaignPortfolio[cacheKey]).toBeDefined(); + }); + + it('returns cached portfolio when cache is fresh', async () => { + const recentTime = Date.now() - 60000; + const cacheKey = `${mockSubscriptionId}:${mockCampaignId}`; + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: { + ...getRewardsControllerDefaultState(), + ondoCampaignPortfolio: { + [cacheKey]: { + ...mockPortfolio, + lastFetched: recentTime, + }, + }, + }, + }); + + const result = await ctrl.getOndoCampaignPortfolioPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toEqual(mockPortfolio); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + + it('returns null when API returns null and does not cache', async () => { + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + }); + + mockMessenger.call.mockResolvedValue(null); + + const result = await ctrl.getOndoCampaignPortfolioPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + const cacheKey = `${mockSubscriptionId}:${mockCampaignId}`; + expect(ctrl.state.ondoCampaignPortfolio[cacheKey]).toBeUndefined(); + }); + + it('logs when fetching fresh portfolio', async () => { + const ctrl = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + }); + + mockMessenger.call.mockResolvedValue(mockPortfolio); + mockLogger.log.mockClear(); + + await ctrl.getOndoCampaignPortfolioPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + 'RewardsController: Fetching fresh campaign portfolio via API call', + ); + }); + }); + describe('getClientVersionRequirements', () => { it('fetches version requirements from the data service', async () => { const mockRequirements = { diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index b9b8de8fdde..dc5d0d5e886 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -25,6 +25,8 @@ import { type CampaignParticipantStatusDto, type CampaignLeaderboardDto, type CampaignLeaderboardPositionDto, + type OndoGmPortfolioDto, + type OndoGmPortfolioState, type PointsEstimateHistoryEntry, ClaimRewardDto, PointsEventsDtoState, @@ -115,6 +117,9 @@ const ONDO_CAMPAIGN_LEADERBOARD_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes // Campaign leaderboard position cache threshold const ONDO_CAMPAIGN_LEADERBOARD_POSITION_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes +// Campaign portfolio position cache threshold +const ONDO_CAMPAIGN_PORTFOLIO_POSITION_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes + // Points events cache threshold (first page only) const POINTS_EVENTS_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute cache @@ -212,6 +217,12 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + ondoCampaignPortfolio: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, pointsEstimateHistory: { includeInStateLogs: true, persist: true, @@ -244,6 +255,7 @@ export const getRewardsControllerDefaultState = (): RewardsControllerState => ({ campaignParticipantStatus: {}, ondoCampaignLeaderboard: {}, ondoCampaignLeaderboardPositions: {}, + ondoCampaignPortfolio: {}, pointsEstimateHistory: [], rewardsEnvUrl: null, }); @@ -630,6 +642,10 @@ export class RewardsController extends BaseController< 'RewardsController:getOndoCampaignLeaderboardPosition', this.getOndoCampaignLeaderboardPosition.bind(this), ); + this.messenger.registerActionHandler( + 'RewardsController:getOndoCampaignPortfolioPosition', + this.getOndoCampaignPortfolioPosition.bind(this), + ); this.messenger.registerActionHandler( 'RewardsController:claimReward', this.claimReward.bind(this), @@ -2680,6 +2696,11 @@ export class RewardsController extends BaseController< delete state.ondoCampaignLeaderboardPositions[key]; } }); + Object.keys(state.ondoCampaignPortfolio).forEach((key) => { + if (key.startsWith(`${subscriptionId}:`)) { + delete state.ondoCampaignPortfolio[key]; + } + }); }); Logger.log('RewardsController: Logout completed successfully'); @@ -3490,10 +3511,16 @@ export class RewardsController extends BaseController< campaignId, )) as CampaignParticipantStatusDto; }, subscriptionId); - // Invalidate the participant status cache and leaderboard position cache + // Invalidate the participant status cache and leaderboard / portfolio cache this.update((state) => { delete state.campaignParticipantStatus[key]; delete state.ondoCampaignLeaderboardPositions[key]; + delete ( + state.ondoCampaignPortfolio as Record< + string, + OndoGmPortfolioState | undefined + > + )[key]; }); // Only emit if the user wasn't already opted in, to avoid redundant refetches if (!wasAlreadyOptedIn) { @@ -3508,6 +3535,10 @@ export class RewardsController extends BaseController< subscriptionId, }, ); + this.messenger.publish('RewardsController:portfolioPositionInvalidated', { + campaignId, + subscriptionId, + }); } return result; } @@ -3575,7 +3606,7 @@ export class RewardsController extends BaseController< campaignId: string, ): Promise { if (!this.isRewardsFeatureEnabled()) { - return { campaign_id: campaignId, computed_at: '', tiers: {} }; + return { campaignId, computedAt: '', tiers: {} }; } const result = await wrapWithCache({ @@ -3586,8 +3617,8 @@ export class RewardsController extends BaseController< if (!cached) return undefined; return { payload: { - campaign_id: cached.campaign_id, - computed_at: cached.computed_at, + campaignId: cached.campaignId, + computedAt: cached.computedAt, tiers: cached.tiers, }, lastFetched: cached.lastFetched, @@ -3605,8 +3636,8 @@ export class RewardsController extends BaseController< writeCache: (k, payload) => { this.update((state) => { state.ondoCampaignLeaderboard[k] = { - campaign_id: payload.campaign_id, - computed_at: payload.computed_at, + campaignId: payload.campaignId, + computedAt: payload.computedAt, tiers: payload.tiers, lastFetched: Date.now(), }; @@ -3644,14 +3675,14 @@ export class RewardsController extends BaseController< } return { payload: { - projected_tier: cached.projected_tier, + projectedTier: cached.projectedTier, rank: cached.rank, - total_in_tier: cached.total_in_tier, - rate_of_return: cached.rate_of_return, - current_usd_value: cached.current_usd_value, - total_usd_deposited: cached.total_usd_deposited, - net_deposit: cached.net_deposit, - computed_at: cached.computed_at, + totalInTier: cached.totalInTier, + rateOfReturn: cached.rateOfReturn, + currentUsdValue: cached.currentUsdValue, + totalUsdDeposited: cached.totalUsdDeposited, + netDeposit: cached.netDeposit, + computedAt: cached.computedAt, }, lastFetched: cached.lastFetched, }; @@ -3678,14 +3709,14 @@ export class RewardsController extends BaseController< } else { this.update((state) => { state.ondoCampaignLeaderboardPositions[k] = { - projected_tier: payload.projected_tier, + projectedTier: payload.projectedTier, rank: payload.rank, - total_in_tier: payload.total_in_tier, - rate_of_return: payload.rate_of_return, - current_usd_value: payload.current_usd_value, - total_usd_deposited: payload.total_usd_deposited, - net_deposit: payload.net_deposit, - computed_at: payload.computed_at, + totalInTier: payload.totalInTier, + rateOfReturn: payload.rateOfReturn, + currentUsdValue: payload.currentUsdValue, + totalUsdDeposited: payload.totalUsdDeposited, + netDeposit: payload.netDeposit, + computedAt: payload.computedAt, lastFetched: Date.now(), }; }); @@ -3695,6 +3726,78 @@ export class RewardsController extends BaseController< return result; } + /** + * Get the current user's Ondo GM portfolio for a campaign. + * This is an authenticated endpoint. + * Results are cached for 5 minutes under + * `state.ondoCampaignPortfolio[subscriptionId:campaignId]` as + * {@link OndoGmPortfolioState}. Null API responses are not written to the cache. + * @param campaignId - The campaign ID to get portfolio for. + * @param subscriptionId - The subscription ID for authentication. + * @returns The portfolio, or null if not found. + */ + async getOndoCampaignPortfolioPosition( + campaignId: string, + subscriptionId: string, + ): Promise { + if (!this.isRewardsFeatureEnabled()) { + return null; + } + + const key = `${subscriptionId}:${campaignId}`; + const result = await wrapWithCache({ + key, + ttl: ONDO_CAMPAIGN_PORTFOLIO_POSITION_CACHE_THRESHOLD_MS, + readCache: (k) => { + const cached = this.state.ondoCampaignPortfolio[k]; + if (!cached) { + return undefined; + } + return { + payload: { + positions: cached.positions, + summary: cached.summary, + computedAt: cached.computedAt, + }, + lastFetched: cached.lastFetched, + }; + }, + fetchFresh: async () => + this.#withAuthRetry(async () => { + Logger.log( + 'RewardsController: Fetching fresh campaign portfolio via API call', + ); + return (await this.messenger.call( + 'RewardsDataService:getOndoCampaignPortfolioPosition', + campaignId, + subscriptionId, + )) as OndoGmPortfolioDto | null; + }, subscriptionId), + writeCache: (k, payload) => { + if (payload === null) { + this.update((state) => { + delete ( + state.ondoCampaignPortfolio as Record< + string, + OndoGmPortfolioState | undefined + > + )[k]; + }); + return; + } + this.update((state) => { + state.ondoCampaignPortfolio[k] = { + positions: payload.positions, + summary: payload.summary, + computedAt: payload.computedAt, + lastFetched: Date.now(), + }; + }); + }, + }); + return result; + } + /** * Claim a reward * @param rewardId - The reward ID @@ -3928,6 +4031,15 @@ export class RewardsController extends BaseController< delete state.ondoCampaignLeaderboardPositions[key]; } }); + const portfolioByKeySeason = state.ondoCampaignPortfolio as Record< + string, + OndoGmPortfolioState | undefined + >; + Object.keys(portfolioByKeySeason).forEach((key) => { + if (key.startsWith(`${subscriptionId}:`)) { + delete portfolioByKeySeason[key]; + } + }); }); } else { // Invalidate all seasons for this subscription @@ -3974,6 +4086,15 @@ export class RewardsController extends BaseController< delete state.ondoCampaignLeaderboardPositions[key]; } }); + const portfolioByKeyAllSeasons = state.ondoCampaignPortfolio as Record< + string, + OndoGmPortfolioState | undefined + >; + Object.keys(portfolioByKeyAllSeasons).forEach((key) => { + if (key.startsWith(`${subscriptionId}:`)) { + delete portfolioByKeyAllSeasons[key]; + } + }); }); } diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index b2f996bcae7..7bd2298a0d1 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -4494,11 +4494,11 @@ describe('RewardsDataService', () => { describe('getOndoCampaignLeaderboard', () => { const mockCampaignId = 'campaign-ondo-123'; const mockLeaderboard = { - campaign_id: mockCampaignId, - computed_at: '2024-03-20T12:00:00.000Z', + campaignId: mockCampaignId, + computedAt: '2024-03-20T12:00:00.000Z', tiers: { - STARTER: { entries: [], total_participants: 100 }, - MID: { entries: [], total_participants: 50 }, + STARTER: { entries: [], totalParticipants: 100 }, + MID: { entries: [], totalParticipants: 50 }, }, }; @@ -4535,14 +4535,14 @@ describe('RewardsDataService', () => { const mockSubscriptionId = 'sub-789'; const mockToken = 'test-bearer-token'; const mockPosition = { - projected_tier: 'MID', + projectedTier: 'MID', rank: 5, - total_in_tier: 150, - rate_of_return: 0.15, - current_usd_value: 12500.5, - total_usd_deposited: 10000.0, - net_deposit: 8500.0, - computed_at: '2024-03-20T12:00:00.000Z', + totalInTier: 150, + rateOfReturn: 0.15, + currentUsdValue: 12500.5, + totalUsdDeposited: 10000.0, + netDeposit: 8500.0, + computedAt: '2024-03-20T12:00:00.000Z', }; beforeEach(() => { @@ -4597,6 +4597,89 @@ describe('RewardsDataService', () => { }); }); + describe('getOndoCampaignPortfolioPosition', () => { + const mockCampaignId = 'campaign-ondo-portfolio'; + const mockSubscriptionId = 'sub-portfolio-1'; + const mockToken = 'test-bearer-token'; + const mockPortfolio = { + positions: [ + { + tokenSymbol: 'AAPL', + tokenName: 'Apple Inc.', + tokenAsset: + 'eip155:1/erc20:0x14c3abf95cb9c93a8b82c1cdcb76d72cb87b2d4c', + units: '10', + costBasis: '1000.000000', + avgCostPerUnit: '100.000000', + currentPrice: '110.000000', + currentValue: '1100.000000', + unrealizedPnl: '100.000000', + unrealizedPnlPercent: '0.1', + }, + ], + summary: { + totalCurrentValue: '1100.000000', + totalCostBasis: '1000.000000', + totalUsdDeposited: '1000.000000', + netDeposit: '1000.000000', + portfolioPnl: '100.000000', + portfolioPnlPercent: '0.1', + }, + computedAt: '2024-03-20T12:00:00.000Z', + }; + + beforeEach(() => { + mockGetSubscriptionToken.mockResolvedValue({ + success: true, + token: mockToken, + }); + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockPortfolio), + } as unknown as Response); + }); + + it('calls the correct authenticated endpoint with GET and returns portfolio', async () => { + const result = await service.getOndoCampaignPortfolioPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockFetch).toHaveBeenCalledWith( + `https://uat.rewards.test/ondo-gm/${mockCampaignId}/portfolio/me`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'rewards-access-token': mockToken, + }), + }), + ); + expect(result).toEqual(mockPortfolio); + }); + + it('returns null when response status is 404', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 } as Response); + + const result = await service.getOndoCampaignPortfolioPosition( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + }); + + it('throws when response is not ok with non-404 status', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 } as Response); + + await expect( + service.getOndoCampaignPortfolioPosition( + mockCampaignId, + mockSubscriptionId, + ), + ).rejects.toThrow('Get campaign portfolio position failed: 500'); + }); + }); + describe('getClientVersionRequirements', () => { const mockVersionRequirements = { minimumMobileVersion: '7.72.0', diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index 2be7a865b3a..cd76d49b8bd 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -30,6 +30,7 @@ import type { ClientVersionRequirementDto, CampaignLeaderboardDto, CampaignLeaderboardPositionDto, + OndoGmPortfolioDto, } from '../types'; import { getSubscriptionToken } from '../utils/multi-subscription-token-vault'; import Logger from '../../../../../util/Logger'; @@ -232,6 +233,11 @@ export interface RewardsDataServiceGetOndoCampaignLeaderboardPositionAction { handler: RewardsDataService['getOndoCampaignLeaderboardPosition']; } +export interface RewardsDataServiceGetOndoCampaignPortfolioPositionAction { + type: `${typeof SERVICE_NAME}:getOndoCampaignPortfolioPosition`; + handler: RewardsDataService['getOndoCampaignPortfolioPosition']; +} + export interface RewardsDataServiceGetRewardsEnvUrlAction { type: `${typeof SERVICE_NAME}:getRewardsEnvUrl`; handler: RewardsDataService['getRewardsEnvUrl']; @@ -286,7 +292,8 @@ export type RewardsDataServiceActions = | RewardsDataServiceGetCampaignParticipantStatusAction | RewardsDataServiceGetClientVersionRequirementsAction | RewardsDataServiceGetOndoCampaignLeaderboardAction - | RewardsDataServiceGetOndoCampaignLeaderboardPositionAction; + | RewardsDataServiceGetOndoCampaignLeaderboardPositionAction + | RewardsDataServiceGetOndoCampaignPortfolioPositionAction; export type RewardsDataServiceMessenger = Messenger< typeof SERVICE_NAME, @@ -441,6 +448,10 @@ export class RewardsDataService { `${SERVICE_NAME}:getOndoCampaignLeaderboardPosition`, this.getOndoCampaignLeaderboardPosition.bind(this), ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getOndoCampaignPortfolioPosition`, + this.getOndoCampaignPortfolioPosition.bind(this), + ); this.#messenger.registerActionHandler( `${SERVICE_NAME}:getRewardsEnvUrl`, this.getRewardsEnvUrl.bind(this), @@ -1463,4 +1474,34 @@ export class RewardsDataService { return (await response.json()) as CampaignLeaderboardPositionDto; } + + /** + * Get the current user's Ondo GM portfolio for a campaign. + * This is an authenticated endpoint. + * @param campaignId - The campaign ID to get portfolio for. + * @param subscriptionId - The subscription ID for authentication. + * @returns The portfolio, or null if not found (404). + */ + async getOndoCampaignPortfolioPosition( + campaignId: string, + subscriptionId: string, + ): Promise { + const response = await this.makeRequest( + `/ondo-gm/${campaignId}/portfolio/me`, + { method: 'GET' }, + subscriptionId, + ); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error( + `Get campaign portfolio position failed: ${response.status}`, + ); + } + + return (await response.json()) as OndoGmPortfolioDto; + } } diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index 5b80f815e9a..f8965a438ef 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -168,18 +168,6 @@ export type OndoCampaignStepState = { iconName: string; }; -/** - * Serializable version of OndoCampaignPhase for state storage. - */ -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type OndoCampaignPhaseState = { - name: string; - daysLabel: string; - sortOrder: number; - steps: OndoCampaignStepState[]; - days?: number | null; -}; - /** * Serializable version of OndoCampaignHowItWorks for state storage. */ @@ -187,7 +175,7 @@ export type OndoCampaignPhaseState = { export type OndoCampaignHowItWorksState = { title: string; description: string; - phases: OndoCampaignPhaseState[]; + steps: OndoCampaignStepState[]; notes?: Json | null; }; @@ -205,7 +193,6 @@ export type ThemeImageState = { */ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type CampaignDetailsState = { - image: ThemeImageState; howItWorks: OndoCampaignHowItWorksState; }; @@ -223,6 +210,7 @@ export type CampaignDtoState = { termsAndConditions: Json | null; excludedRegions: string[]; statusLabel: string; + image: ThemeImageState | null; details: CampaignDetailsState | null; featured: boolean; }; @@ -261,13 +249,13 @@ export interface CampaignLeaderboardEntry { * The participant's referral code (used as identifier) * @example 'ABC123' */ - referral_code: string; + referralCode: string; /** * The rate of return as a decimal ratio (0.15 = 15%, -0.05 = -5%) * @example 0.15 */ - rate_of_return: number; + rateOfReturn: number; } /** @@ -283,7 +271,7 @@ export interface CampaignLeaderboardTier { * Total number of participants in this tier * @example 150 */ - total_participants: number; + totalParticipants: number; } /** @@ -295,13 +283,13 @@ export interface CampaignLeaderboardDto { * The campaign ID * @example '123e4567-e89b-12d3-a456-426614174000' */ - campaign_id: string; + campaignId: string; /** * When the leaderboard was last computed (ISO timestamp) * @example '2024-03-20T12:00:00.000Z' */ - computed_at: string; + computedAt: string; /** * Leaderboard data by tier name (e.g. STARTER, MID, UPPER) @@ -319,7 +307,7 @@ export interface CampaignLeaderboardPositionDto { * The user's projected tier based on net deposit * @example 'MID' */ - projected_tier: string; + projectedTier: string; /** * The user's rank within their tier @@ -331,57 +319,201 @@ export interface CampaignLeaderboardPositionDto { * Total number of participants in the user's tier * @example 150 */ - total_in_tier: number; + totalInTier: number; /** * The user's rate of return as a decimal ratio * @example 0.15 */ - rate_of_return: number; + rateOfReturn: number; /** * Current USD value of the user's positions * @example 12500.50 */ - current_usd_value: number; + currentUsdValue: number; /** * Total USD deposited by the user * @example 10000.00 */ - total_usd_deposited: number; + totalUsdDeposited: number; /** * Net deposit amount (deposits - withdrawals at cost basis) * @example 8500.00 */ - net_deposit: number; + netDeposit: number; /** * When the leaderboard was last computed (ISO timestamp) * @example '2024-03-20T12:00:00.000Z' */ - computed_at: string; + computedAt: string; } +/** + * Single position in GET /ondo-gm/:campaignId/portfolio/me + */ +export interface OndoGmPortfolioPositionDto { + /** + * @example 'AAPLon' + */ + tokenSymbol: string; + + /** + * @example 'Apple Inc.' + */ + tokenName: string; + + /** + * CAIP-19 asset type identifier for this position + * @example 'eip155:1/erc20:0x14c3abf95cb9c93a8b82c1cdcb76d72cb87b2d4c' + */ + tokenAsset: string; + + /** + * @example '45.2' + */ + units: string; + + /** + * @example '9040.000000' + */ + costBasis: string; + + /** + * @example '200.000000' + */ + avgCostPerUnit: string; + + /** + * @example '215.500000' + */ + currentPrice: string; + + /** + * @example '9740.600000' + */ + currentValue: string; + + /** + * @example '700.600000' + */ + unrealizedPnl: string; + + /** + * @example '0.0775' + */ + unrealizedPnlPercent: string; +} + +/** + * Portfolio summary in GET /ondo-gm/:campaignId/portfolio/me + */ +export interface OndoGmPortfolioSummaryDto { + /** + * @example '9740.600000' + */ + totalCurrentValue: string; + + /** + * @example '9040.000000' + */ + totalCostBasis: string; + + /** + * @example '9040.000000' + */ + totalUsdDeposited: string; + + /** + * @example '9040.000000' + */ + netDeposit: string; + + /** + * @example '700.600000' + */ + portfolioPnl: string; + + /** + * @example '0.0775' + */ + portfolioPnlPercent: string; +} + +/** + * Response DTO for GET /ondo-gm/:campaignId/portfolio/me + */ +export interface OndoGmPortfolioDto { + positions: OndoGmPortfolioPositionDto[]; + summary: OndoGmPortfolioSummaryDto; + + /** + * @example '2026-03-20T12:00:00.000Z' + */ + computedAt: string; +} + +/** + * Single cached portfolio row (mirrors {@link OndoGmPortfolioPositionDto}; explicit plain-object shape for cache / Json). + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type OndoGmPortfolioPositionState = { + tokenSymbol: string; + tokenName: string; + tokenAsset: string; + units: string; + costBasis: string; + avgCostPerUnit: string; + currentPrice: string; + currentValue: string; + unrealizedPnl: string; + unrealizedPnlPercent: string; +}; + +/** + * Cached portfolio summary (mirrors {@link OndoGmPortfolioSummaryDto}; explicit plain-object shape for cache / Json). + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type OndoGmPortfolioSummaryState = { + totalCurrentValue: string; + totalCostBasis: string; + totalUsdDeposited: string; + netDeposit: string; + portfolioPnl: string; + portfolioPnlPercent: string; +}; + +/** + * Cached portfolio payload (explicit shape for Json / StateConstraint compatibility). + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type OndoGmPortfolioState = { + positions: OndoGmPortfolioPositionState[]; + summary: OndoGmPortfolioSummaryState; + computedAt: string; + lastFetched: number; +}; + /** * State for cached leaderboard data in the controller */ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type CampaignLeaderboardState = { - campaign_id: string; - computed_at: string; - tiers: Record< - string, - { + campaignId: string; + computedAt: string; + tiers: { + [tierName: string]: { entries: { rank: number; - referral_code: string; - rate_of_return: number; + referralCode: string; + rateOfReturn: number; }[]; - total_participants: number; - } - >; + totalParticipants: number; + }; + }; lastFetched: number; }; @@ -390,14 +522,14 @@ export type CampaignLeaderboardState = { */ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type CampaignLeaderboardPositionFoundState = { - projected_tier: string; + projectedTier: string; rank: number; - total_in_tier: number; - rate_of_return: number; - current_usd_value: number; - total_usd_deposited: number; - net_deposit: number; - computed_at: string; + totalInTier: number; + rateOfReturn: number; + currentUsdValue: number; + totalUsdDeposited: number; + netDeposit: number; + computedAt: string; lastFetched: number; }; @@ -425,21 +557,10 @@ export interface OndoCampaignStep { iconName: string; } -export interface OndoCampaignPhase { - name: string; - daysLabel: string; - sortOrder: number; - steps: OndoCampaignStep[]; - /** - * Number of days in the phase, used to calculate phase cut-off dates - */ - days?: number | null; -} - export interface OndoCampaignHowItWorks { title: string; description: string; - phases: OndoCampaignPhase[]; + steps: OndoCampaignStep[]; notes?: Json | null; } @@ -1455,6 +1576,14 @@ export type RewardsControllerState = { ondoCampaignLeaderboardPositions: { [compositeId: string]: CampaignLeaderboardPositionState; }; + /** + * Ondo campaign portfolio keyed by compositeId (subscriptionId:campaignId). + * Each value is a cached successful GET /portfolio/me response plus {@link OndoGmPortfolioState.lastFetched}. + * Null API responses are not cached (unlike leaderboard position, which uses a not-found sentinel). + */ + ondoCampaignPortfolio: { + [compositeId: string]: OndoGmPortfolioState; + }; /** * History of points estimates for Customer Support diagnostics. * Stores the last N successful estimates to verify user-reported discrepancies. @@ -1546,6 +1675,20 @@ export interface RewardsControllerLeaderboardPositionInvalidatedEvent { ]; } +/** + * Event emitted when a user opts into a campaign, invalidating the cached + * portfolio so hooks can refetch fresh data. + */ +export interface RewardsControllerPortfolioPositionInvalidatedEvent { + type: 'RewardsController:portfolioPositionInvalidated'; + payload: [ + { + campaignId: string; + subscriptionId: string; + }, + ]; +} + /** * Events that can be emitted by the RewardsController */ @@ -1556,7 +1699,8 @@ export type RewardsControllerEvents = | RewardsControllerBalanceUpdatedEvent | RewardsControllerPointsEventsUpdatedEvent | RewardsControllerCampaignOptedInEvent - | RewardsControllerLeaderboardPositionInvalidatedEvent; + | RewardsControllerLeaderboardPositionInvalidatedEvent + | RewardsControllerPortfolioPositionInvalidatedEvent; /** * Patch type for state changes @@ -1865,6 +2009,17 @@ export interface RewardsControllerGetOndoCampaignLeaderboardPositionAction { ) => Promise; } +/** + * Action for getting the current user's Ondo GM portfolio (authenticated) + */ +export interface RewardsControllerGetOndoCampaignPortfolioPositionAction { + type: 'RewardsController:getOndoCampaignPortfolioPosition'; + handler: ( + campaignId: string, + subscriptionId: string, + ) => Promise; +} + /** * Action for getting CAIP-10 accounts linked to a subscription that are not on this device */ @@ -1987,6 +2142,7 @@ export type RewardsControllerActions = | RewardsControllerGetCampaignParticipantStatusAction | RewardsControllerGetOndoCampaignLeaderboardAction | RewardsControllerGetOndoCampaignLeaderboardPositionAction + | RewardsControllerGetOndoCampaignPortfolioPositionAction | RewardsControllerGetOffDeviceSubscriptionAccountsAction | RewardsControllerClaimRewardAction | RewardsControllerGetSeasonOneLineaRewardTokensAction diff --git a/app/core/Engine/messengers/rewards-controller-messenger/index.ts b/app/core/Engine/messengers/rewards-controller-messenger/index.ts index 71a74f238f2..21ab2fad2a1 100644 --- a/app/core/Engine/messengers/rewards-controller-messenger/index.ts +++ b/app/core/Engine/messengers/rewards-controller-messenger/index.ts @@ -59,6 +59,7 @@ import { RewardsDataServiceGetClientVersionRequirementsAction, RewardsDataServiceGetOndoCampaignLeaderboardAction, RewardsDataServiceGetOndoCampaignLeaderboardPositionAction, + RewardsDataServiceGetOndoCampaignPortfolioPositionAction, } from '../../controllers/rewards-controller/services/rewards-data-service'; import { RootMessenger } from '../../types'; @@ -104,7 +105,8 @@ type AllowedActions = | RewardsDataServiceGetCampaignParticipantStatusAction | RewardsDataServiceGetClientVersionRequirementsAction | RewardsDataServiceGetOndoCampaignLeaderboardAction - | RewardsDataServiceGetOndoCampaignLeaderboardPositionAction; + | RewardsDataServiceGetOndoCampaignLeaderboardPositionAction + | RewardsDataServiceGetOndoCampaignPortfolioPositionAction; // Don't reexport as per guidelines type AllowedEvents = @@ -172,6 +174,7 @@ export function getRewardsControllerMessenger( 'RewardsDataService:getClientVersionRequirements', 'RewardsDataService:getOndoCampaignLeaderboard', 'RewardsDataService:getOndoCampaignLeaderboardPosition', + 'RewardsDataService:getOndoCampaignPortfolioPosition', ], events: [ 'AccountTreeController:selectedAccountGroupChange', diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts index 2ebb2c54bb7..19963d230ed 100644 --- a/app/core/NavigationService/types.ts +++ b/app/core/NavigationService/types.ts @@ -197,6 +197,7 @@ import type { WebviewParams, SimpleWebviewParams, } from '../../components/Views/Webview/Webview.types'; +import { SectionId } from '../../components/Views/TrendingView/sections.config'; /** * Flattened param list for React Navigation compatibility. @@ -296,6 +297,12 @@ export interface RootStackParamList extends ParamListBase { TrendingFeed: undefined; SitesFullView: undefined; ExploreSearch: undefined; + ExploreSectionResultsFullView: { + sectionId: SectionId; + title: string; + searchQuery: string; + data: unknown[]; + }; RewardsOnboardingFlow: undefined; RewardsOnboardingIntro: undefined; RewardsOnboarding1: undefined; diff --git a/app/core/ReactQueryService/ReactQueryService.test.ts b/app/core/ReactQueryService/ReactQueryService.test.ts index 0326a4e7582..22660415165 100644 --- a/app/core/ReactQueryService/ReactQueryService.test.ts +++ b/app/core/ReactQueryService/ReactQueryService.test.ts @@ -48,7 +48,7 @@ describe('ReactQueryService', () => { queries: { staleTime: 1000 * 60 * 5, retry: 2, - gcTime: 1000 * 60 * 60 * 24, + cacheTime: 1000 * 60 * 60 * 24, }, }, }); diff --git a/app/core/ReactQueryService/ReactQueryService.ts b/app/core/ReactQueryService/ReactQueryService.ts index f836f9e02d8..afb6a28aa08 100644 --- a/app/core/ReactQueryService/ReactQueryService.ts +++ b/app/core/ReactQueryService/ReactQueryService.ts @@ -24,7 +24,7 @@ export class ReactQueryService { // On mobile, failures are often due to network drops. retry: 2, // Keep data in memory for longer. - gcTime: 1000 * 60 * 60 * 24, // 24 hours + cacheTime: 1000 * 60 * 60 * 24, // 24 hours }, }, }); diff --git a/app/core/WalletConnect/WalletConnectV2.test.ts b/app/core/WalletConnect/WalletConnectV2.test.ts index d0dd8f26dce..f5b6ba9f115 100644 --- a/app/core/WalletConnect/WalletConnectV2.test.ts +++ b/app/core/WalletConnect/WalletConnectV2.test.ts @@ -19,6 +19,7 @@ import * as waitUtil from '../SDKConnect/utils/wait.util'; jest.mock('../AppConstants', () => ({ WALLET_CONNECT: { PROJECT_ID: 'test-project-id', + LIMIT_SESSIONS: 20, METADATA: { name: 'Test Wallet', description: 'Test Wallet Description', @@ -882,6 +883,17 @@ describe('WC2Manager', () => { ); }); + it('revokes permissions for each active session', async () => { + const revokeAllPermissionsSpy = jest.spyOn( + Engine.context.PermissionController, + 'revokeAllPermissions', + ); + + await manager.removeAll(); + + expect(revokeAllPermissionsSpy).toHaveBeenCalledWith('test-pairing'); + }); + it('calls removeListeners on each WalletConnect2Session before clearing local sessions', async () => { const removeListenersA = jest.fn().mockResolvedValue(undefined); const removeListenersB = jest.fn().mockResolvedValue(undefined); @@ -2025,4 +2037,198 @@ describe('WC2Manager', () => { }); }); }); + + describe('session limit enforcement', () => { + it('removes the oldest session when the session limit is exceeded', async () => { + const web3Wallet = (manager as unknown as { web3Wallet: IWalletKit }) + .web3Wallet; + const mockDisconnectSession = jest.spyOn(web3Wallet, 'disconnectSession'); + + const now = Math.floor(Date.now() / 1000); + + // Pre-populate 20 active sessions + const existingSessions: Record = {}; + for (let i = 0; i < 20; i++) { + const topic = `topic-${i}`; + existingSessions[topic] = { + topic, + pairingTopic: `pairing-${topic}`, + expiry: now + i * 100, + relay: { protocol: 'irn' }, + acknowledged: true, + controller: 'controller', + namespaces: {}, + requiredNamespaces: {}, + optionalNamespaces: {}, + self: { publicKey: 'self-key', metadata: {} as any }, + peer: { + publicKey: 'peer-key', + metadata: { + url: 'https://example.com', + name: 'Test App', + description: '', + icons: [], + }, + }, + } as SessionTypes.Struct; + } + + // Make getActiveSessions return all 20 existing sessions plus the new one + // so enforceSessionLimit fires. + const newSessionTopic = 'new-session-topic'; + (web3Wallet.getActiveSessions as jest.Mock).mockReturnValue({ + ...existingSessions, + [newSessionTopic]: { + topic: newSessionTopic, + pairingTopic: 'new-pairing', + expiry: now + 9999, + relay: { protocol: 'irn' }, + acknowledged: true, + controller: 'controller', + namespaces: {}, + requiredNamespaces: {}, + optionalNamespaces: {}, + self: { publicKey: 'self-key', metadata: {} as any }, + peer: { + publicKey: 'peer-key', + metadata: { + url: 'https://example.com', + name: 'New App', + description: '', + icons: [], + }, + }, + } as SessionTypes.Struct, + }); + + mockApproveSession.mockResolvedValue({ + topic: newSessionTopic, + pairingTopic: 'new-pairing', + peer: { + metadata: { + url: 'https://example.com', + name: 'New App', + icons: [], + }, + }, + }); + + const proposal = { + id: 9001, + params: { + id: 9001, + pairingTopic: 'new-pairing', + proposer: { + publicKey: 'test-public-key', + metadata: { + name: 'New App', + description: 'New App', + url: 'https://example.com', + icons: ['https://example.com/icon.png'], + }, + }, + expiryTimestamp: Date.now() + 300000, + relays: [{ protocol: 'irn' }], + requiredNamespaces: { + eip155: { + chains: ['eip155:1'], + methods: ['eth_sendTransaction'], + events: ['chainChanged'], + }, + }, + optionalNamespaces: {}, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await manager.onSessionProposal(proposal as any); + + expect(mockDisconnectSession).toHaveBeenCalledWith( + expect.objectContaining({ topic: 'topic-0' }), + ); + }); + + it('does not remove any session when the session count is at or below the limit', async () => { + const web3Wallet = (manager as unknown as { web3Wallet: IWalletKit }) + .web3Wallet; + const mockDisconnectSession = jest.spyOn(web3Wallet, 'disconnectSession'); + + const now = Math.floor(Date.now() / 1000); + + // Exactly 20 active sessions (at the limit, not over it). + const sessionsAtLimit: Record = {}; + for (let i = 0; i < 20; i++) { + const topic = `at-limit-topic-${i}`; + sessionsAtLimit[topic] = { + topic, + pairingTopic: `pairing-${topic}`, + expiry: now + i * 100, + relay: { protocol: 'irn' }, + acknowledged: true, + controller: 'controller', + namespaces: {}, + requiredNamespaces: {}, + optionalNamespaces: {}, + self: { publicKey: 'self-key', metadata: {} as any }, + peer: { + publicKey: 'peer-key', + metadata: { + url: 'https://example.com', + name: 'Test App', + description: '', + icons: [], + }, + }, + } as SessionTypes.Struct; + } + + (web3Wallet.getActiveSessions as jest.Mock).mockReturnValue( + sessionsAtLimit, + ); + + mockApproveSession.mockResolvedValue({ + topic: 'within-limit-topic', + pairingTopic: 'within-limit-pairing', + peer: { + metadata: { + url: 'https://example.com', + name: 'Test App', + icons: [], + }, + }, + }); + + const proposal = { + id: 9002, + params: { + id: 9002, + pairingTopic: 'within-limit-pairing', + proposer: { + publicKey: 'test-public-key', + metadata: { + name: 'Test App', + description: 'Test App', + url: 'https://example.com', + icons: ['https://example.com/icon.png'], + }, + }, + expiryTimestamp: Date.now() + 300000, + relays: [{ protocol: 'irn' }], + requiredNamespaces: { + eip155: { + chains: ['eip155:1'], + methods: ['eth_sendTransaction'], + events: ['chainChanged'], + }, + }, + optionalNamespaces: {}, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await manager.onSessionProposal(proposal as any); + + expect(mockDisconnectSession).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/core/WalletConnect/WalletConnectV2.ts b/app/core/WalletConnect/WalletConnectV2.ts index 4f3fadaad4a..2cceb551bf4 100644 --- a/app/core/WalletConnect/WalletConnectV2.ts +++ b/app/core/WalletConnect/WalletConnectV2.ts @@ -402,7 +402,24 @@ export class WC2Manager { this.sessions = {}; const actives = this.web3Wallet.getActiveSessions() || {}; + const permissionsController = ( + Engine.context as { + // TODO: Replace 'any' with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + PermissionController: PermissionController; + } + ).PermissionController; + Object.values(actives).forEach(async (session) => { + try { + permissionsController.revokeAllPermissions(session.pairingTopic); + } catch (err) { + DevLogger.log( + `WC2::removeAll revokeAllPermissions failed for ${session.pairingTopic}`, + err, + ); + } + this.web3Wallet .disconnectSession({ topic: session.topic, @@ -676,6 +693,8 @@ export class WC2Manager { this.sessions[activeSession.topic] = session; + await this.enforceSessionLimit(); + DevLogger.log(`WC2::session_proposal updateSession`, { chainId: walletChainIdDecimal, accounts: approvedAccounts, @@ -727,6 +746,25 @@ export class WC2Manager { } } + private async enforceSessionLimit() { + const activeSessions = this.getSessions(); + const limit = AppConstants.WALLET_CONNECT.LIMIT_SESSIONS; + + if (activeSessions.length <= limit) { + return; + } + + const oldestSession = activeSessions.reduce((oldest, session) => + session.expiry < oldest.expiry ? session : oldest, + ); + + DevLogger.log( + `WC2::enforceSessionLimit removing oldest session topic=${oldestSession.topic} (${activeSessions.length} sessions exceed limit of ${limit})`, + ); + + await this.removeSession(oldestSession); + } + private async onSessionRequest(requestEvent: WalletKitTypes.SessionRequest) { const keyringController = ( Engine.context as { KeyringController: KeyringController } diff --git a/app/reducers/rewards/index.test.ts b/app/reducers/rewards/index.test.ts index bfcac261088..0baa341c594 100644 --- a/app/reducers/rewards/index.test.ts +++ b/app/reducers/rewards/index.test.ts @@ -33,6 +33,7 @@ import rewardsReducer, { setOndoCampaignLeaderboardError, setOndoCampaignLeaderboardSelectedTier, setOndoCampaignLeaderboardPosition, + setOndoCampaignPortfolioPosition, bulkLinkStarted, bulkLinkAccountResult, bulkLinkCompleted, @@ -55,6 +56,7 @@ import { CampaignType, CampaignLeaderboardDto, CampaignLeaderboardPositionDto, + OndoGmPortfolioDto, } from '../../core/Engine/controllers/rewards-controller/types'; import { AccountGroupId } from '@metamask/account-api'; import { brandColor } from '@metamask/design-tokens'; @@ -2151,6 +2153,7 @@ describe('rewardsReducer', () => { ondoCampaignLeaderboardError: false, ondoCampaignLeaderboardSelectedTier: null, ondoCampaignLeaderboardPositions: {}, + ondoCampaignPortfolio: {}, versionGuardMinimumMobileVersion: null, versionGuardLoading: false, versionGuardError: false, @@ -2264,6 +2267,7 @@ describe('rewardsReducer', () => { ondoCampaignLeaderboardError: false, ondoCampaignLeaderboardSelectedTier: null, ondoCampaignLeaderboardPositions: {}, + ondoCampaignPortfolio: {}, versionGuardMinimumMobileVersion: null, versionGuardLoading: false, versionGuardError: false, @@ -2578,14 +2582,14 @@ describe('rewardsReducer', () => { it('should restore ondoCampaignLeaderboardPositions from persisted state', () => { const mockPosition: CampaignLeaderboardPositionDto = { - projected_tier: 'MID', + projectedTier: 'MID', rank: 3, - total_in_tier: 150, - rate_of_return: 0.15, - current_usd_value: 12500.5, - total_usd_deposited: 10000, - net_deposit: 8500, - computed_at: '2024-03-20T12:00:00.000Z', + totalInTier: 150, + rateOfReturn: 0.15, + currentUsdValue: 12500.5, + totalUsdDeposited: 10000, + netDeposit: 8500, + computedAt: '2024-03-20T12:00:00.000Z', }; const persistedRewardsState: RewardsState = { ...initialState, @@ -2619,6 +2623,52 @@ describe('rewardsReducer', () => { expect(state.ondoCampaignLeaderboardPositions).toEqual({}); }); + + it('should restore ondoCampaignPortfolio from persisted state', () => { + const persisted: OndoGmPortfolioDto = { + positions: [], + summary: { + totalCurrentValue: '1', + totalCostBasis: '1', + totalUsdDeposited: '1', + netDeposit: '1', + portfolioPnl: '0', + portfolioPnlPercent: '0', + }, + computedAt: '2024-03-20T12:00:00.000Z', + }; + const persistedRewardsState: RewardsState = { + ...initialState, + ondoCampaignPortfolio: { + 'sub-1:campaign-1': persisted, + }, + }; + const rehydrateAction = { + type: 'persist/REHYDRATE', + payload: { rewards: persistedRewardsState }, + }; + + const state = rewardsReducer(initialState, rehydrateAction); + + expect(state.ondoCampaignPortfolio).toEqual({ + 'sub-1:campaign-1': persisted, + }); + }); + + it('should default ondoCampaignPortfolio to {} when absent from persisted state (upgrade path)', () => { + const persistedRewardsStateWithoutField = { + ...initialState, + ondoCampaignPortfolio: undefined, + } as unknown as RewardsState; + const rehydrateAction = { + type: 'persist/REHYDRATE', + payload: { rewards: persistedRewardsStateWithoutField }, + }; + + const state = rewardsReducer(initialState, rehydrateAction); + + expect(state.ondoCampaignPortfolio).toEqual({}); + }); }); describe('unknown actions', () => { @@ -4890,54 +4940,67 @@ describe('setVersionGuardError', () => { }); const mockLeaderboard: CampaignLeaderboardDto = { - campaign_id: 'campaign-1', - computed_at: '2024-03-20T12:00:00.000Z', + campaignId: 'campaign-1', + computedAt: '2024-03-20T12:00:00.000Z', tiers: { STARTER: { entries: [ - { rank: 1, referral_code: 'TOP001', rate_of_return: 0.325 }, - { rank: 2, referral_code: 'TOP002', rate_of_return: 0.284 }, - { rank: 3, referral_code: 'TOP003', rate_of_return: 0.261 }, - { rank: 4, referral_code: 'TOP004', rate_of_return: 0.238 }, - { rank: 5, referral_code: 'TOP005', rate_of_return: 0.217 }, - { rank: 6, referral_code: 'TOP006', rate_of_return: 0.198 }, - { rank: 7, referral_code: 'TOP007', rate_of_return: 0.182 }, - { rank: 8, referral_code: 'TOP008', rate_of_return: 0.167 }, - { rank: 9, referral_code: 'TOP009', rate_of_return: 0.154 }, - { rank: 10, referral_code: 'TOP010', rate_of_return: 0.141 }, - { rank: 11, referral_code: 'TOP011', rate_of_return: 0.129 }, - { rank: 12, referral_code: 'TOP012', rate_of_return: 0.118 }, - { rank: 13, referral_code: 'TOP013', rate_of_return: 0.108 }, - { rank: 14, referral_code: 'TOP014', rate_of_return: 0.099 }, - { rank: 15, referral_code: 'TOP015', rate_of_return: 0.091 }, - { rank: 16, referral_code: 'TOP016', rate_of_return: 0.083 }, - { rank: 17, referral_code: 'TOP017', rate_of_return: 0.076 }, - { rank: 18, referral_code: 'TOP018', rate_of_return: 0.069 }, - { rank: 19, referral_code: 'MY_CODE', rate_of_return: 0.063 }, - { rank: 20, referral_code: 'TOP020', rate_of_return: 0.057 }, + { rank: 1, referralCode: 'TOP001', rateOfReturn: 0.325 }, + { rank: 2, referralCode: 'TOP002', rateOfReturn: 0.284 }, + { rank: 3, referralCode: 'TOP003', rateOfReturn: 0.261 }, + { rank: 4, referralCode: 'TOP004', rateOfReturn: 0.238 }, + { rank: 5, referralCode: 'TOP005', rateOfReturn: 0.217 }, + { rank: 6, referralCode: 'TOP006', rateOfReturn: 0.198 }, + { rank: 7, referralCode: 'TOP007', rateOfReturn: 0.182 }, + { rank: 8, referralCode: 'TOP008', rateOfReturn: 0.167 }, + { rank: 9, referralCode: 'TOP009', rateOfReturn: 0.154 }, + { rank: 10, referralCode: 'TOP010', rateOfReturn: 0.141 }, + { rank: 11, referralCode: 'TOP011', rateOfReturn: 0.129 }, + { rank: 12, referralCode: 'TOP012', rateOfReturn: 0.118 }, + { rank: 13, referralCode: 'TOP013', rateOfReturn: 0.108 }, + { rank: 14, referralCode: 'TOP014', rateOfReturn: 0.099 }, + { rank: 15, referralCode: 'TOP015', rateOfReturn: 0.091 }, + { rank: 16, referralCode: 'TOP016', rateOfReturn: 0.083 }, + { rank: 17, referralCode: 'TOP017', rateOfReturn: 0.076 }, + { rank: 18, referralCode: 'TOP018', rateOfReturn: 0.069 }, + { rank: 19, referralCode: 'MY_CODE', rateOfReturn: 0.063 }, + { rank: 20, referralCode: 'TOP020', rateOfReturn: 0.057 }, ], - total_participants: 150, + totalParticipants: 150, }, MID: { entries: [ - { rank: 1, referral_code: 'MID001', rate_of_return: 0.412 }, - { rank: 2, referral_code: 'MID002', rate_of_return: 0.368 }, - { rank: 3, referral_code: 'MID003', rate_of_return: 0.341 }, + { rank: 1, referralCode: 'MID001', rateOfReturn: 0.412 }, + { rank: 2, referralCode: 'MID002', rateOfReturn: 0.368 }, + { rank: 3, referralCode: 'MID003', rateOfReturn: 0.341 }, ], - total_participants: 75, + totalParticipants: 75, }, }, }; const mockPosition: CampaignLeaderboardPositionDto = { - projected_tier: 'STARTER', + projectedTier: 'STARTER', rank: 19, - total_in_tier: 150, - rate_of_return: 0.063, - current_usd_value: 5063, - total_usd_deposited: 5000, - net_deposit: 4800, - computed_at: '2024-03-20T12:00:00.000Z', + totalInTier: 150, + rateOfReturn: 0.063, + currentUsdValue: 5063, + totalUsdDeposited: 5000, + netDeposit: 4800, + computedAt: '2024-03-20T12:00:00.000Z', +}; + +const mockPortfolio: OndoGmPortfolioDto = { + positions: [], + summary: { + totalCurrentValue: '5063', + totalCostBasis: '5000', + totalUsdDeposited: '5000', + netDeposit: '4800', + portfolioPnl: '63', + portfolioPnlPercent: '0.0126', + }, + computedAt: '2024-03-20T12:00:00.000Z', }; describe('setOndoCampaignLeaderboard', () => { @@ -5131,7 +5194,7 @@ describe('setOndoCampaignLeaderboardPosition', () => { }), ); - const position2 = { ...mockPosition, rank: 10, projected_tier: 'MID' }; + const position2 = { ...mockPosition, rank: 10, projectedTier: 'MID' }; currentState = rewardsReducer( currentState, setOndoCampaignLeaderboardPosition({ @@ -5149,3 +5212,35 @@ describe('setOndoCampaignLeaderboardPosition', () => { ).toEqual(position2); }); }); + +describe('setOndoCampaignPortfolioPosition', () => { + it('should set portfolio for a campaign', () => { + const action = setOndoCampaignPortfolioPosition({ + subscriptionId: 'sub-1', + campaignId: 'campaign-1', + portfolio: mockPortfolio, + }); + + const state = rewardsReducer(initialState, action); + + expect(state.ondoCampaignPortfolio['sub-1:campaign-1']).toEqual( + mockPortfolio, + ); + }); + + it('should remove portfolio when null is provided', () => { + const stateWithPortfolio: RewardsState = { + ...initialState, + ondoCampaignPortfolio: { 'sub-1:campaign-1': mockPortfolio }, + }; + const action = setOndoCampaignPortfolioPosition({ + subscriptionId: 'sub-1', + campaignId: 'campaign-1', + portfolio: null, + }); + + const state = rewardsReducer(stateWithPortfolio, action); + + expect(state.ondoCampaignPortfolio['sub-1:campaign-1']).toBeUndefined(); + }); +}); diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts index ea41e754443..b2b9487283e 100644 --- a/app/reducers/rewards/index.ts +++ b/app/reducers/rewards/index.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction, Action, Draft } from '@reduxjs/toolkit'; +import { createSlice, PayloadAction, Action } from '@reduxjs/toolkit'; import { SeasonStatusState, SeasonTierDto, @@ -12,6 +12,7 @@ import { CampaignParticipantStatusDto, CampaignLeaderboardDto, CampaignLeaderboardPositionDto, + OndoGmPortfolioDto, } from '../../core/Engine/controllers/rewards-controller/types'; import { OnboardingStep } from './types'; import { AccountGroupId } from '@metamask/account-api'; @@ -144,6 +145,9 @@ export interface RewardsState { string, CampaignLeaderboardPositionDto >; + + // Ondo GM portfolio (keyed by composite key `${subscriptionId}:${campaignId}`) + ondoCampaignPortfolio: Record; } export const initialState: RewardsState = { @@ -228,6 +232,9 @@ export const initialState: RewardsState = { // Campaign leaderboard position initial state ondoCampaignLeaderboardPositions: {}, + + // Ondo GM portfolio initial state + ondoCampaignPortfolio: {}, }; interface RehydrateAction extends Action<'persist/REHYDRATE'> { @@ -338,6 +345,7 @@ const rewardsSlice = createSlice({ state.ondoCampaignLeaderboard = null; state.ondoCampaignLeaderboardSelectedTier = null; state.ondoCampaignLeaderboardPositions = {}; + state.ondoCampaignPortfolio = {}; }, setOnboardingActiveStep: (state, action: PayloadAction) => { @@ -482,7 +490,7 @@ const rewardsSlice = createSlice({ }, // Campaigns reducers - setCampaigns: (state, action: PayloadAction[]>) => { + setCampaigns: (state, action: PayloadAction) => { state.campaigns = action.payload; state.campaignsError = false; state.campaignsHasLoaded = true; @@ -584,6 +592,22 @@ const rewardsSlice = createSlice({ } }, + setOndoCampaignPortfolioPosition: ( + state, + action: PayloadAction<{ + subscriptionId: string; + campaignId: string; + portfolio: OndoGmPortfolioDto | null; + }>, + ) => { + const key = `${action.payload.subscriptionId}:${action.payload.campaignId}`; + if (action.payload.portfolio) { + state.ondoCampaignPortfolio[key] = action.payload.portfolio; + } else { + delete state.ondoCampaignPortfolio[key]; + } + }, + // Bulk link reducers bulkLinkStarted: ( state, @@ -691,6 +715,8 @@ const rewardsSlice = createSlice({ action.payload.rewards.campaignParticipantStatuses ?? {}, ondoCampaignLeaderboardPositions: action.payload.rewards.ondoCampaignLeaderboardPositions ?? {}, + ondoCampaignPortfolio: + action.payload.rewards.ondoCampaignPortfolio ?? {}, hideUnlinkedAccountsBanner: action.payload.rewards.hideUnlinkedAccountsBanner, hideCurrentAccountNotOptedInBanner: @@ -760,6 +786,7 @@ export const { setOndoCampaignLeaderboardError, setOndoCampaignLeaderboardSelectedTier, setOndoCampaignLeaderboardPosition, + setOndoCampaignPortfolioPosition, // Bulk link actions bulkLinkStarted, bulkLinkAccountResult, diff --git a/app/reducers/rewards/selectors.test.ts b/app/reducers/rewards/selectors.test.ts index deef0d15527..3baf720f1c8 100644 --- a/app/reducers/rewards/selectors.test.ts +++ b/app/reducers/rewards/selectors.test.ts @@ -68,6 +68,8 @@ import { selectOndoCampaignLeaderboardTotalParticipantsByTier, selectOndoCampaignLeaderboardPositions, selectOndoCampaignLeaderboardPositionById, + selectOndoCampaignPortfolio, + selectOndoCampaignPortfolioById, } from './selectors'; // eslint-disable-next-line import-x/no-namespace import * as remoteFeatureFlagModule from '../../util/remoteFeatureFlag'; @@ -3397,33 +3399,56 @@ describe('Rewards selectors', () => { }); const mockLeaderboard = { - campaign_id: 'campaign-1', - computed_at: '2024-03-20T12:00:00.000Z', + campaignId: 'campaign-1', + computedAt: '2024-03-20T12:00:00.000Z', tiers: { STARTER: { entries: [ - { rank: 1, referral_code: 'ABC123', rate_of_return: 0.15 }, - { rank: 2, referral_code: 'DEF456', rate_of_return: 0.1 }, + { rank: 1, referralCode: 'ABC123', rateOfReturn: 0.15 }, + { rank: 2, referralCode: 'DEF456', rateOfReturn: 0.1 }, ], - total_participants: 50, + totalParticipants: 50, }, MID: { - entries: [{ rank: 1, referral_code: 'GHI789', rate_of_return: 0.2 }], - total_participants: 30, + entries: [{ rank: 1, referralCode: 'GHI789', rateOfReturn: 0.2 }], + totalParticipants: 30, }, }, }; const mockPosition = { - projected_tier: 'STARTER', + projectedTier: 'STARTER', rank: 5, - total_in_tier: 50, - rate_of_return: 0.12, - current_usd_value: 1000, - total_usd_deposited: 900, - net_deposit: 800, - computed_at: '2024-03-20T12:00:00.000Z', - referral_code: 'XYZ789', + totalInTier: 50, + rateOfReturn: 0.12, + currentUsdValue: 1000, + totalUsdDeposited: 900, + netDeposit: 800, + computedAt: '2024-03-20T12:00:00.000Z', + }; + + const mockPortfolio = { + positions: [] as { + tokenSymbol: string; + tokenName: string; + tokenAsset: string; + units: string; + costBasis: string; + avgCostPerUnit: string; + currentPrice: string; + currentValue: string; + unrealizedPnl: string; + unrealizedPnlPercent: string; + }[], + summary: { + totalCurrentValue: '1000', + totalCostBasis: '900', + totalUsdDeposited: '900', + netDeposit: '800', + portfolioPnl: '100', + portfolioPnlPercent: '0.1', + }, + computedAt: '2024-03-20T12:00:00.000Z', }; describe('selectOndoCampaignLeaderboard', () => { @@ -3516,7 +3541,7 @@ describe('Rewards selectors', () => { expect(selectOndoCampaignLeaderboardComputedAt(state)).toBeNull(); }); - it('returns computed_at from leaderboard', () => { + it('returns computedAt from leaderboard', () => { const state = createMockRootState({ ondoCampaignLeaderboard: mockLeaderboard, }); @@ -3660,4 +3685,59 @@ describe('Rewards selectors', () => { ).toEqual(mockPosition); }); }); + + describe('selectOndoCampaignPortfolio', () => { + it('returns empty object when no portfolios', () => { + const state = createMockRootState({ + ondoCampaignPortfolio: {}, + }); + expect(selectOndoCampaignPortfolio(state)).toEqual({}); + }); + + it('returns portfolios when set', () => { + const portfolios = { 'sub-1:campaign-1': mockPortfolio }; + const state = createMockRootState({ + ondoCampaignPortfolio: portfolios, + }); + expect(selectOndoCampaignPortfolio(state)).toEqual(portfolios); + }); + }); + + describe('selectOndoCampaignPortfolioById', () => { + it('returns null when subscriptionId is undefined', () => { + const state = createMockRootState({ + ondoCampaignPortfolio: { 'sub-1:campaign-1': mockPortfolio }, + }); + expect( + selectOndoCampaignPortfolioById(undefined, 'campaign-1')(state), + ).toBeNull(); + }); + + it('returns null when campaignId is undefined', () => { + const state = createMockRootState({ + ondoCampaignPortfolio: { 'sub-1:campaign-1': mockPortfolio }, + }); + expect( + selectOndoCampaignPortfolioById('sub-1', undefined)(state), + ).toBeNull(); + }); + + it('returns null when portfolio does not exist', () => { + const state = createMockRootState({ + ondoCampaignPortfolio: {}, + }); + expect( + selectOndoCampaignPortfolioById('sub-1', 'campaign-1')(state), + ).toBeNull(); + }); + + it('returns portfolio for specified subscription and campaign', () => { + const state = createMockRootState({ + ondoCampaignPortfolio: { 'sub-1:campaign-1': mockPortfolio }, + }); + expect( + selectOndoCampaignPortfolioById('sub-1', 'campaign-1')(state), + ).toEqual(mockPortfolio); + }); + }); }); diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts index d81ad9d286d..53d715afa29 100644 --- a/app/reducers/rewards/selectors.ts +++ b/app/reducers/rewards/selectors.ts @@ -224,7 +224,7 @@ export const selectOndoCampaignLeaderboardTiers = (state: RootState) => state.rewards.ondoCampaignLeaderboard?.tiers ?? EMPTY_TIERS; export const selectOndoCampaignLeaderboardComputedAt = (state: RootState) => - state.rewards.ondoCampaignLeaderboard?.computed_at ?? null; + state.rewards.ondoCampaignLeaderboard?.computedAt ?? null; export const selectOndoCampaignLeaderboardTierNames = createSelector( selectOndoCampaignLeaderboardTiers, @@ -240,7 +240,7 @@ export const selectOndoCampaignLeaderboardEntriesByTier = export const selectOndoCampaignLeaderboardTotalParticipantsByTier = (tierName: string | null) => (state: RootState) => tierName && state.rewards.ondoCampaignLeaderboard?.tiers[tierName] - ? state.rewards.ondoCampaignLeaderboard.tiers[tierName].total_participants + ? state.rewards.ondoCampaignLeaderboard.tiers[tierName].totalParticipants : 0; // Campaign leaderboard position selectors @@ -257,3 +257,15 @@ export const selectOndoCampaignLeaderboardPositionById = `${subscriptionId}:${campaignId}` ] ?? null) : null; + +export const selectOndoCampaignPortfolio = (state: RootState) => + state.rewards.ondoCampaignPortfolio; + +export const selectOndoCampaignPortfolioById = + (subscriptionId: string | undefined, campaignId: string | undefined) => + (state: RootState) => + subscriptionId && campaignId && state.rewards.ondoCampaignPortfolio + ? (state.rewards.ondoCampaignPortfolio[ + `${subscriptionId}:${campaignId}` + ] ?? null) + : null; diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 1174836fbcb..38791322358 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -680,6 +680,7 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "offDeviceSubscriptionAccounts": {}, "ondoCampaignLeaderboard": {}, "ondoCampaignLeaderboardPositions": {}, + "ondoCampaignPortfolio": {}, "pointsEstimateHistory": [], "pointsEvents": {}, "rewardsEnvUrl": null, @@ -1496,6 +1497,7 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "offDeviceSubscriptionAccounts": {}, "ondoCampaignLeaderboard": {}, "ondoCampaignLeaderboardPositions": {}, + "ondoCampaignPortfolio": {}, "pointsEstimateHistory": [], "pointsEvents": {}, "rewardsEnvUrl": null, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index b112b409e95..082908dfee7 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -709,6 +709,7 @@ "pointsEvents": {}, "campaigns": {}, "ondoCampaignLeaderboardPositions": {}, + "ondoCampaignPortfolio": {}, "ondoCampaignLeaderboard": {}, "campaignParticipantStatus": {}, "unlockedRewards": {}, diff --git a/builds.yml b/builds.yml index 9dc6fc714aa..ba89a5c7d5c 100644 --- a/builds.yml +++ b/builds.yml @@ -43,7 +43,7 @@ _public_envs: &public_envs # Servers (production) MM_PERPS_HIP3_ENABLED: 'true' # Temporary flag to enable builds with GitHub Actions, remove it when deprecating bitrise BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY: 'true' - MM_CHARTING_LIBRARY_URL: 'https://va-mmcx-terminal.s3.us-east-2.amazonaws.com/charting_library/' + MM_CHARTING_LIBRARY_URL: 'https://charting-assets.static.metamask.io/tradingview/advanced-charts/v30.1.0/' # Common secrets (shared across ALL builds - same names, GitHub Environment determines values) _secrets: &secrets # Infrastructure diff --git a/locales/languages/en.json b/locales/languages/en.json index 21178fe3f53..7f272e278ba 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7929,7 +7929,7 @@ "starts_date": "Starts {{date}}", "ends_date": "Ends {{date}}", "ended_date": "Ended {{date}}", - "pill_up_next": "Up next", + "pill_up_next": "Coming soon", "pill_active": "Live", "pill_complete": "Complete", "enter_now": "Enter now", @@ -7960,29 +7960,50 @@ }, "ondo_campaign_leaderboard_position": { "title": "Your Position", - "rank": "Rank", - "tier": "Tier", + "rank": "My Rank", + "tier": "My Tier", "rate_of_return": "Return", "total_deposited": "Total Deposited", "current_value": "Current Value", - "updated_at": "Updated {{time}}", "not_found": "Not on the leaderboard yet. Please check back later.", + "updated_at": "Last updated: {{time}}", "error_loading": "Failed to load your position", "error_loading_description": "There was an error loading your leaderboard position. Please try again.", "retry": "Retry" }, + "ondo_campaign_portfolio": { + "title": "Positions", + "total_value": "Total value", + "portfolio_pnl": "P&L", + "portfolio_pnl_percent": "P&L (%)", + "summary_cost_basis": "Cost basis", + "summary_net_deposit": "Net deposit", + "positions_heading": "Positions", + "position_current_value": "Value", + "position_unrealized_pnl": "P&L", + "updated_at": "Last updated: {{time}}", + "error_loading": "Failed to load positions.", + "error_loading_description": "There was an error loading your positions. Please try again.", + "retry": "Retry", + "loading": "Loading positions...", + "empty": "No positions found yet.", + "empty_description": "Start earning rewards by opening a position in tokenized real-world assets.", + "empty_cta": "Open a position", + "position_units": "{{units}} shares" + }, "ondo_campaign_leaderboard": { "title": "Leaderboard", "your_position": "Your position", "of_total": "of {{total}} participants", "total_participants": "{{count}} participants", - "updated_at": "Updated {{time}}", + "updated_at": "Last updated: {{time}}", "error_loading": "Failed to load leaderboard", "error_loading_description": "Something went wrong while loading the leaderboard. Please try again.", "error_loading_position": "Failed to load your position", "retry": "Retry", "no_data": "No leaderboard data available", - "no_entries_in_tier": "No participants in this tier yet" + "no_entries_in_tier": "No participants in this tier yet", + "not_yet_computed": "The leaderboard hasn't been computed yet. Check back soon." }, "campaigns_preview": { "title": "Campaigns", @@ -8117,6 +8138,7 @@ "sites": "Sites", "popular_sites": "Popular sites", "search_sites": "Search sites", + "view_all": "View all", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", "basic_functionality_disabled_description": "We can't fetch the required metadata when basic functionality is disabled.", diff --git a/package.json b/package.json index 05289e9f5c6..30899efefc5 100644 --- a/package.json +++ b/package.json @@ -226,7 +226,7 @@ "@metamask/core-backend": "^6.2.0", "@metamask/delegation-controller": "^2.0.2", "@metamask/delegation-deployments": "^0.15.0", - "@metamask/design-system-react-native": "^0.10.0", + "@metamask/design-system-react-native": "^0.11.0", "@metamask/design-system-twrnc-preset": "^0.3.0", "@metamask/design-tokens": "^8.2.2", "@metamask/earn-controller": "^10.0.0", @@ -350,7 +350,7 @@ "@sentry/react-native": "~6.15.0", "@shopify/flash-list": "2.0.3", "@solana/addresses": "2.0.0", - "@tanstack/react-query": "^5.90.20", + "@tanstack/react-query": "^4.43.0", "@tommasini/react-native-scrollable-tab-view": "^1.1.1", "@tradle/react-native-http": "2.0.1", "@types/he": "^1.2.3", diff --git a/tests/api-mocking/MockServerE2E.ts b/tests/api-mocking/MockServerE2E.ts index 57ee8cf2561..3f4326441f3 100644 --- a/tests/api-mocking/MockServerE2E.ts +++ b/tests/api-mocking/MockServerE2E.ts @@ -29,6 +29,39 @@ const logger = createLogger({ level: LogLevel.INFO, }); +// --------------------------------------------------------------------------- +// Patch mockttp's matchesAll so aborted requests don't become unhandled +// rejections. +// +// findMatchingRule() starts ALL rules matching concurrently (.map) but awaits +// them one-by-one. When the first match succeeds it returns, abandoning the +// rest. If any of those reject (streamToBuffer → Error('Aborted') from a +// client disconnect), they become unhandled rejections that Jest turns into +// test failures. +// +// This patch catches only Error('Aborted') and returns false ("no match"). +// All other errors propagate normally. +// --------------------------------------------------------------------------- +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mockttpMatchers = require('mockttp/dist/rules/matchers'); + const originalMatchesAll = mockttpMatchers.matchesAll; + mockttpMatchers.matchesAll = async function ( + ...args: Parameters + ) { + try { + return await originalMatchesAll.apply(this, args); + } catch (e) { + if (e instanceof Error && e.message === 'Aborted') { + return false; + } + throw e; + } + }; +} catch (e) { + logger.warn('Failed to patch mockttp matchesAll:', e); +} + /** * Safely reads request body text, catching abort errors. * When a client drops a connection mid-request (e.g., app navigation, AbortController), diff --git a/tests/framework/fixtures/json/default-fixture.json b/tests/framework/fixtures/json/default-fixture.json index a3ab50a2c2b..54333a41d4e 100644 --- a/tests/framework/fixtures/json/default-fixture.json +++ b/tests/framework/fixtures/json/default-fixture.json @@ -752,6 +752,7 @@ "ondoCampaignLeaderboardLoading": false, "ondoCampaignLeaderboardPositions": {}, "ondoCampaignLeaderboardSelectedTier": null, + "ondoCampaignPortfolio": {}, "campaignParticipantStatuses": {}, "campaigns": [], "campaignsError": false, diff --git a/tests/helpers/swap/constants.ts b/tests/helpers/swap/constants.ts index c07ab445f33..0b404226fe9 100644 --- a/tests/helpers/swap/constants.ts +++ b/tests/helpers/swap/constants.ts @@ -790,6 +790,20 @@ export const GET_TOKENS_MAINNET_RESPONSE = [ }, }, }, + { + address: '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + chainId: 1, + assetId: 'eip155:1/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'MUSD', + decimals: 6, + name: 'MetaMask USD', + coingeckoId: 'metamask-usd', + aggregators: ['metamask', 'liFi', 'socket', 'rubic', 'rango', 'sonarwatch'], + occurrences: 6, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', + metadata: { storage: {} }, + }, ]; // Popular tokens response format for POST /getTokens/popular endpoint @@ -843,6 +857,14 @@ export const GET_POPULAR_TOKENS_MAINNET_RESPONSE = [ name: 'Alphabet Class A (Ondo Tokenized)', symbol: 'GOOGLON', }, + { + assetId: 'eip155:1/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da', + decimals: 6, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', + name: 'MetaMask USD', + symbol: 'MUSD', + }, ]; export const GET_TOKENS_SOLANA_RESPONSE = [ @@ -1257,6 +1279,139 @@ export const GASLESS_SWAP_QUOTES_ETH_MUSD = [ quoteBpsFee: 87.5, baseBpsFee: 87.5, }, + // txFee is required by calcIncludedTxFees to compute the strikethrough + // gas cost shown next to "Included" in the Network fee row. + // Amount ≈ gasLimit(448721) * 4.67 gwei ≈ 0.00210 ETH. + // maxFeePerGas and maxPriorityFeePerGas are required by the + // bridge-controller quote validator (superstruct schema). + txFee: { + amount: '2095527070000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + coingeckoId: 'ethereum', + aggregators: [], + occurrences: 100, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + metadata: {}, + }, + maxFeePerGas: '4667609171', + maxPriorityFeePerGas: '1000000004', + }, + }, + bridges: ['openocean'], + protocols: ['openocean'], + steps: [], + slippage: 2, + priceData: { + totalFromAmountUsd: '3865.21', + totalToAmountUsd: '3832.3211880033778', + priceImpact: '0.008508932760864812', + totalFeeAmountUsd: '33.8205875', + }, + }, + trade: { + chainId: 1, + to: '0x881D40237659C251811CEC9c364ef91dC08D300C', + from: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + value: '0xde0b6b3a7640000', + data: '0x5f575529000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000136f70656e4f6365616e46656544796e616d6963000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000aca92e438df0b2401ff60da7e4337b687a2435da0000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000e0123b42', + gasLimit: 448721, + }, + estimatedProcessingTimeInSeconds: 0, + }, +]; + +export const GASLESS_SWAP_QUOTES_ETH_MUSD_7702 = [ + { + quote: { + requestId: + '0xa1b2c3d4e5f60718293a4b5c6d7e8f9001122334455667788990aabbccddeeff', + bridgeId: 'openocean', + srcChainId: 1, + destChainId: 1, + aggregator: 'openocean', + aggregatorType: 'AGG', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + coingeckoId: 'ethereum', + aggregators: [], + occurrences: 100, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + metadata: {}, + }, + srcTokenAmount: '991250000000000000', + destAsset: { + address: '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + chainId: 1, + assetId: 'eip155:1/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'MUSD', + decimals: 6, + name: 'MetaMask USD', + coingeckoId: 'metamask-usd', + aggregators: ['metamask', 'liFi', 'socket', 'rubic', 'rango'], + occurrences: 5, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', + metadata: { storage: {} }, + }, + destTokenAmount: '3839447765', + minDestTokenAmount: '3762658809', + walletAddress: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + destWalletAddress: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + gasIncluded: false, + gasIncluded7702: true, + gasSponsored: false, + feeData: { + metabridge: { + amount: '8750000000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + coingeckoId: 'ethereum', + aggregators: [], + occurrences: 100, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + metadata: {}, + }, + quoteBpsFee: 87.5, + baseBpsFee: 87.5, + }, + txFee: { + amount: '2095527070000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + coingeckoId: 'ethereum', + aggregators: [], + occurrences: 100, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + metadata: {}, + }, + maxFeePerGas: '4667609171', + maxPriorityFeePerGas: '1000000004', + }, }, bridges: ['openocean'], protocols: ['openocean'], @@ -1281,6 +1436,139 @@ export const GASLESS_SWAP_QUOTES_ETH_MUSD = [ }, ]; +export const GASLESS_SWAP_QUOTES_USDC_MUSD = [ + { + quote: { + requestId: + '0x77dbb16ffe107c8a942da0cff5f70566cb33ac4629b606c196963077b809e6bd', + bridgeId: '1inch', + srcChainId: 1, + destChainId: 1, + aggregator: '1inch', + aggregatorType: 'AGG', + srcAsset: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: 1, + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6, + name: 'USDC', + coingeckoId: 'usd-coin', + aggregators: [ + 'metamask', + 'oneInch', + 'liFi', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 11, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + metadata: { storage: { balance: 9, approval: 10 } }, + }, + srcTokenAmount: '1000000000', + destAsset: { + address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + chainId: 1, + assetId: 'eip155:1/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'MUSD', + decimals: 6, + name: 'MetaMask USD', + aggregators: ['metamask', 'liFi', 'socket', 'rubic', 'rango'], + occurrences: 5, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', + metadata: {}, + }, + destTokenAmount: '999626107', + minDestTokenAmount: '979633584', + walletAddress: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + destWalletAddress: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + feeData: { + metabridge: { + amount: '0', + asset: { + address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + chainId: 1, + assetId: + 'eip155:1/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'MUSD', + decimals: 6, + name: 'MetaMask USD', + aggregators: ['metamask', 'liFi', 'socket', 'rubic', 'rango'], + occurrences: 5, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', + metadata: {}, + }, + quoteBpsFee: 0, + baseBpsFee: 87.5, + }, + // txFee: gas cost included in the swap (paid by the protocol, not the user). + // maxFeePerGas and maxPriorityFeePerGas are required by the quote validator schema. + txFee: { + amount: '2095527070000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + coingeckoId: 'ethereum', + aggregators: [], + occurrences: 100, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + metadata: {}, + }, + maxFeePerGas: '4667609171', + maxPriorityFeePerGas: '1000000004', + }, + }, + bridges: ['1inch'], + protocols: ['1inch'], + steps: [], + slippage: 2, + gasSponsored: false, + gasIncluded: true, + gasIncluded7702: false, + priceData: { + totalFromAmountUsd: '999.924', + totalToAmountUsd: '999.5651298074731', + priceImpact: '0.00035889746873448894', + totalFeeAmountUsd: '0', + }, + }, + approval: { + chainId: 1, + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + from: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + value: '0x0', + data: '0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c000000000000000000000000000000000000000000000000000000003b9aca00', + gasLimit: 36019, + effectiveGas: 35658, + }, + trade: { + chainId: 1, + to: '0x881D40237659C251811CEC9c364ef91dC08D300C', + from: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + value: '0x0', + data: '0x5f5755290000000000000000000000000000000000000000000000000000000000000080000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000003b9aca0000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000136f6e65496e6368563646656544796e616d69630000000000000000000000000000000000000000000000000000000000000000000000000000000000000003a0000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000aca92e438df0b2401ff60da7e4337b687a2435da000000000000000000000000000000000000000000000000000000003b9aca00000000000000000000000000000000000000000000000000000000003a6405b000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f326e4de8f66a0bdc0970b79e0924e33c79f19150000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000026807ed2379000000000000000000000000990636ecb3ff04d33d92e970d3d588bf5cd8d086000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000aca92e438df0b2401ff60da7e4337b687a2435da000000000000000000000000990636ecb3ff04d33d92e970d3d588bf5cd8d08600000000000000000000000074de5d4fcbf63e00296fd95d33236b9794016631000000000000000000000000000000000000000000000000000000003b9aca00000000000000000000000000000000000000000000000000000000003a6405b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000001050000000000000000000000000000000000000000000000e70000b900004e00a0744c8c09a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4890cbe4bdd538d6e9b379bff5fe72c3d67a521de50000000000000000000000000000000000000000000000000000000000061a8002a0000000000000000000000000000000000000000000000000000000003a6405b048c9503381a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48aca92e438df0b2401ff60da7e4337b687a2435da0000000000010000111111125421ca6dc452d289314280a0f8842a650020d6bdbf78aca92e438df0b2401ff60da7e4337b687a2435da111111125421ca6dc452d289314280a0f8842a650000000000000000000000000000000000000000000000000000007dcbea7c0000000000000000000000000000000000000000000000004c', + gasLimit: 361700, + effectiveGas: 257105, + }, + estimatedProcessingTimeInSeconds: 0, + quoteId: '45deada4-639c-433b-ada4-0312d548d736', + }, +]; + export const GET_QUOTE_USDC_USDT_RESPONSE = [ { quote: { @@ -1447,6 +1735,15 @@ export const GET_TOKENS_API_USDT_RESPONSE = [ }, ]; +export const GET_TOKENS_API_MUSD_RESPONSE = [ + { + assetId: 'eip155:1/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da', + decimals: 6, + name: 'MetaMask USD', + symbol: 'MUSD', + }, +]; + export const GET_QUOTE_USDC_GOOGLON_RESPONSE = [ { quote: { diff --git a/tests/helpers/swap/swap-mocks.ts b/tests/helpers/swap/swap-mocks.ts index e2d5e411d67..30caa82c330 100644 --- a/tests/helpers/swap/swap-mocks.ts +++ b/tests/helpers/swap/swap-mocks.ts @@ -18,6 +18,7 @@ import { GET_POPULAR_TOKENS_MAINNET_RESPONSE, GET_TOKENS_API_USDC_RESPONSE, GET_TOKENS_API_USDT_RESPONSE, + GET_TOKENS_API_MUSD_RESPONSE, GET_QUOTE_USDC_GOOGLON_RESPONSE, toSSEResponse, } from './constants'; @@ -27,6 +28,7 @@ const DAI_MAINNET = '0x6b175474e89094c44da98b954eedeac495271d0f'; const USDT_MAINNET = '0xdac17f958d2ee523a2206206994597c13d831ec7'; const WETH_MAINNET = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; const GOOGLON_MAINNET = '0xba47214edd2bb43099611b208f75e4b42fdcfedc'; +const MUSD_MAINNET = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; /** * Mock spot prices so balance display (balance * price) does not show NaN. @@ -60,6 +62,11 @@ export async function setupSpotPricesMock(mockServer: Mockttp): Promise { price: 312.79, usd: 312.79, }, + [`eip155:1/erc20:${MUSD_MAINNET}`]: { price: 1.0, usd: 1.0 }, + [`eip155:1/erc20:${toChecksumHexAddress(MUSD_MAINNET)}`]: { + price: 1.0, + usd: 1.0, + }, }; await setupMockRequest(mockServer, { @@ -174,6 +181,14 @@ export const testSpecificMock: TestSpecificMock = async ( responseCode: 200, }); + // Mock API tokens for MUSD + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: 'https://tokens.api.cx.metamask.io/v3/assets?assetIds=eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + response: GET_TOKENS_API_MUSD_RESPONSE, + responseCode: 200, + }); + // Mock USDC->GOOGLON (SSE) await setupSSEMockRequest( mockServer, diff --git a/tests/helpers/swap/swap-unified-ui.ts b/tests/helpers/swap/swap-unified-ui.ts index aad35f66584..049e189525f 100644 --- a/tests/helpers/swap/swap-unified-ui.ts +++ b/tests/helpers/swap/swap-unified-ui.ts @@ -1,7 +1,6 @@ -import TestHelpers from '../../helpers'; import QuoteView from '../../page-objects/swaps/QuoteView'; import SlippageModal from '../../page-objects/swaps/SlippageModal'; -import Assertions from '../../framework/Assertions'; +import { Assertions } from '../../framework'; import ActivitiesView from '../../page-objects/Transactions/ActivitiesView'; import { ActivitiesViewSelectorsText } from '../../../app/components/Views/ActivityView/ActivitiesView.testIds'; @@ -58,6 +57,7 @@ export async function checkSwapActivity( // Check the swap activity completed await Assertions.expectElementToBeVisible(ActivitiesView.title); + await Assertions.expectElementToBeVisible( ActivitiesView.swapActivityTitle(sourceTokenSymbol, destTokenSymbol), ); @@ -76,7 +76,4 @@ export async function checkSwapActivity( ActivitiesViewSelectorsText.CONFIRM_TEXT, ); } - - // Wait for tx toast to clear - await TestHelpers.delay(5000); } diff --git a/tests/smoke/swap/gasless-swap.spec.ts b/tests/smoke/swap/gasless-swap.spec.ts index 300e11dbe70..fa8e02d247b 100644 --- a/tests/smoke/swap/gasless-swap.spec.ts +++ b/tests/smoke/swap/gasless-swap.spec.ts @@ -5,25 +5,29 @@ import Assertions from '../../framework/Assertions'; import WalletView from '../../page-objects/wallet/WalletView'; import { SmokeTrade } from '../../tags'; import { loginToApp } from '../../flows/wallet.flow'; -import { logger } from '../../framework/logger'; import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; -import { AnvilManager } from '../../seeder/anvil-manager'; +import { AnvilManager, DEFAULT_ANVIL_PORT } from '../../seeder/anvil-manager'; import QuoteView from '../../page-objects/swaps/QuoteView'; import { setupSSEMockRequest } from '../../api-mocking/helpers/mockHelpers'; import { GASLESS_SWAP_QUOTES_ETH_MUSD, + GASLESS_SWAP_QUOTES_ETH_MUSD_7702, + GASLESS_SWAP_QUOTES_USDC_MUSD, toSSEResponse, } from '../../helpers/swap/constants'; -import { setupSpotPricesMock } from '../../helpers/swap/swap-mocks'; +import { testSpecificMock as swapTestSpecificMock } from '../../helpers/swap/swap-mocks'; +import { setupSmartTransactionsMocks } from '../../helpers/swap/smart-transactions-mocks'; +import { prepareSwapsTestEnvironment } from '../../helpers/swap/prepareSwapsTestEnvironment'; +import { checkSwapActivity } from '../../helpers/swap/swap-unified-ui'; describe(SmokeTrade('Gasless Swap - '), (): void => { const chainId = '0x1'; beforeEach(async (): Promise => { - jest.setTimeout(120000); + jest.setTimeout(180000); }); - it('displays included label for gasless ETH to MUSD swap quote', async (): Promise => { + it.skip('completes a gasless ETH to MUSD swap', async (): Promise => { await withFixtures( { fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { @@ -41,7 +45,6 @@ describe(SmokeTrade('Gasless Swap - '), (): void => { nickname: 'Localhost', ticker: 'ETH', }) - .withMetaMetricsOptIn() .build(); }, localNodeOptions: [ @@ -53,41 +56,208 @@ describe(SmokeTrade('Gasless Swap - '), (): void => { }, ], testSpecificMock: async (mockServer) => { - await setupSpotPricesMock(mockServer); - // Mock ETH->MUSD quote — SSE path (getQuoteStream) + // Use the full swap mock suite (token list, spot prices, catch-alls) + await swapTestSpecificMock(mockServer); + // Override: return the gasless quote for MUSD instead of the default empty response. + // Priority 1000 > 999 so this rule beats the empty-string MUSD mock in swapTestSpecificMock. await setupSSEMockRequest( mockServer, /getQuoteStream.*destTokenAddress=0xacA92E438df0B2401fF60dA7E4337B687a2435DA/i, toSSEResponse(GASLESS_SWAP_QUOTES_ETH_MUSD), + 1000, ); + await setupSmartTransactionsMocks(mockServer, DEFAULT_ANVIL_PORT); }, restartDevice: true, - endTestfn: async () => { - logger.debug('Gasless swap test completed'); + }, + async () => { + await loginToApp(); + await prepareSwapsTestEnvironment(); + await WalletView.tapWalletSwapButton(); + await device.disableSynchronization(); + + await Assertions.expectElementToBeVisible(QuoteView.sourceTokenArea, { + description: 'Swap quote view (source token area) visible', + timeout: 20000, + }); + + await QuoteView.tapSourceAmountInput(); + await QuoteView.enterAmount('1'); + await QuoteView.tapDestinationToken(); + await QuoteView.tapToken(chainId, 'MUSD'); + + // Verify network fee shows "Included" for gasless swap + await Assertions.expectElementToBeVisible(QuoteView.networkFeeLabel, { + timeout: 60000, + description: 'Network fee label visible', + }); + await Assertions.expectElementToBeVisible(QuoteView.includedLabel, { + timeout: 10000, + description: 'Gas fee included in quote', + }); + + await Assertions.expectElementToBeVisible(QuoteView.confirmSwap, { + description: 'Confirm swap button visible', + }); + await QuoteView.tapConfirmSwap(); + + await checkSwapActivity('ETH', 'MUSD'); + }, + ); + }); + + it.skip('completes a gasless USDC to MUSD swap (ERC-20 source with approval)', async (): Promise => { + await withFixtures( + { + fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { + const node = localNodes?.[0] as unknown as AnvilManager; + const rpcPort = + node instanceof AnvilManager + ? (node.getPort() ?? AnvilPort()) + : undefined; + + return new FixtureBuilder() + .withNetworkController({ + chainId, + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', + }) + .build(); }, + localNodeOptions: [ + { + type: LocalNodeType.anvil, + options: { + chainId: 1, + loadState: './tests/smoke/swap/withTokens.json', + }, + }, + ], + testSpecificMock: async (mockServer) => { + // Use the full swap mock suite (token list, spot prices, catch-alls) + await swapTestSpecificMock(mockServer); + // Override the empty MUSD mock with the USDC→MUSD gasless quote. + // Priority 1000 > 999 so this rule beats the empty-string MUSD mock in swapTestSpecificMock. + await setupSSEMockRequest( + mockServer, + /getQuoteStream.*destTokenAddress=0xacA92E438df0B2401fF60dA7E4337B687a2435DA/i, + toSSEResponse(GASLESS_SWAP_QUOTES_USDC_MUSD), + 1000, + ); + await setupSmartTransactionsMocks(mockServer, DEFAULT_ANVIL_PORT); + }, + restartDevice: true, }, async () => { await loginToApp(); + await prepareSwapsTestEnvironment(); await WalletView.tapWalletSwapButton(); await device.disableSynchronization(); + await Assertions.expectElementToBeVisible(QuoteView.sourceTokenArea, { description: 'Swap quote view (source token area) visible', timeout: 20000, }); - // Tap Max to use maximum balance + await QuoteView.tapSourceToken(); + await QuoteView.tapToken(chainId, 'USDC'); await QuoteView.tapMax(); + await QuoteView.tapDestinationToken(); + await QuoteView.tapToken(chainId, 'MUSD'); - // Verify network fee shows "Included" for gasless swap await Assertions.expectElementToBeVisible(QuoteView.networkFeeLabel, { timeout: 60000, description: 'Network fee label visible', }); + await Assertions.expectElementToBeVisible(QuoteView.includedLabel, { + timeout: 10000, + description: 'Gas fee included in quote', + }); + + await Assertions.expectElementToBeVisible(QuoteView.confirmSwap, { + description: 'Confirm swap button visible', + }); + await QuoteView.tapConfirmSwap(); + + await checkSwapActivity('USDC', 'MUSD'); + }, + ); + }); + + it('completes a gasless 7702 ETH to MUSD swap (native source)', async (): Promise => { + await withFixtures( + { + fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { + const node = localNodes?.[0] as unknown as AnvilManager; + const rpcPort = + node instanceof AnvilManager + ? (node.getPort() ?? AnvilPort()) + : undefined; + + return new FixtureBuilder() + .withNetworkController({ + chainId, + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', + }) + .build(); + }, + localNodeOptions: [ + { + type: LocalNodeType.anvil, + options: { + chainId: 1, + }, + }, + ], + testSpecificMock: async (mockServer) => { + await swapTestSpecificMock(mockServer); + // Priority 1000 > 999 so this rule beats the empty-string MUSD mock in swapTestSpecificMock. + await setupSSEMockRequest( + mockServer, + /getQuoteStream.*destTokenAddress=0xacA92E438df0B2401fF60dA7E4337B687a2435DA/i, + toSSEResponse(GASLESS_SWAP_QUOTES_ETH_MUSD_7702), + 1000, + ); + await setupSmartTransactionsMocks(mockServer, DEFAULT_ANVIL_PORT); + }, + restartDevice: true, + }, + async () => { + await loginToApp(); + await prepareSwapsTestEnvironment(); + await WalletView.tapWalletSwapButton(); + await device.disableSynchronization(); + + await Assertions.expectElementToBeVisible(QuoteView.sourceTokenArea, { + description: 'Swap quote view (source token area) visible', + timeout: 20000, + }); + + await QuoteView.tapSourceAmountInput(); + await QuoteView.enterAmount('1'); + await QuoteView.tapDestinationToken(); + await QuoteView.tapToken(chainId, 'MUSD'); + await Assertions.expectElementToBeVisible(QuoteView.networkFeeLabel, { + timeout: 60000, + description: 'Network fee label visible', + }); await Assertions.expectElementToBeVisible(QuoteView.includedLabel, { timeout: 10000, - description: 'Gas included in quote', + description: 'Gas fee included in quote (7702)', }); + + await Assertions.expectElementToBeVisible(QuoteView.confirmSwap, { + description: 'Confirm swap button visible', + }); + await QuoteView.tapConfirmSwap(); + + await checkSwapActivity('ETH', 'MUSD'); }, ); }); diff --git a/yarn.lock b/yarn.lock index 7a365b0191d..2509c541b63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8135,11 +8135,11 @@ __metadata: languageName: node linkType: hard -"@metamask/design-system-react-native@npm:^0.10.0": - version: 0.10.0 - resolution: "@metamask/design-system-react-native@npm:0.10.0" +"@metamask/design-system-react-native@npm:^0.11.0": + version: 0.11.0 + resolution: "@metamask/design-system-react-native@npm:0.11.0" dependencies: - "@metamask/design-system-shared": "npm:^0.3.0" + "@metamask/design-system-shared": "npm:^0.4.0" fast-text-encoding: "npm:^1.0.6" react-native-jazzicon: "npm:^0.1.2" peerDependencies: @@ -8152,16 +8152,16 @@ __metadata: react-native-gesture-handler: ">=1.10.3" react-native-reanimated: ">=3.3.0" react-native-safe-area-context: ">=4.0.0" - checksum: 10/4b105db890392d9f363ad5a573249980a621a3edb6e6d371cef8e78f8ade378d98c24a1f4d814c815a4f8e30e2978a8c69571f61e9b0983289d1c08e28f02da2 + checksum: 10/d30aa86379138b689a821af25f7c56a8a4dbaaa3378ce4daf668122babfd0c254b8ea319a9b2e9202f2c58d86caca2eb6b888f13e063e83100da4136bad0b378 languageName: node linkType: hard -"@metamask/design-system-shared@npm:^0.3.0": - version: 0.3.0 - resolution: "@metamask/design-system-shared@npm:0.3.0" +"@metamask/design-system-shared@npm:^0.4.0": + version: 0.4.0 + resolution: "@metamask/design-system-shared@npm:0.4.0" dependencies: "@metamask/utils": "npm:^11.10.0" - checksum: 10/2be032b91b10d03ca4e02ba9ff5afc9db763fb865161add83a7e28ef83f165cc3c3edba03f5d600ec95e3014afd0077abcadd75e613792e77c709760813ff749 + checksum: 10/e7799c011360295b6ae1cb458cd069e2df2ce27e836a6c604c3f2488ef7b76d00709afadc5c9462e47cb81a379751fd9da8684dcc704244e180870aa293cdd2b languageName: node linkType: hard @@ -17427,21 +17427,36 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-core@npm:5.90.20, @tanstack/query-core@npm:^5.62.16": +"@tanstack/query-core@npm:4.43.0": + version: 4.43.0 + resolution: "@tanstack/query-core@npm:4.43.0" + checksum: 10/c2a5a151c7adaea8311e01a643255f31946ae3164a71567ba80048242821ae14043f13f5516b695baebe5ea7e4b2cf717fd60908a929d18a5c5125fee925ff67 + languageName: node + linkType: hard + +"@tanstack/query-core@npm:^5.62.16": version: 5.90.20 resolution: "@tanstack/query-core@npm:5.90.20" checksum: 10/25e38f4382442bc15e0f6cce8d787e9df8d8822c61d3f3e9427e89e01b1e2506f848292e086dae29aeb55f8ce71b097c34221f3c5eda37fb4a688b5ceca5d1b3 languageName: node linkType: hard -"@tanstack/react-query@npm:^5.90.20": - version: 5.90.20 - resolution: "@tanstack/react-query@npm:5.90.20" +"@tanstack/react-query@npm:^4.43.0": + version: 4.43.0 + resolution: "@tanstack/react-query@npm:4.43.0" dependencies: - "@tanstack/query-core": "npm:5.90.20" + "@tanstack/query-core": "npm:4.43.0" + use-sync-external-store: "npm:^1.6.0" peerDependencies: - react: ^18 || ^19 - checksum: 10/ac98af011078717f4ee754f687b434a9396d05dc17a6b0dd80048c56a1081da921adf81aa48ccb92eaca80f06db6d28497ce4adfaebd5e01b16effd1e5f4ff85 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-native: "*" + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: 10/23f9d18d130fa2a1238d8fba8bc914c67e33753b7fc3a3c7856354a9873c4cbc5d18ce24dbf6364ecf86b8ea787575e1e60998ea75baa2b9e9647ad4b9127e10 languageName: node linkType: hard @@ -35536,7 +35551,7 @@ __metadata: "@metamask/core-backend": "npm:^6.2.0" "@metamask/delegation-controller": "npm:^2.0.2" "@metamask/delegation-deployments": "npm:^0.15.0" - "@metamask/design-system-react-native": "npm:^0.10.0" + "@metamask/design-system-react-native": "npm:^0.11.0" "@metamask/design-system-twrnc-preset": "npm:^0.3.0" "@metamask/design-tokens": "npm:^8.2.2" "@metamask/earn-controller": "npm:^10.0.0" @@ -35679,7 +35694,7 @@ __metadata: "@storybook/addon-ondevice-controls": "npm:^6.5.6" "@storybook/builder-webpack5": "npm:^7.5.1" "@storybook/react-native": "npm:^6.5.6" - "@tanstack/react-query": "npm:^5.90.20" + "@tanstack/react-query": "npm:^4.43.0" "@testing-library/react": "npm:14.0.0" "@testing-library/react-hooks": "npm:^8.0.1" "@testing-library/react-native": "npm:^13.2.0" @@ -46432,7 +46447,7 @@ __metadata: languageName: node linkType: hard -"use-sync-external-store@npm:1.2.0, use-sync-external-store@npm:^1.0.0": +"use-sync-external-store@npm:1.2.0": version: 1.2.0 resolution: "use-sync-external-store@npm:1.2.0" peerDependencies: @@ -46441,6 +46456,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.0.0, use-sync-external-store@npm:^1.6.0": + version: 1.6.0 + resolution: "use-sync-external-store@npm:1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/b40ad2847ba220695bff2d4ba4f4d60391c0fb4fb012faa7a4c18eb38b69181936f5edc55a522c4d20a788d1a879b73c3810952c9d0fd128d01cb3f22042c09e + languageName: node + linkType: hard + "userhome@npm:1.0.1": version: 1.0.1 resolution: "userhome@npm:1.0.1"