Skip to main content

Token Selection

This example demonstrates how to build React components for selecting source chains and tokens, including filtering tokens by balance.

Overview

A bridge interface typically needs:
  1. A chain selector dropdown
  2. A token selector with search and balance display
  3. State management to connect selections to the quote system

Chain Selection Component

This component displays available source chains with logos and names.
import { useState, useEffect } from 'react';
import { useMina } from '@siphoyawe/mina-sdk/react';
import type { Chain } from '@siphoyawe/mina-sdk/react';

interface ChainSelectorProps {
  selectedChainId: number | null;
  onSelect: (chain: Chain) => void;
}

export function ChainSelector({ selectedChainId, onSelect }: ChainSelectorProps) {
  const { mina, isReady } = useMina();
  const [chains, setChains] = useState<Chain[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [isOpen, setIsOpen] = useState(false);

  // Fetch available chains on mount
  useEffect(() => {
    if (!mina || !isReady) return;

    async function fetchChains() {
      setIsLoading(true);
      try {
        // Get chains with valid routes to HyperEVM
        const availableChains = await mina.getChainsByRoutes();
        setChains(availableChains);
      } catch (error) {
        console.error('Failed to fetch chains:', error);
      } finally {
        setIsLoading(false);
      }
    }

    fetchChains();
  }, [mina, isReady]);

  // Find the selected chain object
  const selectedChain = chains.find((c) => c.id === selectedChainId);

  if (isLoading) {
    return (
      <div className="chain-selector loading">
        <span>Loading chains...</span>
      </div>
    );
  }

  return (
    <div className="chain-selector">
      {/* Selected Chain Display / Trigger Button */}
      <button
        className="chain-selector-trigger"
        onClick={() => setIsOpen(!isOpen)}
        type="button"
      >
        {selectedChain ? (
          <>
            <img
              src={selectedChain.logoUrl}
              alt={selectedChain.name}
              className="chain-logo"
            />
            <span>{selectedChain.name}</span>
          </>
        ) : (
          <span>Select a chain</span>
        )}
        <ChevronDownIcon />
      </button>

      {/* Dropdown List */}
      {isOpen && (
        <div className="chain-selector-dropdown">
          {chains.map((chain) => (
            <button
              key={chain.id}
              className={`chain-option ${chain.id === selectedChainId ? 'selected' : ''}`}
              onClick={() => {
                onSelect(chain);
                setIsOpen(false);
              }}
              type="button"
            >
              <img
                src={chain.logoUrl}
                alt={chain.name}
                className="chain-logo"
              />
              <span className="chain-name">{chain.name}</span>
              <span className="chain-id">ID: {chain.id}</span>
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

// Simple chevron icon component
function ChevronDownIcon() {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="currentColor"
    >
      <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="2" fill="none" />
    </svg>
  );
}

Token Selection Component

This component displays available tokens for a selected chain with balance information.
import { useState, useEffect, useMemo } from 'react';
import { useMina, useTokenBalance } from '@siphoyawe/mina-sdk/react';
import type { Token } from '@siphoyawe/mina-sdk/react';

interface TokenSelectorProps {
  chainId: number | null;
  walletAddress: string | null;
  selectedToken: Token | null;
  onSelect: (token: Token) => void;
  showOnlyWithBalance?: boolean;
}

export function TokenSelector({
  chainId,
  walletAddress,
  selectedToken,
  onSelect,
  showOnlyWithBalance = false,
}: TokenSelectorProps) {
  const { mina, isReady } = useMina();
  const [tokens, setTokens] = useState<Token[]>([]);
  const [balances, setBalances] = useState<Record<string, string>>({});
  const [isLoading, setIsLoading] = useState(false);
  const [isOpen, setIsOpen] = useState(false);
  const [searchQuery, setSearchQuery] = useState('');

  // Fetch tokens when chain changes
  useEffect(() => {
    if (!mina || !isReady || !chainId) {
      setTokens([]);
      return;
    }

    async function fetchTokens() {
      setIsLoading(true);
      try {
        // Get bridgeable tokens (only tokens that can bridge to HyperEVM)
        const bridgeableTokens = await mina.getBridgeableTokens(chainId);
        setTokens(bridgeableTokens);
      } catch (error) {
        console.error('Failed to fetch tokens:', error);
      } finally {
        setIsLoading(false);
      }
    }

    fetchTokens();
  }, [mina, isReady, chainId]);

  // Fetch balances for all tokens
  useEffect(() => {
    if (!mina || !isReady || !chainId || !walletAddress || tokens.length === 0) {
      return;
    }

    async function fetchBalances() {
      const balanceMap: Record<string, string> = {};

      // Fetch balances in parallel (batch of 5 at a time)
      const batchSize = 5;
      for (let i = 0; i < tokens.length; i += batchSize) {
        const batch = tokens.slice(i, i + batchSize);
        const results = await Promise.allSettled(
          batch.map((token) =>
            mina.getBalance(chainId, token.address, walletAddress)
          )
        );

        results.forEach((result, index) => {
          if (result.status === 'fulfilled') {
            balanceMap[batch[index].address] = result.value.formatted;
          }
        });
      }

      setBalances(balanceMap);
    }

    fetchBalances();
  }, [mina, isReady, chainId, walletAddress, tokens]);

  // Filter and sort tokens
  const filteredTokens = useMemo(() => {
    let filtered = tokens;

    // Filter by search query
    if (searchQuery) {
      const query = searchQuery.toLowerCase();
      filtered = filtered.filter(
        (token) =>
          token.symbol.toLowerCase().includes(query) ||
          token.name.toLowerCase().includes(query)
      );
    }

    // Filter to only tokens with balance
    if (showOnlyWithBalance) {
      filtered = filtered.filter((token) => {
        const balance = balances[token.address];
        return balance && parseFloat(balance) > 0;
      });
    }

    // Sort by balance (tokens with balance first, then alphabetically)
    return filtered.sort((a, b) => {
      const balanceA = parseFloat(balances[a.address] || '0');
      const balanceB = parseFloat(balances[b.address] || '0');

      if (balanceA > 0 && balanceB === 0) return -1;
      if (balanceA === 0 && balanceB > 0) return 1;
      if (balanceA !== balanceB) return balanceB - balanceA;

      return a.symbol.localeCompare(b.symbol);
    });
  }, [tokens, searchQuery, showOnlyWithBalance, balances]);

  if (!chainId) {
    return (
      <div className="token-selector disabled">
        <span>Select a chain first</span>
      </div>
    );
  }

  return (
    <div className="token-selector">
      {/* Selected Token Display */}
      <button
        className="token-selector-trigger"
        onClick={() => setIsOpen(!isOpen)}
        type="button"
        disabled={isLoading}
      >
        {selectedToken ? (
          <>
            <img
              src={selectedToken.logoUrl}
              alt={selectedToken.symbol}
              className="token-logo"
            />
            <div className="token-info">
              <span className="token-symbol">{selectedToken.symbol}</span>
              {balances[selectedToken.address] && (
                <span className="token-balance">
                  {balances[selectedToken.address]}
                </span>
              )}
            </div>
          </>
        ) : (
          <span>{isLoading ? 'Loading tokens...' : 'Select token'}</span>
        )}
      </button>

      {/* Dropdown with Search */}
      {isOpen && (
        <div className="token-selector-dropdown">
          {/* Search Input */}
          <div className="token-search">
            <input
              type="text"
              placeholder="Search tokens..."
              value={searchQuery}
              onChange={(e) => setSearchQuery(e.target.value)}
              autoFocus
            />
          </div>

          {/* Token List */}
          <div className="token-list">
            {filteredTokens.length === 0 ? (
              <div className="no-tokens">
                {searchQuery ? 'No tokens match your search' : 'No tokens available'}
              </div>
            ) : (
              filteredTokens.map((token) => (
                <TokenOption
                  key={token.address}
                  token={token}
                  balance={balances[token.address]}
                  isSelected={selectedToken?.address === token.address}
                  onSelect={() => {
                    onSelect(token);
                    setIsOpen(false);
                    setSearchQuery('');
                  }}
                />
              ))
            )}
          </div>
        </div>
      )}
    </div>
  );
}

// Individual token option component
interface TokenOptionProps {
  token: Token;
  balance?: string;
  isSelected: boolean;
  onSelect: () => void;
}

function TokenOption({ token, balance, isSelected, onSelect }: TokenOptionProps) {
  const hasBalance = balance && parseFloat(balance) > 0;

  return (
    <button
      className={`token-option ${isSelected ? 'selected' : ''} ${hasBalance ? 'has-balance' : ''}`}
      onClick={onSelect}
      type="button"
    >
      <img src={token.logoUrl} alt={token.symbol} className="token-logo" />
      <div className="token-details">
        <span className="token-symbol">{token.symbol}</span>
        <span className="token-name">{token.name}</span>
      </div>
      <div className="token-balance-info">
        {balance ? (
          <>
            <span className="balance-amount">{formatBalance(balance)}</span>
            {token.priceUsd && (
              <span className="balance-usd">
                ${(parseFloat(balance) * token.priceUsd).toFixed(2)}
              </span>
            )}
          </>
        ) : (
          <span className="balance-loading">...</span>
        )}
      </div>
    </button>
  );
}

// Helper to format balance display
function formatBalance(balance: string): string {
  const num = parseFloat(balance);
  if (num === 0) return '0';
  if (num < 0.0001) return '<0.0001';
  if (num < 1) return num.toFixed(4);
  if (num < 1000) return num.toFixed(2);
  if (num < 1000000) return `${(num / 1000).toFixed(2)}K`;
  return `${(num / 1000000).toFixed(2)}M`;
}

Using with State Management

Here’s how to combine these components with React state:
BridgeForm.tsx
import { useState } from 'react';
import { MinaProvider } from '@siphoyawe/mina-sdk/react';
import type { Chain, Token } from '@siphoyawe/mina-sdk/react';
import { ChainSelector } from './ChainSelector';
import { TokenSelector } from './TokenSelector';
import { useAccount } from 'wagmi'; // Or your wallet library

function BridgeForm() {
  const { address: walletAddress } = useAccount();

  // State for selections
  const [sourceChain, setSourceChain] = useState<Chain | null>(null);
  const [sourceToken, setSourceToken] = useState<Token | null>(null);
  const [amount, setAmount] = useState('');

  // Reset token when chain changes
  const handleChainChange = (chain: Chain) => {
    setSourceChain(chain);
    setSourceToken(null); // Clear token selection
    setAmount('');
  };

  return (
    <div className="bridge-form">
      <div className="form-section">
        <label>From Chain</label>
        <ChainSelector
          selectedChainId={sourceChain?.id ?? null}
          onSelect={handleChainChange}
        />
      </div>

      <div className="form-section">
        <label>Token</label>
        <TokenSelector
          chainId={sourceChain?.id ?? null}
          walletAddress={walletAddress ?? null}
          selectedToken={sourceToken}
          onSelect={setSourceToken}
          showOnlyWithBalance={false}
        />
      </div>

      <div className="form-section">
        <label>Amount</label>
        <input
          type="number"
          placeholder="0.00"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
          disabled={!sourceToken}
        />
      </div>

      {/* Display selected values */}
      {sourceChain && sourceToken && amount && (
        <div className="selection-summary">
          Bridging {amount} {sourceToken.symbol} from {sourceChain.name} to HyperEVM
        </div>
      )}
    </div>
  );
}

// Wrap with MinaProvider at app level
export function App() {
  return (
    <MinaProvider config={{ integrator: 'my-app' }}>
      <BridgeForm />
    </MinaProvider>
  );
}

Using the useTokenBalance Hook

For individual token balance display, use the useTokenBalance hook:
SingleTokenBalance.tsx
import { useTokenBalance } from '@siphoyawe/mina-sdk/react';

interface BalanceDisplayProps {
  chainId: number;
  tokenAddress: string;
  walletAddress: string;
}

export function BalanceDisplay({ chainId, tokenAddress, walletAddress }: BalanceDisplayProps) {
  const {
    formattedBalance,
    symbol,
    balanceUsd,
    isLoading,
    error,
    refetch,
  } = useTokenBalance({
    chainId,
    tokenAddress,
    walletAddress,
    refetchInterval: 15000, // Refresh every 15 seconds
  });

  if (isLoading && !formattedBalance) {
    return <span className="balance">Loading...</span>;
  }

  if (error) {
    return (
      <span className="balance error">
        Error loading balance
        <button onClick={refetch}>Retry</button>
      </span>
    );
  }

  return (
    <div className="balance-display">
      <span className="balance">
        {formattedBalance ?? '0'} {symbol}
      </span>
      {balanceUsd !== null && (
        <span className="balance-usd">${balanceUsd.toFixed(2)}</span>
      )}
    </div>
  );
}

Best Practices

When building token selectors, consider these UX best practices:
  1. Show tokens with balances at the top of the list
  2. Include token logos for visual recognition
  3. Display both symbol and name for clarity
  4. Show USD values when available
  5. Add search functionality for chains with many tokens
  6. Disable the token selector until a chain is selected

Next Steps

Quote Display

Show quote details and fees

Full Integration

Complete bridge widget