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 }) => ;
diff --git a/packages/react/src/components/FnxFloatingButton/modals/ImportCustomTokenCard.tsx b/packages/react/src/components/FnxFloatingButton/modals/ImportCustomTokenCard.tsx
new file mode 100644
index 00000000..55841472
--- /dev/null
+++ b/packages/react/src/components/FnxFloatingButton/modals/ImportCustomTokenCard.tsx
@@ -0,0 +1,143 @@
+import { useMemo, useState } from 'react';
+import { isAddress, type Address } from 'viem';
+
+import { useCofheChainId } from '@/hooks/useCofheConnection';
+import { useResolvedCofheToken } from '@/hooks/useResolvedCofheToken';
+import { useCustomTokensStore } from '@/stores/customTokensStore';
+import type { Token } from '@/types/token';
+
+import type { BalanceType } from '../components/CofheTokenConfidentialBalance';
+import { Button } from '../components';
+
+export const ImportCustomTokenCard: React.FC<{
+ balanceType: BalanceType;
+ tokens: Token[];
+ onSelectToken: (token: Token) => void;
+}> = ({ tokens, onSelectToken, balanceType }) => {
+ const [addressInput, setAddressInput] = useState('');
+ const chainId = useCofheChainId();
+ const addCustomToken = useCustomTokensStore((state) => state.addCustomToken);
+ const removeCustomToken = useCustomTokensStore((state) => state.removeCustomToken);
+ const customTokensByChainId = useCustomTokensStore((state) => state.customTokensByChainId);
+
+ const trimmedAddress = addressInput.trim();
+ const normalizedAddress = isAddress(trimmedAddress) ? (trimmedAddress as Address) : undefined;
+
+ const importedCustomTokens = useMemo(() => {
+ if (!chainId) return [];
+ return customTokensByChainId[chainId.toString()] ?? [];
+ }, [chainId, customTokensByChainId]);
+
+ const existingToken = useMemo(() => {
+ if (!normalizedAddress) return undefined;
+ return tokens.find((token) => token.address.toLowerCase() === normalizedAddress.toLowerCase());
+ }, [normalizedAddress, tokens]);
+
+ const resolvedToken = useResolvedCofheToken(
+ { address: normalizedAddress },
+ {
+ enabled: !!normalizedAddress && !existingToken,
+ retry: false,
+ }
+ );
+
+ const previewToken = existingToken ?? resolvedToken.data;
+ const canImport = !!previewToken && !resolvedToken.isFetching;
+
+ return (
+
+
+
+ setAddressInput(event.target.value)}
+ placeholder="0x..."
+ className="w-full bg-transparent fnx-text-primary outline-none border-b pb-2 px-2 text-sm"
+ />
+
+
+ {!normalizedAddress && trimmedAddress.length > 0 && (
+
Enter a valid token address.
+ )}
+
+ {resolvedToken.isFetching && !existingToken && (
+
Checking token metadata and CoFHE support...
+ )}
+
+ {resolvedToken.error && !existingToken && (
+
{resolvedToken.error.message}
+ )}
+
+ {previewToken && (
+
+
+ {previewToken.symbol} · {previewToken.name}
+
+
+ {previewToken.extensions.fhenix.confidentialityType === 'wrapped'
+ ? 'Wrapped confidential token'
+ : previewToken.extensions.fhenix.confidentialityType === 'dual'
+ ? 'Dual-balance confidential token'
+ : 'Pure confidential token'}
+
+ {balanceType === 'public' &&
+ previewToken.extensions.fhenix.confidentialityType === 'wrapped' &&
+ !previewToken.extensions.fhenix.erc20Pair && (
+
+ Public-balance actions may stay unavailable until the token's paired asset can be discovered.
+
+ )}
+
+ )}
+
+
+ );
+};
diff --git a/packages/react/src/components/FnxFloatingButton/modals/ImportCustomTokenModal.tsx b/packages/react/src/components/FnxFloatingButton/modals/ImportCustomTokenModal.tsx
new file mode 100644
index 00000000..9743f7cc
--- /dev/null
+++ b/packages/react/src/components/FnxFloatingButton/modals/ImportCustomTokenModal.tsx
@@ -0,0 +1,34 @@
+import { ArrowBackIcon } from '@/components/Icons';
+import { PageContainer } from '../components/PageContainer';
+import { ImportCustomTokenCard } from './ImportCustomTokenCard';
+import { PortalModal, type PortalModalStateMap } from './types';
+
+export const ImportCustomTokenModal: React.FC = ({
+ tokens,
+ onClose,
+ title,
+ onSelectToken,
+ balanceType,
+}) => {
+ return (
+
+
+ {title}
+
+ }
+ 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 = () => ;
export const TokenListModal: React.FC = ({
tokens,
onClose,
@@ -16,6 +16,8 @@ export const TokenListModal: React.FC {
+ const { openModal } = usePortalModals();
+
return (
{title}
-
+
+ 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),
+ }
+ )
+);