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 {