Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/import-custom-tokens-react.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion examples/react/src/utils/cofhe.config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
// ],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Button } from '../components';

export const AddCustomTokenButton: React.FC<{
label?: string;
onClick?: () => void;
}> = ({ label = 'Import token', onClick }) => <Button variant="outline" size="sm" label={label} onClick={onClick} />;
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fnx-card-bg border fnx-card-border rounded-lg p-3 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium opacity-70">Token contract address</label>
<input
type="text"
value={addressInput}
onChange={(event) => setAddressInput(event.target.value)}
placeholder="0x..."
className="w-full bg-transparent fnx-text-primary outline-none border-b pb-2 px-2 text-sm"
/>
</div>

{!normalizedAddress && trimmedAddress.length > 0 && (
<p className="text-xxxs text-[#c2410c]">Enter a valid token address.</p>
)}

{resolvedToken.isFetching && !existingToken && (
<p className="text-xxxs opacity-70">Checking token metadata and CoFHE support...</p>
)}

{resolvedToken.error && !existingToken && (
<p className="text-xxxs text-[#c2410c]">{resolvedToken.error.message}</p>
)}

{previewToken && (
<div className="flex flex-col gap-1 text-xs">
<p className="font-medium fnx-text-primary">
{previewToken.symbol} · {previewToken.name}
</p>
<p className="opacity-70">
{previewToken.extensions.fhenix.confidentialityType === 'wrapped'
? 'Wrapped confidential token'
: previewToken.extensions.fhenix.confidentialityType === 'dual'
? 'Dual-balance confidential token'
: 'Pure confidential token'}
</p>
{balanceType === 'public' &&
previewToken.extensions.fhenix.confidentialityType === 'wrapped' &&
!previewToken.extensions.fhenix.erc20Pair && (
<p className="text-xxxs opacity-70">
Public-balance actions may stay unavailable until the token&apos;s paired asset can be discovered.
</p>
)}
</div>
)}

<Button
variant="primary"
size="sm"
disabled={!canImport}
label={existingToken ? 'Select token' : 'Import and select'}
onClick={() => {
if (!previewToken) return;
if (!existingToken) {
addCustomToken(previewToken);
}
onSelectToken(previewToken);
}}
/>

{importedCustomTokens.length > 0 && (
<div className="flex flex-col gap-2 border-t pt-3">
<p className="text-xs font-medium opacity-70">Imported tokens</p>
{importedCustomTokens.map((token) => (
<div
key={`${token.chainId}-${token.address}`}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium fnx-text-primary truncate">
{token.symbol} · {token.name}
</p>
<p className="text-xxxs opacity-70 truncate">{token.address}</p>
</div>
<Button
variant="error"
size="sm"
label="Remove"
onClick={() => {
removeCustomToken({
chainId: token.chainId,
address: token.address,
});

if (normalizedAddress?.toLowerCase() === token.address.toLowerCase()) {
setAddressInput('');
}
}}
/>
</div>
))}
</div>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import ArrowBackIcon from '@mui/icons-material/ArrowBack';

import { PageContainer } from '../components/PageContainer';
import { ImportCustomTokenCard } from './ImportCustomTokenCard';
import { PortalModal, type PortalModalStateMap } from './types';

export const ImportCustomTokenModal: React.FC<PortalModalStateMap[PortalModal.ImportCustomToken]> = ({
tokens,
onClose,
title,
onSelectToken,
balanceType,
}) => {
return (
<PageContainer
isModal
header={
<button onClick={onClose} className="flex items-center gap-1 text-sm hover:opacity-80 transition-opacity">
<ArrowBackIcon style={{ fontSize: 16 }} />
<p className="text-sm font-medium">{title}</p>
</button>
}
content={
<ImportCustomTokenCard
balanceType={balanceType}
tokens={tokens}
onSelectToken={(token) => {
onSelectToken(token);
onClose();
}}
/>
}
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
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 '@/types/token';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
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 = () => <Button variant="outline" size="sm" label="Add custom Token +" />;
export const TokenListModal: React.FC<PortalModalStateMap[PortalModal.TokenList]> = ({
tokens,
onClose,
title,
onSelectToken,
balanceType,
}) => {
const { openModal } = usePortalModals();

return (
<PageContainer
header={
Expand All @@ -24,7 +26,19 @@ export const TokenListModal: React.FC<PortalModalStateMap[PortalModal.TokenList]
<ArrowBackIcon style={{ fontSize: 16 }} />
<p className="text-sm font-medium">{title}</p>
</button>
<AddCustomTokenButton />
<AddCustomTokenButton
onClick={() =>
openModal(PortalModal.ImportCustomToken, {
balanceType,
title: 'Import token',
tokens,
onSelectToken: (token) => {
onSelectToken(token);
onClose();
},
})
}
/>
</div>
}
content={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PortalModalStateMap[M]> } = {
[PortalModal.ExampleSelection]: ExampleSelectionPage,
Expand All @@ -13,4 +14,5 @@ export const modals: { [M in PortalModal]: React.FC<PortalModalStateMap[M]> } =
[PortalModal.PermitTypeInfo]: PermitTypeInfoModal,
[PortalModal.PermitInfo]: PermitInfoModal,
[PortalModal.TokenList]: TokenListModal,
[PortalModal.ImportCustomToken]: ImportCustomTokenModal,
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export enum PortalModal {
PermitTypeInfo = 'permitTypeInfo',
PermitInfo = 'permitInfo',
TokenList = 'tokenList',
ImportCustomToken = 'importCustomToken',
}

export type PortalModalPropsMap = {
Expand All @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 { AddCustomTokenButton } from '../modals/AddCustomTokenButton';
import { usePortalModals, usePortalNavigation } from '@/stores';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { BalanceType } from '../components/CofheTokenConfidentialBalance';
import { PortalModal } from '../modals/types';

declare module '../pagesConfig/types' {
interface FloatingButtonPagePropsRegistry {
Expand All @@ -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);
Expand All @@ -29,7 +32,22 @@ export const PortfolioPage: React.FC = () => {
<ArrowBackIcon style={{ fontSize: 16 }} />
<p className="text-sm font-medium">Tokens list</p>
</button>
<AddCustomTokenButton />
<AddCustomTokenButton
onClick={() =>
openModal(PortalModal.ImportCustomToken, {
balanceType: BalanceType.Confidential,
title: 'Import token',
tokens: allTokens,
onSelectToken: (token) => {
navigateTo(FloatingButtonPage.TokenInfo, {
pageProps: {
token,
},
});
},
})
}
/>
</div>
}
content={
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { useCofheConnection, useCofhePublicClient } from './useCofheConnection';
export { useResolvedCofheToken } from './useResolvedCofheToken';
export { useCofheEnabled, type UseCofheEnabledOptions, type UseCofheEnabledResult } from './useCofheEnabled';
export {
useCofheActivePermit,
Expand Down
Loading
Loading