diff --git a/.changeset/import-custom-tokens-react.md b/.changeset/import-custom-tokens-react.md new file mode 100644 index 00000000..38e93990 --- /dev/null +++ b/.changeset/import-custom-tokens-react.md @@ -0,0 +1,9 @@ +--- +'@cofhe/react': minor +--- + +Add custom token import support to the React widget token picker. + +- Let users import CoFHE tokens by contract address directly from the token list and portfolio flows. +- Persist imported tokens per chain in local storage and merge them into `useCofheTokens()` results. +- Resolve token metadata and CoFHE compatibility on demand before importing, including wrapped-token pair metadata when available. diff --git a/examples/react/src/utils/cofhe.config.tsx b/examples/react/src/utils/cofhe.config.tsx index 5d318baf..3d02e5be 100644 --- a/examples/react/src/utils/cofhe.config.tsx +++ b/examples/react/src/utils/cofhe.config.tsx @@ -24,7 +24,8 @@ const cofheConfig = createCofheConfig({ // 84532: '0xbED96aa98a49FeA71fcC55d755b915cF022a9159', // base sepolia weth // }, tokenLists: { - 11155111: ['https://storage.googleapis.com/cofhesdk/sepolia.json'], + // 11155111: ['https://storage.googleapis.com/cofhesdk/sepolia.json'], + 11155111: ['https://api.npoint.io/2d295a8f9f9d2c0c6678'], // contains only ETH // 11155111: [ // 'https://api.npoint.io/439ce3fd4b44eaa6f917', // contains "failing usdc" // ], diff --git a/packages/react/src/components/FnxFloatingButton/modals/AddCustomTokenButton.tsx b/packages/react/src/components/FnxFloatingButton/modals/AddCustomTokenButton.tsx new file mode 100644 index 00000000..9b51a7ff --- /dev/null +++ b/packages/react/src/components/FnxFloatingButton/modals/AddCustomTokenButton.tsx @@ -0,0 +1,6 @@ +import { Button } from '../components'; + +export const AddCustomTokenButton: React.FC<{ + label?: string; + onClick?: () => void; +}> = ({ label = 'Import token', onClick }) => + } + content={ + { + onSelectToken(token); + onClose(); + }} + /> + } + /> + ); +}; diff --git a/packages/react/src/components/FnxFloatingButton/modals/TokenListModal.tsx b/packages/react/src/components/FnxFloatingButton/modals/TokenListModal.tsx index 6017937a..180d3871 100644 --- a/packages/react/src/components/FnxFloatingButton/modals/TokenListModal.tsx +++ b/packages/react/src/components/FnxFloatingButton/modals/TokenListModal.tsx @@ -1,14 +1,14 @@ import { PageContainer } from '../components/PageContainer'; import { PortalModal, type PortalModalStateMap } from './types'; -import { useCofheChainId } from '@/hooks/useCofheConnection'; -import { type Token, useCofheTokens } from '@/hooks'; +import { type Token } from '@/hooks'; import { ArrowBackIcon } from '@/components/Icons'; import { TokenRow } from '../components/TokenRow'; import { useCofhePinnedTokenAddress } from '@/hooks/useCofhePinnedTokenAddress'; import type { BalanceType } from '../components/CofheTokenConfidentialBalance'; -import { Button } from '../components'; +import { usePortalModals } from '@/stores'; + +import { AddCustomTokenButton } from './AddCustomTokenButton'; -export const AddCustomTokenButton = () => - + + openModal(PortalModal.ImportCustomToken, { + balanceType, + title: 'Import token', + tokens, + onSelectToken: (token) => { + onSelectToken(token); + onClose(); + }, + }) + } + /> } content={ diff --git a/packages/react/src/components/FnxFloatingButton/modals/index.ts b/packages/react/src/components/FnxFloatingButton/modals/index.ts index 060cf64d..dfbd1892 100644 --- a/packages/react/src/components/FnxFloatingButton/modals/index.ts +++ b/packages/react/src/components/FnxFloatingButton/modals/index.ts @@ -5,6 +5,7 @@ import { PermitDetailsModal } from './PermitDetailsModal'; import { PermitTypeInfoModal } from './PermitTypeInfoModal'; import { PermitInfoModal } from './PermitInfoModal'; import { TokenListModal } from './TokenListModal'; +import { ImportCustomTokenModal } from './ImportCustomTokenModal'; export const modals: { [M in PortalModal]: React.FC } = { [PortalModal.ExampleSelection]: ExampleSelectionPage, @@ -13,4 +14,5 @@ export const modals: { [M in PortalModal]: React.FC } = [PortalModal.PermitTypeInfo]: PermitTypeInfoModal, [PortalModal.PermitInfo]: PermitInfoModal, [PortalModal.TokenList]: TokenListModal, + [PortalModal.ImportCustomToken]: ImportCustomTokenModal, }; diff --git a/packages/react/src/components/FnxFloatingButton/modals/types.ts b/packages/react/src/components/FnxFloatingButton/modals/types.ts index 7afb164c..60c3291e 100644 --- a/packages/react/src/components/FnxFloatingButton/modals/types.ts +++ b/packages/react/src/components/FnxFloatingButton/modals/types.ts @@ -9,6 +9,7 @@ export enum PortalModal { PermitTypeInfo = 'permitTypeInfo', PermitInfo = 'permitInfo', TokenList = 'tokenList', + ImportCustomToken = 'importCustomToken', } export type PortalModalPropsMap = { @@ -23,6 +24,12 @@ export type PortalModalPropsMap = { tokens: Token[]; onSelectToken: (token: Token) => void; }; + [PortalModal.ImportCustomToken]: { + balanceType: BalanceType; + title: string; + tokens: Token[]; + onSelectToken: (token: Token) => void; + }; }; export type PortalModalsWithProps = { diff --git a/packages/react/src/components/FnxFloatingButton/pages/PortfolioPage.tsx b/packages/react/src/components/FnxFloatingButton/pages/PortfolioPage.tsx index 505e53c5..0be2069c 100644 --- a/packages/react/src/components/FnxFloatingButton/pages/PortfolioPage.tsx +++ b/packages/react/src/components/FnxFloatingButton/pages/PortfolioPage.tsx @@ -2,10 +2,12 @@ import { useCofheChainId } from '@/hooks/useCofheConnection'; import { PageContainer } from '../components/PageContainer'; import { FloatingButtonPage } from '../pagesConfig/types'; import { useCofheTokens } from '@/hooks'; -import { AddCustomTokenButton, TokenListContent } from '../modals/TokenListModal'; -import { usePortalNavigation } from '@/stores'; +import { TokenListContent } from '../modals/TokenListModal'; +import { usePortalModals, usePortalNavigation } from '@/stores'; import { ArrowBackIcon } from '@/components/Icons'; import { BalanceType } from '../components/CofheTokenConfidentialBalance'; +import { PortalModal } from '../modals/types'; +import { AddCustomTokenButton } from '../modals/AddCustomTokenButton'; declare module '../pagesConfig/types' { interface FloatingButtonPagePropsRegistry { @@ -14,6 +16,7 @@ declare module '../pagesConfig/types' { } export const PortfolioPage: React.FC = () => { const { navigateBack, navigateTo } = usePortalNavigation(); + const { openModal } = usePortalModals(); const chainId = useCofheChainId(); const allTokens = useCofheTokens(chainId); @@ -29,7 +32,22 @@ export const PortfolioPage: React.FC = () => {

Tokens list

- + + openModal(PortalModal.ImportCustomToken, { + balanceType: BalanceType.Confidential, + title: 'Import token', + tokens: allTokens, + onSelectToken: (token) => { + navigateTo(FloatingButtonPage.TokenInfo, { + pageProps: { + token, + }, + }); + }, + }) + } + /> } content={ diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 12b8c25e..406fc788 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -1,4 +1,5 @@ export { useCofheConnection, useCofhePublicClient } from './useCofheConnection'; +export { useResolvedCofheToken } from './useResolvedCofheToken'; export { useCofheEnabled, type UseCofheEnabledOptions, type UseCofheEnabledResult } from './useCofheEnabled'; export { useCofheActivePermit, diff --git a/packages/react/src/hooks/useCofheTokenLists.ts b/packages/react/src/hooks/useCofheTokenLists.ts index 31cdb015..b2197666 100644 --- a/packages/react/src/hooks/useCofheTokenLists.ts +++ b/packages/react/src/hooks/useCofheTokenLists.ts @@ -5,6 +5,8 @@ import { ETH_ADDRESS_LOWERCASE, type Erc20Pair, type Token } from '../types/toke import { useInternalQueries } from '../providers/index.js'; import type { Address } from 'viem'; import { useCofheChainId } from './useCofheConnection'; +import { useCustomTokensStore } from '@/stores/customTokensStore'; +import { useResolvedCofheToken } from './useResolvedCofheToken'; export { ETH_ADDRESS_LOWERCASE, type Token, type Erc20Pair }; @@ -24,6 +26,12 @@ type UseTokenListsInput = { chainId?: number; }; type UseTokenListsOptions = Omit, 'queryKey' | 'queryFn' | 'select'>; + +function getCustomTokensForChain(customTokensByChainId: Record, chainId?: number): Token[] { + if (!chainId) return []; + return customTokensByChainId[chainId.toString()] ?? []; +} + // Returns array of query results for token lists for the current network export function useCofheTokenLists( { chainId }: UseTokenListsInput, @@ -71,8 +79,12 @@ export function selectTokensFromTokensList(tokenList: TokenList): Token[] { export function useCofheTokens(chainId?: number): Token[] { const tokenLists = useCofheTokenLists({ chainId }); + const customTokensByChainId = useCustomTokensStore((state) => state.customTokensByChainId); + const customTokens = getCustomTokensForChain(customTokensByChainId, chainId); + const tokens = useMemo(() => { const map = new Map(); + tokenLists.forEach((result) => { if (!result.data) return; @@ -82,8 +94,15 @@ export function useCofheTokens(chainId?: number): Token[] { map.set(key, token); }); }); + + customTokens.forEach((token) => { + const key = `${token.chainId}-${token.address.toLowerCase()}`; + if (map.has(key)) return; + map.set(key, token); + }); + return Array.from(map.values()); - }, [tokenLists]); + }, [customTokens, tokenLists]); return tokens; } @@ -101,7 +120,16 @@ export function useCofheToken( return tokens.find((t) => t.chainId === chainId && t.address.toLowerCase() === address.toLowerCase()); }, [address, chainId, tokens]); - // TODO: fetch from chain (metadata) if all the token lists have been loaded but token is not found + const resolvedToken = useResolvedCofheToken( + { + chainId, + address, + }, + { + ...metdataQueryOptions, + enabled: (metdataQueryOptions?.enabled ?? true) && !!address && !!chainId && !tokenFromList, + } + ); - return tokenFromList; + return tokenFromList ?? resolvedToken.data; } diff --git a/packages/react/src/hooks/useResolvedCofheToken.ts b/packages/react/src/hooks/useResolvedCofheToken.ts new file mode 100644 index 00000000..fa6289b1 --- /dev/null +++ b/packages/react/src/hooks/useResolvedCofheToken.ts @@ -0,0 +1,203 @@ +import type { UseQueryOptions } from '@tanstack/react-query'; +import { type Address, isAddress, parseAbi, zeroAddress } from 'viem'; + +import { ERC20_BALANCE_OF_ABI, ERC20_DECIMALS_ABI, ERC20_NAME_ABI, ERC20_SYMBOL_ABI } from '@/constants/erc20ABIs'; +import { CONFIDENTIAL_TYPE_PURE_ABI, CONFIDENTIAL_TYPE_WRAPPED_ABI } from '@/constants/confidentialTokenABIs'; +import { useInternalQuery } from '@/providers'; +import { ETH_ADDRESS_LOWERCASE, type Token } from '@/types/token'; + +import { useCofheChainId, useCofhePublicClient } from './useCofheConnection'; + +const TOKEN_PAIR_GETTER_ABIS = { + token: parseAbi(['function token() view returns (address)']), + underlying: parseAbi(['function underlying() view returns (address)']), + underlyingToken: parseAbi(['function underlyingToken() view returns (address)']), + asset: parseAbi(['function asset() view returns (address)']), + erc20: parseAbi(['function erc20() view returns (address)']), + erc20Token: parseAbi(['function erc20Token() view returns (address)']), +} as const; + +const PAIR_GETTER_ENTRIES = [ + ['token', TOKEN_PAIR_GETTER_ABIS.token], + ['underlying', TOKEN_PAIR_GETTER_ABIS.underlying], + ['underlyingToken', TOKEN_PAIR_GETTER_ABIS.underlyingToken], + ['asset', TOKEN_PAIR_GETTER_ABIS.asset], + ['erc20', TOKEN_PAIR_GETTER_ABIS.erc20], + ['erc20Token', TOKEN_PAIR_GETTER_ABIS.erc20Token], +] as const; + +function pickUnderlyingPairAddress(results: readonly unknown[], tokenAddress: Address): Address | undefined { + for (const result of results) { + if (!result || typeof result !== 'object') continue; + + const typedResult = result as { status?: unknown; result?: unknown }; + if (typedResult.status !== 'success') continue; + + const candidate = typedResult.result; + if (typeof candidate !== 'string' || !isAddress(candidate)) continue; + if (candidate.toLowerCase() === zeroAddress) continue; + if (candidate.toLowerCase() === tokenAddress.toLowerCase()) continue; + return candidate; + } + + return undefined; +} + +type UseResolvedCofheTokenInput = { + chainId?: number; + address?: Address; +}; + +type UseResolvedCofheTokenOptions = Omit, 'queryKey' | 'queryFn'>; + +export function useResolvedCofheToken( + { chainId: _chainId, address }: UseResolvedCofheTokenInput, + queryOptions?: UseResolvedCofheTokenOptions +) { + const publicClient = useCofhePublicClient(); + const cofheChainId = useCofheChainId(); + const chainId = _chainId ?? cofheChainId; + + return useInternalQuery({ + queryKey: ['resolvedCofheToken', chainId, address?.toLowerCase()], + queryFn: async (): Promise => { + if (!publicClient) { + throw new Error('PublicClient is required to resolve a token'); + } + if (!chainId || !address) { + return undefined; + } + + const metadataResults = await publicClient.multicall({ + contracts: [ + { + address, + abi: ERC20_DECIMALS_ABI, + functionName: 'decimals', + }, + { + address, + abi: ERC20_SYMBOL_ABI, + functionName: 'symbol', + }, + { + address, + abi: ERC20_NAME_ABI, + functionName: 'name', + }, + ], + }); + + const decimals = metadataResults[0].result; + const symbol = metadataResults[1].result; + const name = metadataResults[2].result; + + if (decimals == null || symbol == null || name == null) { + throw new Error('Failed to fetch token metadata'); + } + + const [wrappedProbe, confidentialProbe, publicProbe, pairGetterResults] = await Promise.all([ + publicClient + .readContract({ + address, + abi: CONFIDENTIAL_TYPE_WRAPPED_ABI, + functionName: 'encBalanceOf', + args: [zeroAddress], + }) + .then(() => true) + .catch(() => false), + publicClient + .readContract({ + address, + abi: CONFIDENTIAL_TYPE_PURE_ABI, + functionName: 'confidentialBalanceOf', + args: [zeroAddress], + }) + .then(() => true) + .catch(() => false), + publicClient + .readContract({ + address, + abi: ERC20_BALANCE_OF_ABI, + functionName: 'balanceOf', + args: [zeroAddress], + }) + .then(() => true) + .catch(() => false), + publicClient.multicall({ + contracts: PAIR_GETTER_ENTRIES.map(([, abi]) => ({ + address, + abi, + functionName: abi[0].name, + })), + allowFailure: true, + }), + ]); + + if (!wrappedProbe && !confidentialProbe) { + throw new Error('Address is not a supported CoFHE token'); + } + + const confidentialityType = wrappedProbe ? 'wrapped' : publicProbe ? 'dual' : 'pure'; + + const extensions: Token['extensions'] = { + fhenix: { + confidentialityType, + confidentialValueType: confidentialityType === 'wrapped' ? 'uint128' : 'uint64', + }, + }; + + if (confidentialityType === 'wrapped') { + const pairAddress = pickUnderlyingPairAddress(pairGetterResults, address); + if (pairAddress) { + if (pairAddress.toLowerCase() === ETH_ADDRESS_LOWERCASE) { + extensions.fhenix.erc20Pair = { + address: ETH_ADDRESS_LOWERCASE, + symbol: 'ETH', + decimals: 18, + }; + } else { + const pairMetadata = await publicClient.multicall({ + contracts: [ + { + address: pairAddress, + abi: ERC20_DECIMALS_ABI, + functionName: 'decimals', + }, + { + address: pairAddress, + abi: ERC20_SYMBOL_ABI, + functionName: 'symbol', + }, + ], + allowFailure: true, + }); + + const pairDecimals = pairMetadata[0]?.status === 'success' ? pairMetadata[0].result : undefined; + const pairSymbol = pairMetadata[1]?.status === 'success' ? pairMetadata[1].result : undefined; + + if (pairDecimals != null && pairSymbol != null) { + extensions.fhenix.erc20Pair = { + address: pairAddress, + symbol: pairSymbol, + decimals: pairDecimals, + }; + } + } + } + } + + return { + chainId, + address, + decimals, + symbol, + name, + extensions, + }; + }, + enabled: (queryOptions?.enabled ?? true) && !!publicClient && !!chainId && !!address, + staleTime: Infinity, + ...queryOptions, + }); +} diff --git a/packages/react/src/stores/customTokensStore.ts b/packages/react/src/stores/customTokensStore.ts new file mode 100644 index 00000000..68630df3 --- /dev/null +++ b/packages/react/src/stores/customTokensStore.ts @@ -0,0 +1,61 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import type { Token } from '@/types/token'; + +type CustomTokensStore = { + customTokensByChainId: Record; +}; + +type CustomTokensActions = { + addCustomToken: (token: Token) => void; + removeCustomToken: (params: { chainId: number; address: Token['address'] }) => void; +}; + +export const useCustomTokensStore = create()( + persist( + (set) => ({ + customTokensByChainId: {}, + addCustomToken: (token) => { + set((state) => { + const chainKey = token.chainId.toString(); + const existing = state.customTokensByChainId[chainKey] ?? []; + const normalizedAddress = token.address.toLowerCase(); + + return { + customTokensByChainId: { + ...state.customTokensByChainId, + [chainKey]: [token, ...existing.filter((item) => item.address.toLowerCase() !== normalizedAddress)], + }, + }; + }); + }, + removeCustomToken: ({ chainId, address }) => { + set((state) => { + const chainKey = chainId.toString(); + const existing = state.customTokensByChainId[chainKey] ?? []; + const filtered = existing.filter((item) => item.address.toLowerCase() !== address.toLowerCase()); + + if (filtered.length === existing.length) { + return state; + } + + const next = { ...state.customTokensByChainId }; + if (filtered.length === 0) { + delete next[chainKey]; + } else { + next[chainKey] = filtered; + } + + return { + customTokensByChainId: next, + }; + }); + }, + }), + { + name: 'cofhesdk-react-custom-tokens', + storage: createJSONStorage(() => localStorage), + } + ) +);