Skip to content
Alchemy Logo

Get a wallet's cross-chain balance and display it

Learn how to fetch and display token balances across multiple blockchain networks using Alchemy's Portfolio API

In this guide, you'll fetch token balances for a wallet across multiple blockchain networks in a single API call and display them in a React component grouped by chain.

Pick the chains you want to query using the Portfolio API network identifiers. For example:

eth-mainnet, polygon-mainnet, base-mainnet, arb-mainnet, opt-mainnet, sol-mainnet

Send a single POST request to the Token Balances by Wallet endpoint with the wallet address and your target networks.

curl -sX POST "https://api.g.alchemy.com/data/v1/<YOUR_API_KEY>/assets/tokens/balances/by-address" \
  -H "content-type: application/json" \
  -d '{
    "addresses": [{
      "address": "vitalik.eth",
      "networks": ["eth-mainnet","polygon-mainnet","base-mainnet","arb-mainnet","opt-mainnet"]
    }]
  }'

The response contains an array of fungible tokens (native + ERC-20/SPL) with balances for each wallet/network pair, plus a pageKey if there are more results to page through.

For human-readable amounts and token names or logos, look up each token's decimals and metadata via alchemy_getTokenMetadata.

If you want USD values and totals, join the results with Alchemy's Prices API.

Create a /components folder in the /app directory of your Next.js project and add a file called CrossChainBalances.tsx. The component below:

  • Calls the Portfolio API once per network (with pagination support if pageKey is returned)
  • Groups balances by network
  • Formats token balances using token decimals from alchemy_getTokenMetadata
// /components/CrossChainBalances.tsx
"use client";
import React, { useEffect, useState } from "react";
 
/**
 * Minimal types for the Portfolio API response shape we use here.
 * (The API may include more fields; we only type what we read.)
 */
type NetworkId =
  | "eth-mainnet"
  | "polygon-mainnet"
  | "base-mainnet"
  | "arb-mainnet"
  | "opt-mainnet";
 
type TokenRow = {
  network: string;            // e.g. "eth-mainnet"
  tokenAddress?: string | null;
  tokenBalance?: string | null; // raw string from API
  symbol?: string;
  name?: string;
  decimals?: number;
};
 
type PortfolioResponse = {
  data: {
    tokens: TokenRow[];
    pageKey?: string;
  };
};
 
type Props = {
  address: string; // ENS or 0x-address
  networks?: NetworkId[]; // override if you want
  apiKey?: string; // optionally pass explicitly; otherwise reads from env
  onAddressChange?: (newAddress: string) => void; // callback for address changes
};
 
/**
 * Super-simple, TS-safe component:
 * - Calls the Portfolio API once (with pagination if pageKey is returned)
 * - Groups tokens by network
 * - Displays a small list per network (no native/decimals/price logic)
 */
