diff --git a/src/components/asset-list/RecyclerAssetList2/Claimable.tsx b/src/components/asset-list/RecyclerAssetList2/Claimable.tsx new file mode 100644 index 00000000000..d543fb635ab --- /dev/null +++ b/src/components/asset-list/RecyclerAssetList2/Claimable.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Box, Inline, Stack, Text } from '@/design-system'; +import { useAccountSettings } from '@/hooks'; +import { useClaimables } from '@/resources/addys/claimables/query'; +import { FasterImageView } from '@candlefinance/faster-image'; +import { ButtonPressAnimation } from '@/components/animations'; +import { deviceUtils } from '@/utils'; + +export default function Claimable({ uniqueId }: { uniqueId: string }) { + const { accountAddress, nativeCurrency } = useAccountSettings(); + const { data = [] } = useClaimables( + { + address: accountAddress, + currency: nativeCurrency, + }, + { + select: data => data?.filter(claimable => claimable.uniqueId === uniqueId), + } + ); + + const [claimable] = data; + + if (!claimable) return null; + + return ( + + + + + + {claimable.name} + + + {claimable.value.claimAsset.display} + + + + + + {claimable.value.nativeAsset.display} + + + + ); +} diff --git a/src/components/asset-list/RecyclerAssetList2/ClaimablesListHeader.tsx b/src/components/asset-list/RecyclerAssetList2/ClaimablesListHeader.tsx new file mode 100644 index 00000000000..964a58af799 --- /dev/null +++ b/src/components/asset-list/RecyclerAssetList2/ClaimablesListHeader.tsx @@ -0,0 +1,93 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Animated, Easing, Image } from 'react-native'; +import CaretImageSource from '@/assets/family-dropdown-arrow.png'; +import { useTheme } from '@/theme/ThemeContext'; +import { ButtonPressAnimation } from '@/components/animations'; +import { Box, Inline, Text } from '@/design-system'; +import * as i18n from '@/languages'; +import useOpenClaimables from '@/hooks/useOpenClaimables'; + +const AnimatedImgixImage = Animated.createAnimatedComponent(Image); + +const TokenFamilyHeaderAnimationDuration = 200; +const TokenFamilyHeaderHeight = 48; + +const ClaimablesListHeader = ({ total }: { total: string }) => { + const { colors } = useTheme(); + const { isClaimablesOpen, toggleOpenClaimables } = useOpenClaimables(); + + const toValue = Number(!!isClaimablesOpen); + + const [animation] = useState(() => new Animated.Value(toValue)); + + useEffect(() => { + Animated.timing(animation, { + duration: TokenFamilyHeaderAnimationDuration, + easing: Easing.bezier(0.25, 0.1, 0.25, 1), + toValue, + useNativeDriver: true, + }).start(); + }, [toValue, animation]); + + const imageAnimatedStyles = useMemo( + () => ({ + height: 18, + marginBottom: 1, + right: 5, + transform: [ + { + rotate: animation.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '90deg'], + }), + }, + ], + width: 8, + }), + [animation] + ); + + const sumNumberAnimatedStyles = useMemo( + () => ({ + opacity: animation.interpolate({ + inputRange: [0, 1], + outputRange: [1, 0], + }), + paddingRight: 4, + }), + [animation] + ); + + return ( + + + + + {i18n.t(i18n.l.account.tab_claimables)} + + + {!isClaimablesOpen && ( + + + {total} + + + )} + + + + + + ); +}; + +ClaimablesListHeader.animationDuration = TokenFamilyHeaderAnimationDuration; + +ClaimablesListHeader.height = TokenFamilyHeaderHeight; + +export default ClaimablesListHeader; diff --git a/src/components/asset-list/RecyclerAssetList2/core/RawRecyclerList.tsx b/src/components/asset-list/RecyclerAssetList2/core/RawRecyclerList.tsx index fecef705bcd..c68667a4f87 100644 --- a/src/components/asset-list/RecyclerAssetList2/core/RawRecyclerList.tsx +++ b/src/components/asset-list/RecyclerAssetList2/core/RawRecyclerList.tsx @@ -22,7 +22,6 @@ import { remoteCardsStore } from '@/state/remoteCards/remoteCards'; import { useRoute } from '@react-navigation/native'; import Routes from '@/navigation/routesNames'; import { useRemoteConfig } from '@/model/remoteConfig'; -import { useExperimentalFlag } from '@/config'; import { RainbowContext } from '@/helpers/RainbowContext'; const dataProvider = new DataProvider((r1, r2) => { @@ -79,7 +78,7 @@ const RawMemoRecyclerAssetList = React.memo(function RawRecyclerAssetList({ remoteConfig, experimentalConfig, }), - [briefSectionsData, isCoinListEdited, cardIds, isReadOnlyWallet, experimentalConfig] + [briefSectionsData, isCoinListEdited, cardIds, isReadOnlyWallet, remoteConfig, experimentalConfig] ); const { accountAddress } = useAccountSettings(); diff --git a/src/components/asset-list/RecyclerAssetList2/core/RowRenderer.tsx b/src/components/asset-list/RecyclerAssetList2/core/RowRenderer.tsx index 75f9844cdde..b5805584840 100644 --- a/src/components/asset-list/RecyclerAssetList2/core/RowRenderer.tsx +++ b/src/components/asset-list/RecyclerAssetList2/core/RowRenderer.tsx @@ -8,6 +8,8 @@ import { ExtendedState } from './RawRecyclerList'; import { AssetsHeaderExtraData, CellType, + ClaimableExtraData, + ClaimablesHeaderExtraData, CoinDividerExtraData, CoinExtraData, NFTExtraData, @@ -33,6 +35,8 @@ import { RemoteCardCarousel } from '@/components/cards/remote-cards'; import WrappedCollectiblesHeader from '../WrappedCollectiblesHeader'; import NFTLoadingSkeleton from '../NFTLoadingSkeleton'; import { NFTEmptyState } from '../NFTEmptyState'; +import Claimable from '../Claimable'; +import ClaimablesListHeader from '../ClaimablesListHeader'; function rowRenderer(type: CellType, { uid }: { uid: string }, _: unknown, extendedState: ExtendedState) { const data = extendedState.additionalData[uid]; @@ -51,6 +55,8 @@ function rowRenderer(type: CellType, { uid }: { uid: string }, _: unknown, exten case CellType.EMPTY_ROW: case CellType.POSITIONS_SPACE_AFTER: case CellType.POSITIONS_SPACE_BEFORE: + case CellType.CLAIMABLES_SPACE_AFTER: + case CellType.CLAIMABLES_SPACE_BEFORE: return null; case CellType.COIN_DIVIDER: return ( @@ -162,6 +168,15 @@ function rowRenderer(type: CellType, { uid }: { uid: string }, _: unknown, exten return ; } + case CellType.CLAIMABLES_HEADER: { + const { total } = data as ClaimablesHeaderExtraData; + return ; + } + case CellType.CLAIMABLE: { + const { uniqueId } = data as ClaimableExtraData; + + return ; + } case CellType.LOADING_ASSETS: return ; diff --git a/src/components/asset-list/RecyclerAssetList2/core/ViewDimensions.tsx b/src/components/asset-list/RecyclerAssetList2/core/ViewDimensions.tsx index 1cc6fb9dad6..27fdb8faa27 100644 --- a/src/components/asset-list/RecyclerAssetList2/core/ViewDimensions.tsx +++ b/src/components/asset-list/RecyclerAssetList2/core/ViewDimensions.tsx @@ -70,6 +70,13 @@ const ViewDimensions: Record = { height: 130, width: deviceUtils.dimensions.width / 2 - 0.1, }, + [CellType.CLAIMABLES_HEADER]: { height: AssetListHeaderHeight }, + [CellType.CLAIMABLES_SPACE_BEFORE]: { height: 10 }, + [CellType.CLAIMABLES_SPACE_AFTER]: { height: 3 }, + [CellType.CLAIMABLE]: { + height: 60, + width: deviceUtils.dimensions.width, + }, [CellType.REMOTE_CARD_CAROUSEL]: { height: 112 }, }; diff --git a/src/components/asset-list/RecyclerAssetList2/core/ViewTypes.ts b/src/components/asset-list/RecyclerAssetList2/core/ViewTypes.ts index a38a6d1ab05..333ed7c9fe6 100644 --- a/src/components/asset-list/RecyclerAssetList2/core/ViewTypes.ts +++ b/src/components/asset-list/RecyclerAssetList2/core/ViewTypes.ts @@ -30,6 +30,11 @@ export enum CellType { POSITION = 'POSITION', POSITIONS_SPACE_AFTER = 'POSITIONS_SPACE_AFTER', + CLAIMABLES_SPACE_BEFORE = 'CLAIMABLES_SPACE_BEFORE', + CLAIMABLES_HEADER = 'CLAIMABLES_HEADER', + CLAIMABLE = 'CLAIMABLE', + CLAIMABLES_SPACE_AFTER = 'CLAIMABLES_SPACE_AFTER', + LOADING_ASSETS = 'LOADING_ASSETS', RECEIVE_CARD = 'RECEIVE_CARD', ETH_CARD = 'ETH_CARD', @@ -74,6 +79,12 @@ export type PositionExtraData = { export type PositionHeaderExtraData = { total: string; }; +export type ClaimableExtraData = { + uniqueId: string; +}; +export type ClaimablesHeaderExtraData = { + total: string; +}; export type NFTFamilyExtraData = { type: CellType.FAMILY_HEADER; name: string; @@ -90,6 +101,8 @@ export type CellExtraData = | AssetListHeaderExtraData | AssetsHeaderExtraData | PositionExtraData - | PositionHeaderExtraData; + | PositionHeaderExtraData + | ClaimableExtraData + | ClaimablesHeaderExtraData; export type CellTypes = BaseCellType & CellExtraData; diff --git a/src/components/asset-list/RecyclerAssetList2/core/useMemoBriefSectionData.ts b/src/components/asset-list/RecyclerAssetList2/core/useMemoBriefSectionData.ts index 875d7f91d90..4ef83d223a9 100644 --- a/src/components/asset-list/RecyclerAssetList2/core/useMemoBriefSectionData.ts +++ b/src/components/asset-list/RecyclerAssetList2/core/useMemoBriefSectionData.ts @@ -12,6 +12,7 @@ import { } from '@/hooks'; import useOpenPositionCards from '@/hooks/useOpenPositionCards'; import * as ls from '@/storage'; +import useOpenClaimables from '@/hooks/useOpenClaimables'; const FILTER_TYPES = { 'ens-profile': [CellType.NFT_SPACE_AFTER, CellType.NFT, CellType.FAMILY_HEADER], @@ -47,6 +48,7 @@ export default function useMemoBriefSectionData({ const { isSmallBalancesOpen } = useOpenSmallBalances(); const { isPositionCardsOpen } = useOpenPositionCards(); + const { isClaimablesOpen } = useOpenClaimables(); const { isCoinListEdited } = useCoinListEdited(); const { hiddenCoinsObj } = useCoinListEditOptions(); const { openFamilies } = useOpenFamilies(); @@ -107,6 +109,10 @@ export default function useMemoBriefSectionData({ return false; } + if (data.type === CellType.CLAIMABLE && !isClaimablesOpen) { + return false; + } + index++; return true; }) @@ -114,7 +120,7 @@ export default function useMemoBriefSectionData({ return { type: cellType, uid }; }); return briefSectionsDataFiltered; - }, [sectionsDataToUse, type, isCoinListEdited, isSmallBalancesOpen, hiddenCoinsObj, isPositionCardsOpen, openFamilies]); + }, [sectionsDataToUse, type, isCoinListEdited, isSmallBalancesOpen, hiddenCoinsObj, isPositionCardsOpen, isClaimablesOpen, openFamilies]); const memoizedResult = useDeepCompareMemo(() => result, [result]); const additionalData = useDeepCompareMemo( () => diff --git a/src/config/experimental.ts b/src/config/experimental.ts index 1a3d1892632..6aa5824dbdb 100644 --- a/src/config/experimental.ts +++ b/src/config/experimental.ts @@ -29,6 +29,7 @@ export const DAPP_BROWSER = 'Dapp Browser'; export const ETH_REWARDS = 'ETH Rewards'; export const DEGEN_MODE = 'Degen Mode'; export const FEATURED_RESULTS = 'Featured Results'; +export const CLAIMABLES = 'Claimables'; export const NFTS_ENABLED = 'Nfts Enabled'; /** @@ -67,6 +68,7 @@ export const defaultConfig: Record = { [ETH_REWARDS]: { settings: true, value: false }, [DEGEN_MODE]: { settings: true, value: false }, [FEATURED_RESULTS]: { settings: true, value: false }, + [CLAIMABLES]: { settings: true, value: false }, [NFTS_ENABLED]: { settings: true, value: !!IS_TEST }, }; diff --git a/src/helpers/buildWalletSections.tsx b/src/helpers/buildWalletSections.tsx index 8cc4dc1e504..b0da188e88a 100644 --- a/src/helpers/buildWalletSections.tsx +++ b/src/helpers/buildWalletSections.tsx @@ -4,9 +4,15 @@ import { NativeCurrencyKey, ParsedAddressAsset } from '@/entities'; import { queryClient } from '@/react-query'; import { positionsQueryKey } from '@/resources/defi/PositionsQuery'; import store from '@/redux/store'; -import { PositionExtraData } from '@/components/asset-list/RecyclerAssetList2/core/ViewTypes'; -import { getExperimetalFlag, DEFI_POSITIONS } from '@/config/experimental'; +import { ClaimableExtraData, PositionExtraData } from '@/components/asset-list/RecyclerAssetList2/core/ViewTypes'; +import { getExperimetalFlag, DEFI_POSITIONS, CLAIMABLES, ExperimentalValue } from '@/config/experimental'; import { RainbowPositions } from '@/resources/defi/types'; +import { claimablesQueryKey } from '@/resources/addys/claimables/query'; +import { Claimable } from '@/resources/addys/claimables/types'; +import { add, convertAmountToNativeDisplay } from './utilities'; +import { getRemoteConfig, RainbowConfig } from '@/model/remoteConfig'; +import { useConnectedToHardhatStore } from '@/state/connectedToHardhat'; +import { IS_TEST } from '@/env'; const CONTENT_PLACEHOLDER = [ { type: 'LOADING_ASSETS', uid: 'loadings-asset-1' }, @@ -53,11 +59,23 @@ const uniqueTokensSelector = (state: any) => state.uniqueTokens; const nftSortSelector = (state: any) => state.nftSort; const isFetchingNftsSelector = (state: any) => state.isFetchingNfts; const listTypeSelector = (state: any) => state.listType; +const remoteConfigSelector = (state: any) => state.remoteConfig; +const experimentalConfigSelector = (state: any) => state.experimentalConfig; -const buildBriefWalletSections = (balanceSectionData: any, uniqueTokenFamiliesSection: any) => { +const buildBriefWalletSections = ( + balanceSectionData: any, + uniqueTokenFamiliesSection: any, + remoteConfig: RainbowConfig, + experimentalConfig: Record +) => { const { balanceSection, isEmpty, isLoadingUserAssets } = balanceSectionData; - const positionSection = withPositionsSection(isLoadingUserAssets); - const sections = [balanceSection, positionSection, uniqueTokenFamiliesSection]; + + const positionsEnabled = experimentalConfig[DEFI_POSITIONS] && !IS_TEST; + const claimablesEnabled = (remoteConfig.claimables || experimentalConfig[CLAIMABLES]) && !IS_TEST; + + const positionSection = positionsEnabled ? withPositionsSection(isLoadingUserAssets) : []; + const claimablesSection = claimablesEnabled ? withClaimablesSection(isLoadingUserAssets) : []; + const sections = [balanceSection, claimablesSection, positionSection, uniqueTokenFamiliesSection]; const filteredSections = sections.filter(section => section.length !== 0).flat(1); @@ -68,10 +86,6 @@ const buildBriefWalletSections = (balanceSectionData: any, uniqueTokenFamiliesSe }; const withPositionsSection = (isLoadingUserAssets: boolean) => { - // check if the feature is enabled - const positionsEnabled = getExperimetalFlag(DEFI_POSITIONS); - if (!positionsEnabled) return []; - const { accountAddress: address, nativeCurrency: currency } = store.getState().settings; const positionsObj: RainbowPositions | undefined = queryClient.getQueryData(positionsQueryKey({ address, currency })); @@ -105,6 +119,48 @@ const withPositionsSection = (isLoadingUserAssets: boolean) => { return []; }; +const withClaimablesSection = (isLoadingUserAssets: boolean) => { + const { accountAddress: address, nativeCurrency: currency } = store.getState().settings; + const isHardhatConnected = useConnectedToHardhatStore.getState().connectedToHardhat; + const claimables: Claimable[] | undefined = queryClient.getQueryData( + claimablesQueryKey({ address, currency, testnetMode: isHardhatConnected }) + ); + + const result: ClaimableExtraData[] = []; + let totalNativeValue = '0'; + claimables?.forEach(claimable => { + totalNativeValue = add(totalNativeValue, claimable.value.nativeAsset.amount ?? '0'); + const listData = { + type: 'CLAIMABLE', + uniqueId: claimable.uniqueId, + uid: `claimable-${claimable.uniqueId}`, + }; + result.push(listData); + }); + const totalNativeDisplay = convertAmountToNativeDisplay(totalNativeValue, currency); + if (result.length && !isLoadingUserAssets) { + const res = [ + { + type: 'CLAIMABLES_SPACE_BEFORE', + uid: 'claimables-header-space-before', + }, + { + type: 'CLAIMABLES_HEADER', + uid: 'claimables-header', + total: totalNativeDisplay, + }, + { + type: 'CLAIMABLES_SPACE_AFTER', + uid: 'claimables-header-space-before', + }, + ...result, + ]; + + return res; + } + return []; +}; + const withBriefBalanceSection = ( sortedAssets: ParsedAddressAsset[], isLoadingUserAssets: boolean, @@ -233,6 +289,6 @@ const briefBalanceSectionSelector = createSelector( ); export const buildBriefWalletSectionsSelector = createSelector( - [briefBalanceSectionSelector, (state: any) => briefUniqueTokenDataSelector(state)], + [briefBalanceSectionSelector, (state: any) => briefUniqueTokenDataSelector(state), remoteConfigSelector, experimentalConfigSelector], buildBriefWalletSections ); diff --git a/src/hooks/useOpenClaimables.ts b/src/hooks/useOpenClaimables.ts new file mode 100644 index 00000000000..c64d2e1ed78 --- /dev/null +++ b/src/hooks/useOpenClaimables.ts @@ -0,0 +1,15 @@ +import { useCallback } from 'react'; +import { useMMKVBoolean } from 'react-native-mmkv'; +import useAccountSettings from './useAccountSettings'; + +export default function useOpenClaimables() { + const { accountAddress } = useAccountSettings(); + const [isClaimablesOpen, setIsClaimablesOpen] = useMMKVBoolean('claimables-open-' + accountAddress); + + const toggleOpenClaimables = useCallback(() => setIsClaimablesOpen(prev => !prev), [setIsClaimablesOpen]); + + return { + isClaimablesOpen, + toggleOpenClaimables, + }; +} diff --git a/src/hooks/useRefreshAccountData.ts b/src/hooks/useRefreshAccountData.ts index f9cb7ab786c..f17233b657c 100644 --- a/src/hooks/useRefreshAccountData.ts +++ b/src/hooks/useRefreshAccountData.ts @@ -15,6 +15,7 @@ import useNftSort from './useNFTsSortBy'; import { Address } from 'viem'; import { addysSummaryQueryKey } from '@/resources/summary/summary'; import useWallets from './useWallets'; +import { claimablesQueryKey } from '@/resources/addys/claimables/query'; import { useConnectedToHardhatStore } from '@/state/connectedToHardhat'; export default function useRefreshAccountData() { @@ -35,6 +36,9 @@ export default function useRefreshAccountData() { const fetchAccountData = useCallback(async () => { queryClient.invalidateQueries(nftsQueryKey({ address: accountAddress, sortBy: nftSort })); queryClient.invalidateQueries(positionsQueryKey({ address: accountAddress as Address, currency: nativeCurrency })); + queryClient.invalidateQueries( + claimablesQueryKey({ address: accountAddress, currency: nativeCurrency, testnetMode: connectedToHardhat }) + ); queryClient.invalidateQueries(addysSummaryQueryKey({ addresses: allAddresses, currency: nativeCurrency })); queryClient.invalidateQueries(userAssetsQueryKey({ address: accountAddress, currency: nativeCurrency, connectedToHardhat })); queryClient.invalidateQueries( diff --git a/src/hooks/useWalletSectionsData.ts b/src/hooks/useWalletSectionsData.ts index 72e89e3de55..f974ee2673c 100644 --- a/src/hooks/useWalletSectionsData.ts +++ b/src/hooks/useWalletSectionsData.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; import useAccountSettings from './useAccountSettings'; import useCoinListEditOptions from './useCoinListEditOptions'; import useCoinListEdited from './useCoinListEdited'; @@ -12,6 +12,8 @@ import { useSortedUserAssets } from '@/resources/assets/useSortedUserAssets'; import { useLegacyNFTs } from '@/resources/nfts'; import useNftSort from './useNFTsSortBy'; import useWalletsWithBalancesAndNames from './useWalletsWithBalancesAndNames'; +import { useRemoteConfig } from '@/model/remoteConfig'; +import { RainbowContext } from '@/helpers/RainbowContext'; export default function useWalletSectionsData({ type, @@ -43,6 +45,9 @@ export default function useWalletSectionsData({ const { showcaseTokens } = useShowcaseTokens(); const { hiddenTokens } = useHiddenTokens(); + const remoteConfig = useRemoteConfig(); + const experimentalConfig = useContext(RainbowContext).config; + const { hiddenCoinsObj: hiddenCoins, pinnedCoinsObj: pinnedCoins } = useCoinListEditOptions(); const { isCoinListEdited } = useCoinListEdited(); @@ -69,6 +74,8 @@ export default function useWalletSectionsData({ uniqueTokens: allUniqueTokens, isFetchingNfts, nftSort, + remoteConfig, + experimentalConfig, }; const { briefSectionsData, isEmpty } = buildBriefWalletSectionsSelector(accountInfo); @@ -92,7 +99,7 @@ export default function useWalletSectionsData({ pinnedCoins, sendableUniqueTokens, sortedAssets, - accountWithBalance, + accountWithBalance?.balances, isWalletEthZero, hiddenTokens, isReadOnlyWallet, @@ -101,6 +108,8 @@ export default function useWalletSectionsData({ allUniqueTokens, isFetchingNfts, nftSort, + remoteConfig, + experimentalConfig, ]); return walletSections; } diff --git a/src/languages/en_US.json b/src/languages/en_US.json index 31697624327..d765cb3a092 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -16,6 +16,7 @@ "tab_balances": "Balances", "tab_balances_empty_state": "Balance", "tab_balances_tooltip": "Ethereum and Token Balances", + "tab_claimables": "Claimables", "tab_collectibles": "Collectibles", "tab_interactions": "Interactions", "tab_interactions_tooltip": "Smart Contract Interactions", diff --git a/src/model/remoteConfig.ts b/src/model/remoteConfig.ts index e2fa3709f8c..208fd748aa6 100644 --- a/src/model/remoteConfig.ts +++ b/src/model/remoteConfig.ts @@ -92,6 +92,7 @@ export interface RainbowConfig extends Record degen_mode: boolean; featured_results: boolean; + claimables: boolean; nfts_enabled: boolean; } @@ -176,6 +177,7 @@ export const DEFAULT_CONFIG: RainbowConfig = { degen_mode: false, featured_results: false, + claimables: false, nfts_enabled: true, }; @@ -233,6 +235,7 @@ export async function fetchRemoteConfig(): Promise { key === 'rewards_enabled' || key === 'degen_mode' || key === 'featured_results' || + key === 'claimables' || key === 'nfts_enabled' ) { config[key] = entry.asBoolean(); diff --git a/src/resources/addys/claimables/query.ts b/src/resources/addys/claimables/query.ts new file mode 100644 index 00000000000..40f304c24ba --- /dev/null +++ b/src/resources/addys/claimables/query.ts @@ -0,0 +1,85 @@ +import { NativeCurrencyKey } from '@/entities'; +import { RainbowFetchClient } from '@/rainbow-fetch'; +import { QueryConfigWithSelect, QueryFunctionArgs, QueryFunctionResult, createQueryKey } from '@/react-query'; +import { SUPPORTED_CHAIN_IDS } from '@/references'; +import { useQuery } from '@tanstack/react-query'; +import { ADDYS_API_KEY } from 'react-native-dotenv'; +import { ConsolidatedClaimablesResponse } from './types'; +import { logger, RainbowError } from '@/logger'; +import { parseClaimables } from './utils'; +import { useRemoteConfig } from '@/model/remoteConfig'; +import { CLAIMABLES, useExperimentalFlag } from '@/config'; +import { useConnectedToHardhatStore } from '@/state/connectedToHardhat'; +import { IS_TEST } from '@/env'; + +const addysHttp = new RainbowFetchClient({ + baseURL: 'https://addys.p.rainbow.me/v3', + headers: { + Authorization: `Bearer ${ADDYS_API_KEY}`, + }, +}); + +// /////////////////////////////////////////////// +// Query Types + +export type ClaimablesArgs = { + address: string; + currency: NativeCurrencyKey; + testnetMode?: boolean; +}; + +// /////////////////////////////////////////////// +// Query Key + +export const claimablesQueryKey = ({ address, currency, testnetMode }: ClaimablesArgs) => + createQueryKey('claimables', { address, currency, testnetMode }, { persisterVersion: 1 }); + +type ClaimablesQueryKey = ReturnType; + +// /////////////////////////////////////////////// +// Query Function + +async function claimablesQueryFunction({ queryKey: [{ address, currency, testnetMode }] }: QueryFunctionArgs) { + try { + const url = `/${SUPPORTED_CHAIN_IDS({ testnetMode }).join(',')}/${address}/claimables`; + const { data } = await addysHttp.get(url, { + params: { + currency: currency.toLowerCase(), + }, + timeout: 20000, + }); + + if (data.metadata.status !== 'ok') { + logger.error(new RainbowError('[userAssetsQueryFunction]: Failed to fetch user assets (API error)'), { + message: data.metadata.errors, + }); + } + + return parseClaimables(data.payload.claimables, currency); + } catch (e) { + logger.error(new RainbowError('[userAssetsQueryFunction]: Failed to fetch user assets (client error)'), { + message: (e as Error)?.message, + }); + } +} + +type ClaimablesResult = QueryFunctionResult; + +// /////////////////////////////////////////////// +// Query Hook + +export function useClaimables( + { address, currency }: ClaimablesArgs, + config: QueryConfigWithSelect = {} +) { + const { claimables: remoteFlag } = useRemoteConfig(); + const localFlag = useExperimentalFlag(CLAIMABLES); + const { connectedToHardhat } = useConnectedToHardhatStore(); + + return useQuery(claimablesQueryKey({ address, currency, testnetMode: connectedToHardhat }), claimablesQueryFunction, { + ...config, + enabled: !!address && (remoteFlag || localFlag) && !IS_TEST, + staleTime: 1000 * 60 * 2, + cacheTime: 1000 * 60 * 60 * 24, + }); +} diff --git a/src/resources/addys/claimables/types.ts b/src/resources/addys/claimables/types.ts new file mode 100644 index 00000000000..814a4bfc869 --- /dev/null +++ b/src/resources/addys/claimables/types.ts @@ -0,0 +1,73 @@ +import { ChainId } from '@rainbow-me/swaps'; +import { Address } from 'viem'; +import { AddysAsset, AddysConsolidatedError, AddysResponseStatus } from '../types'; + +interface Colors { + primary: string; + fallback: string; + shadow: string; +} + +interface ClaimActionSponsored { + url: string; + method: string; +} + +interface ClaimActionTransaction { + address_to: Address; + calldata: string; + chain_id: ChainId; +} + +type ClaimAction = ClaimActionTransaction | ClaimActionSponsored; + +interface DApp { + name: string; + url: string; + icon_url: string; + colors: Colors; +} + +type ClaimableType = 'transaction' | 'sponsored'; + +export interface AddysClaimable { + name: string; + unique_id: string; + type: ClaimableType; + network: ChainId; + asset: AddysAsset; + amount: string; + dapp: DApp; + claim_action_type?: string | null; + claim_action?: ClaimAction[]; +} + +interface ConsolidatedClaimablesPayloadResponse { + claimables: AddysClaimable[]; +} + +interface ConsolidatedClaimablesMetadataResponse { + addresses: Address[]; + currency: string; + chain_ids: ChainId[]; + errors: AddysConsolidatedError[]; + addresses_with_errors: Address[]; + chain_ids_with_errors: ChainId[]; + status: AddysResponseStatus; +} + +export interface ConsolidatedClaimablesResponse { + metadata: ConsolidatedClaimablesMetadataResponse; + payload: ConsolidatedClaimablesPayloadResponse; +} + +// will add more attributes as needed +export interface Claimable { + name: string; + uniqueId: string; + iconUrl: string; + value: { + claimAsset: { amount: string; display: string }; + nativeAsset: { amount: string; display: string }; + }; +} diff --git a/src/resources/addys/claimables/utils.ts b/src/resources/addys/claimables/utils.ts new file mode 100644 index 00000000000..30b0395cc87 --- /dev/null +++ b/src/resources/addys/claimables/utils.ts @@ -0,0 +1,17 @@ +import { NativeCurrencyKey } from '@/entities'; +import { AddysClaimable, Claimable } from './types'; +import { convertRawAmountToBalance, convertRawAmountToNativeDisplay, greaterThan, lessThan } from '@/helpers/utilities'; + +export const parseClaimables = (claimables: AddysClaimable[], currency: NativeCurrencyKey): Claimable[] => { + return claimables + .map(claimable => ({ + name: claimable.name, + uniqueId: claimable.unique_id, + iconUrl: claimable.dapp.icon_url, + value: { + claimAsset: convertRawAmountToBalance(claimable.amount, claimable.asset), + nativeAsset: convertRawAmountToNativeDisplay(claimable.amount, claimable.asset.decimals, claimable.asset.price.value, currency), + }, + })) + .sort((a, b) => (greaterThan(a.value.claimAsset.amount ?? '0', b.value.claimAsset.amount ?? '0') ? -1 : 1)); +}; diff --git a/src/resources/addys/types.ts b/src/resources/addys/types.ts new file mode 100644 index 00000000000..34cd7593edc --- /dev/null +++ b/src/resources/addys/types.ts @@ -0,0 +1,68 @@ +import { ChainId } from '@/networks/types'; +import { Address } from 'viem'; + +interface BridgeableNetwork { + bridgeable: boolean; +} + +interface TokenBridging { + bridgeable: boolean; + networks: Record; +} + +interface TokenMapping { + address: Address; + decimals: number; +} + +interface Price { + value: number; + changed_at: number; + relative_change_24h: number; +} + +interface AssetColors { + primary: string; + fallback?: string; + shadow?: string; +} + +export interface AddysAsset { + asset_code: string; + decimals: number; + icon_url: string; + name: string; + network?: string; + chain_id?: ChainId; + price: Price; + symbol: string; + type?: string; + interface?: string; + colors?: AssetColors; + networks?: Record; + // Adding as pointer to avoid showing on NFTs + bridging?: TokenBridging | null; + // To avoid zerion from filtering assets themselves, we add this internal flag to verify them ourselves + probable_spam: boolean; + // New field to handle ERC-721 and ERC-1155 token ids + token_id?: string; + // For ERC-20 tokens, we show the verified status + verified?: boolean; + // Mark defi position based on token type + defi_position?: boolean; + // Transferable Making it a pointer so NFTs doesn't show this field + transferable?: boolean | null; + creation_date?: Date | null; +} + +export type AddysResponseStatus = 'ok' | 'still_indexing' | 'not_found' | 'pending' | 'error'; + +interface ConsolidatedChainIDError { + chain_id: ChainId; + error: string; +} + +export interface AddysConsolidatedError { + address: Address; + errors: ConsolidatedChainIDError[]; +} diff --git a/src/screens/WalletScreen/index.tsx b/src/screens/WalletScreen/index.tsx index e8b297d502e..7b00c9e94e7 100644 --- a/src/screens/WalletScreen/index.tsx +++ b/src/screens/WalletScreen/index.tsx @@ -28,6 +28,7 @@ import { IS_ANDROID } from '@/env'; import { RemoteCardsSync } from '@/state/sync/RemoteCardsSync'; import { RemotePromoSheetSync } from '@/state/sync/RemotePromoSheetSync'; import { UserAssetsSync } from '@/state/sync/UserAssetsSync'; +import { useClaimables } from '@/resources/addys/claimables/query'; import { MobileWalletProtocolListener } from '@/components/MobileWalletProtocolListener'; import { runWalletBackupStatusChecks } from '@/handlers/walletReadyEvents'; @@ -45,6 +46,7 @@ const WalletScreen: React.FC = ({ navigation, route }) => { const initializeWallet = useInitializeWallet(); const { network: currentNetwork, accountAddress, appIcon, nativeCurrency } = useAccountSettings(); usePositions({ address: accountAddress, currency: nativeCurrency }); + useClaimables({ address: accountAddress, currency: nativeCurrency }); const loadAccountLateData = useLoadAccountLateData(); const loadGlobalLateData = useLoadGlobalLateData(); diff --git a/src/state/connectedToHardhat/index.ts b/src/state/connectedToHardhat/index.ts index 8d6b2c5a4ae..362d70e577e 100644 --- a/src/state/connectedToHardhat/index.ts +++ b/src/state/connectedToHardhat/index.ts @@ -1,4 +1,3 @@ -import create from 'zustand'; import { createRainbowStore } from '../internal/createRainbowStore'; export interface ConnectedToHardhatState {