export default function CrossChainBalances({
  address,
  networks = [
    "eth-mainnet",
    "polygon-mainnet",
    "base-mainnet",
    "arb-mainnet",
    "opt-mainnet",
  ],
  apiKey = process.env.NEXT_PUBLIC_ALCHEMY_KEY,
  onAddressChange,
}: Props) {
  const [byNetwork, setByNetwork] = useState<Record<string, TokenRow[]>>({});
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const [hasData, setHasData] = useState(false);
  const [inputError, setInputError] = useState<string | null>(null);
 
  // Helper function to convert hex to decimal
  const hexToDecimal = (hex: string): string => {
    if (!hex || hex === '0x') return '0';
    return BigInt(hex).toString();
  };
 
  // Helper function to convert raw balance to human-readable amount
  const formatBalance = (rawBalance: string, decimals: number | undefined): string => {
    if (!decimals || decimals === undefined) return rawBalance;
 
    const balance = BigInt(rawBalance);
    const divisor = BigInt(10 ** decimals);
    const wholePart = balance / divisor;
    const fractionalPart = balance % divisor;
 
    if (fractionalPart === 0n) {
      return wholePart.toString();
    }
 
    // Convert fractional part to decimal string with proper padding
    const fractionalStr = fractionalPart.toString().padStart(decimals, '0');
    const trimmedFractional = fractionalStr.replace(/0+$/, '');
 
    if (trimmedFractional === '') {
      return wholePart.toString();
    }
 
    return `${wholePart}.${trimmedFractional}`;
  };
 
  // Helper function to validate address input
  const isValidAddress = (input: string): boolean => {
    if (!input) return false;
 
    // Check if it's a valid ENS name (ends with .eth)
    if (input.endsWith('.eth')) {
      return true;
    }
 
    // Check if it's a valid hex address (0x followed by 40 hex characters)
    const hexAddressRegex = /^0x[a-fA-F0-9]{40}$/;
    return hexAddressRegex.test(input);
  };
 
  // Handle address input changes with validation
  const handleAddressChange = (newAddress: string) => {
    setInputError(null);
 
    if (newAddress && !isValidAddress(newAddress)) {
      setInputError('Please enter a valid 0x address or ENS name (e.g., vitalik.eth)');
      return;
    }
 
    onAddressChange?.(newAddress);
  };
 
  useEffect(() => {
    let cancelled = false;
 
    async function run() {
      if (!apiKey) {
        setError("Missing Alchemy API key. Set NEXT_PUBLIC_ALCHEMY_KEY or pass apiKey prop.");
        setLoading(false);
        return;
      }
 
      try {
        // ========================================
        // ALCHEMY API #1: eth_resolveName
        // Resolves ENS names (like "vitalik.eth") to 0x addresses
        // ========================================
        let resolvedAddress = address;
        if (address.endsWith('.eth')) {
          const ensUrl = `https://eth-mainnet.g.alchemy.com/v2/${apiKey}`;
          const ensBody = {
            jsonrpc: "2.0",
            method: "eth_resolveName", // ← ENS resolution API
            params: [address],
            id: 1
          };
 
          const ensRes = await fetch(ensUrl, {
            method: "POST",
            headers: { "content-type": "application/json" },
            body: JSON.stringify(ensBody),
          });
 
          const ensJson = await ensRes.json();
 
          if (ensJson.result) {
            resolvedAddress = ensJson.result;
          } else {
            throw new Error('Could not resolve ENS name');
          }
        }
 
        // Define network endpoints
        const networkEndpoints: Record<NetworkId, string> = {
          "eth-mainnet": `https://eth-mainnet.g.alchemy.com/v2/${apiKey}`,
          "polygon-mainnet": `https://polygon-mainnet.g.alchemy.com/v2/${apiKey}`,
          "base-mainnet": `https://base-mainnet.g.alchemy.com/v2/${apiKey}`,
          "arb-mainnet": `https://arb-mainnet.g.alchemy.com/v2/${apiKey}`,
          "opt-mainnet": `https://opt-mainnet.g.alchemy.com/v2/${apiKey}`,
        };
 
        const headers = { "content-type": "application/json" };
        let allTokens: TokenRow[] = [];
 
        // ========================================
        // ALCHEMY API #2: alchemy_getTokenBalances
        // Fetches ERC-20 token balances for an address across multiple networks
        // ========================================
        const networkPromises = networks.map(async (network) => {
          const url = networkEndpoints[network];
          if (!url) {
            console.warn(`No endpoint configured for network: ${network}`);
            return [];
          }
 
          try {
            const body = {
              jsonrpc: "2.0",
              method: "alchemy_getTokenBalances", // ← Token balances API
              params: [resolvedAddress, "erc20"],
              id: 1
            };
 
            const res = await fetch(url, {
              method: "POST",
              headers,
              body: JSON.stringify(body),
            });
 
            if (!res.ok) {
              console.warn(`Failed to fetch from ${network}:`, res.status);
              return [];
            }
 
            const json = await res.json();
 
            // Parse the response and convert hex balances to decimal
            if (json.result && json.result.tokenBalances) {
              const tokenBalances = json.result.tokenBalances;
              const networkTokens: TokenRow[] = [];
 
              for (const token of tokenBalances) {
                const decimalBalance = hexToDecimal(token.tokenBalance);
 
                // Only include tokens with non-zero balances
                if (decimalBalance !== "0") {
                  networkTokens.push({
                    network: network,
                    tokenAddress: token.contractAddress,
                    tokenBalance: decimalBalance,
                    symbol: undefined,
                    name: undefined,
                    decimals: undefined
                  });
                }
              }
 
              return networkTokens;
            }
          } catch (error) {
            console.warn(`Error fetching from ${network}:`, error);
          }
 
          return [];
        });
 
        // Wait for all network requests to complete
        const networkResults = await Promise.all(networkPromises);
 
        // Flatten all tokens into a single array
        allTokens = networkResults.flat();
 
        // ========================================
        // ALCHEMY API #3: alchemy_getTokenMetadata
        // Fetches token metadata (name, symbol, decimals) for each token contract
        // ========================================
        if (allTokens.length > 0) {
          // Group tokens by network for metadata fetching
          const tokensByNetwork: Record<string, TokenRow[]> = {};
          allTokens.forEach(token => {
            if (!tokensByNetwork[token.network]) {
              tokensByNetwork[token.network] = [];
            }
            tokensByNetwork[token.network].push(token);
          });
 
          // Fetch metadata for each network
          const metadataPromises = Object.entries(tokensByNetwork).map(async ([network, tokens]) => {
            const url = networkEndpoints[network as NetworkId];
            if (!url) return;
 
            const tokenAddresses = tokens.map(t => t.tokenAddress).filter(Boolean);
 
            for (const address of tokenAddresses) {
              try {
                const metadataBody = {
                  jsonrpc: "2.0",
                  method: "alchemy_getTokenMetadata", // ← Token metadata API
                  params: [address],
                  id: Math.floor(Math.random() * 10000)
                };
 
                const metadataRes = await fetch(url, {
                  method: "POST",
                  headers,
                  body: JSON.stringify(metadataBody),
                });
 
                if (metadataRes.ok) {
                  const metadataJson = await metadataRes.json();
                  if (metadataJson.result) {
                    // Find and update the token with metadata
                    const token = tokens.find(t => t.tokenAddress?.toLowerCase() === address.toLowerCase());
                    if (token) {
                      token.symbol = metadataJson.result.symbol;
                      token.name = metadataJson.result.name;
                      token.decimals = metadataJson.result.decimals;
                    }
                  }
                }
              } catch (err) {
                console.warn(`Failed to fetch metadata for ${address} on ${network}:`, err);
              }
            }
          });
 
          // Wait for all metadata requests to complete
          await Promise.all(metadataPromises);
        }
 
        // Group by network
        const map: Record<string, TokenRow[]> = {};
        for (const t of allTokens) {
          const net = t.network || "unknown";
          if (!map[net]) map[net] = [];
          map[net].push(t);
        }
 
        if (!cancelled) {
          setByNetwork(map);
          setError(null);
          setHasData(Object.keys(map).length > 0);
        }
      } catch (e: unknown) {
        if (!cancelled) {
          setError(e instanceof Error ? e.message : "Unknown error");
        }
      } finally {
        if (!cancelled) setLoading(false);
      }
    }
 
    run();
    return () => {
      cancelled = true;
    };
  }, [address, apiKey]);
 
  if (loading) return <div className="p-4 text-sm">Loading balances</div>;
  if (error) return <div className="p-4 text-sm text-red-600">Error: {error}</div>;
 
  const networkKeys = Object.keys(byNetwork).sort();
 
  return (
    <div className="p-4 max-w-4xl space-y-4">
      <h2 className="text-lg font-semibold">Cross-chain balances</h2>
 
      <div className="space-y-2">
        <label htmlFor="address-input" className="block text-sm font-medium text-gray-700">
          Wallet Address
        </label>
        <div className="flex gap-2">
          <div className="flex-1">
            <input
              id="address-input"
              type="text"
              value={address}
              onChange={(e) => handleAddressChange(e.target.value)}
              placeholder="Enter 0x address or ENS name (e.g., vitalik.eth)"
              className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
                inputError
                  ? 'border-red-300 focus:border-red-500 focus:ring-red-500'
                  : 'border-gray-300 focus:border-blue-500'
              }`}
            />
            {inputError && (
              <p className="mt-1 text-sm text-red-600">{inputError}</p>
            )}
          </div>
          <button
            onClick={() => handleAddressChange(address)}
            disabled={!!inputError || !address}
            className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-400 disabled:cursor-not-allowed"
          >
            Load
          </button>
        </div>
      </div>
 
      {!hasData ? (
        <div className="text-sm text-gray-600">No tokens found.</div>
      ) : (
        <div className="space-y-6">
          {networkKeys.map((net) => {
            const tokens = byNetwork[net] || [];
 
            return (
              <section key={net} className="border rounded-xl p-3">
                <div className="flex items-center justify-between">
                  <h3 className="font-medium">{net}</h3>
                  <span className="text-xs text-gray-500">
                    {tokens.length} tokens
                  </span>
                </div>
 
                <ul className="mt-2 divide-y text-sm">
                  {tokens.map((t, i) => {
                    const tokenName = t.symbol || t.name || (t.tokenAddress ? `Token ${t.tokenAddress.slice(0, 6)}...` : "Native Token");
                    const rawBalance = t.tokenBalance || "0";
                    const formattedBalance = formatBalance(rawBalance, t.decimals);
                    const displayName = t.symbol && t.name ? `${t.symbol} (${t.name})` : tokenName;
 
                    return (
                      <li key={`${t.tokenAddress ?? "native"}-${i}`} className="py-2">
                        <div className="flex items-center justify-between">
                          <div className="min-w-0 flex-1">
                            <div className="truncate font-medium">
                              {displayName}
                            </div>
                            {t.tokenAddress && (
                              <div className="text-xs text-gray-500 truncate">
                                {t.tokenAddress}
                              </div>
                            )}
                          </div>
                          <div className="ml-4 text-right">
                            <div className="font-mono text-sm font-semibold">
                              {formattedBalance}
                            </div>
                            {t.symbol && (
                              <div className="text-xs text-gray-500">
                                {t.symbol}
                              </div>
                            )}
                          </div>
                        </div>
                      </li>
                    );
                  })}
                </ul>
              </section>
            );
          })}
        </div>
      )}
 
      <p className="text-xs text-gray-500">
        Tip: For human-readable amounts and prices, join with token metadata and a prices source later.
      </p>
    </div>
  );
}

  • Paginate results — if pageKey is returned in the response, repeat the call with it to fetch the next page.
  • Use per-token decimals — read decimals from alchemy_getTokenMetadata per contract rather than assuming 18; the Token API returns decimals, symbol, and more.
  • Add NFTs — pair with getNFTs for a full wallet portfolio view that includes non-fungible tokens.
Was this page helpful?