Get a wallet’s cross-chain balance and display it
The only method you need to get the cross-chain balances of a wallet is: Token Balances by Wallet
(view in docs here)
In this guide, you’ll use the Alchemy Portfolio API → Token Balances by Wallet endpoint (multi-chain, single call).
1) Configure
- Grab your Alchemy API key.
- Pick the chains (Portfolio API “networks” enums), e.g.
eth-mainnet
,polygon-mainnet
,base-mainnet
,arb-mainnet
,opt-mainnet
,sol-mainnet
.
2) Call the endpoint (one request, many chains)
HTTP
$ 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"] > }] > }'
This returns an array of fungible tokens (native + ERC-20/SPL) with balances for each wallet/network pair, plus a pageKey
if there’s more.
3) (Optional) Normalize & enrich
For human-readable amounts and names/logos, look up each token’s decimals/metadata via alchemy_getTokenMetadata
(view method in docs).
If you want USD values and totals, join with Alchemy’s Prices API.
4) Minimal front-end: render per-chain totals
Paste the code below into any React/Next/Vite app; it:
-
Calls the Portfolio endpoint for multiple chains
-
Groups balances by
network
-
(Optionally) formats by token decimals if you provide a
getTokenMetadata
helper
In this project, we created a /components
folder in the /app
directory of a NextJS project and created a file in it called CrossChainBalances.tsx
:
1 // /components/CrossChainBalances.tsx 2 "use client"; 3 import React, { useEffect, useState } from "react"; 4 5 /** 6 * Minimal types for the Portfolio API response shape we use here. 7 * (The API may include more fields; we only type what we read.) 8 */ 9 type NetworkId = 10 | "eth-mainnet" 11 | "polygon-mainnet" 12 | "base-mainnet" 13 | "arb-mainnet" 14 | "opt-mainnet"; 15 16 type TokenRow = { 17 network: string; // e.g. "eth-mainnet" 18 tokenAddress?: string | null; 19 tokenBalance?: string | null; // raw string from API 20 symbol?: string; 21 name?: string; 22 decimals?: number; 23 }; 24 25 type PortfolioResponse = { 26 data: { 27 tokens: TokenRow[]; 28 pageKey?: string; 29 }; 30 }; 31 32 type Props = { 33 address: string; // ENS or 0x-address 34 networks?: NetworkId[]; // override if you want 35 apiKey?: string; // optionally pass explicitly; otherwise reads from env 36 onAddressChange?: (newAddress: string) => void; // callback for address changes 37 }; 38 39 /** 40 * Super-simple, TS-safe component: 41 * - Calls the Portfolio API once (with pagination if pageKey is returned) 42 * - Groups tokens by network 43 * - Displays a small list per network (no native/decimals/price logic) 44 */ 45 export default function CrossChainBalances({ 46 address, 47 networks = [ 48 "eth-mainnet", 49 "polygon-mainnet", 50 "base-mainnet", 51 "arb-mainnet", 52 "opt-mainnet", 53 ], 54 apiKey = process.env.NEXT_PUBLIC_ALCHEMY_KEY, 55 onAddressChange, 56 }: Props) { 57 const [byNetwork, setByNetwork] = useState<Record<string, TokenRow[]>>({}); 58 const [error, setError] = useState<string | null>(null); 59 const [loading, setLoading] = useState(true); 60 const [hasData, setHasData] = useState(false); 61 const [inputError, setInputError] = useState<string | null>(null); 62 63 // Helper function to convert hex to decimal 64 const hexToDecimal = (hex: string): string => { 65 if (!hex || hex === '0x') return '0'; 66 return BigInt(hex).toString(); 67 }; 68 69 // Helper function to convert raw balance to human-readable amount 70 const formatBalance = (rawBalance: string, decimals: number | undefined): string => { 71 if (!decimals || decimals === undefined) return rawBalance; 72 73 const balance = BigInt(rawBalance); 74 const divisor = BigInt(10 ** decimals); 75 const wholePart = balance / divisor; 76 const fractionalPart = balance % divisor; 77 78 if (fractionalPart === 0n) { 79 return wholePart.toString(); 80 } 81 82 // Convert fractional part to decimal string with proper padding 83 const fractionalStr = fractionalPart.toString().padStart(decimals, '0'); 84 const trimmedFractional = fractionalStr.replace(/0+$/, ''); 85 86 if (trimmedFractional === '') { 87 return wholePart.toString(); 88 } 89 90 return `${wholePart}.${trimmedFractional}`; 91 }; 92 93 // Helper function to validate address input 94 const isValidAddress = (input: string): boolean => { 95 if (!input) return false; 96 97 // Check if it's a valid ENS name (ends with .eth) 98 if (input.endsWith('.eth')) { 99 return true; 100 } 101 102 // Check if it's a valid hex address (0x followed by 40 hex characters) 103 const hexAddressRegex = /^0x[a-fA-F0-9]{40}$/; 104 return hexAddressRegex.test(input); 105 }; 106 107 // Handle address input changes with validation 108 const handleAddressChange = (newAddress: string) => { 109 setInputError(null); 110 111 if (newAddress && !isValidAddress(newAddress)) { 112 setInputError('Please enter a valid 0x address or ENS name (e.g., vitalik.eth)'); 113 return; 114 } 115 116 onAddressChange?.(newAddress); 117 }; 118 119 useEffect(() => { 120 let cancelled = false; 121 122 async function run() { 123 if (!apiKey) { 124 setError("Missing Alchemy API key. Set NEXT_PUBLIC_ALCHEMY_KEY or pass apiKey prop."); 125 setLoading(false); 126 return; 127 } 128 129 try { 130 // ======================================== 131 // ALCHEMY API #1: eth_resolveName 132 // Resolves ENS names (like "vitalik.eth") to 0x addresses 133 // ======================================== 134 let resolvedAddress = address; 135 if (address.endsWith('.eth')) { 136 const ensUrl = `https://eth-mainnet.g.alchemy.com/v2/${apiKey}`; 137 const ensBody = { 138 jsonrpc: "2.0", 139 method: "eth_resolveName", // ← ENS resolution API 140 params: [address], 141 id: 1 142 }; 143 144 const ensRes = await fetch(ensUrl, { 145 method: "POST", 146 headers: { "content-type": "application/json" }, 147 body: JSON.stringify(ensBody), 148 }); 149 150 const ensJson = await ensRes.json(); 151 152 if (ensJson.result) { 153 resolvedAddress = ensJson.result; 154 } else { 155 throw new Error('Could not resolve ENS name'); 156 } 157 } 158 159 // Define network endpoints 160 const networkEndpoints: Record<NetworkId, string> = { 161 "eth-mainnet": `https://eth-mainnet.g.alchemy.com/v2/${apiKey}`, 162 "polygon-mainnet": `https://polygon-mainnet.g.alchemy.com/v2/${apiKey}`, 163 "base-mainnet": `https://base-mainnet.g.alchemy.com/v2/${apiKey}`, 164 "arb-mainnet": `https://arb-mainnet.g.alchemy.com/v2/${apiKey}`, 165 "opt-mainnet": `https://opt-mainnet.g.alchemy.com/v2/${apiKey}`, 166 }; 167 168 const headers = { "content-type": "application/json" }; 169 let allTokens: TokenRow[] = []; 170 171 // ======================================== 172 // ALCHEMY API #2: alchemy_getTokenBalances 173 // Fetches ERC-20 token balances for an address across multiple networks 174 // ======================================== 175 const networkPromises = networks.map(async (network) => { 176 const url = networkEndpoints[network]; 177 if (!url) { 178 console.warn(`No endpoint configured for network: ${network}`); 179 return []; 180 } 181 182 try { 183 const body = { 184 jsonrpc: "2.0", 185 method: "alchemy_getTokenBalances", // ← Token balances API 186 params: [resolvedAddress, "erc20"], 187 id: 1 188 }; 189 190 const res = await fetch(url, { 191 method: "POST", 192 headers, 193 body: JSON.stringify(body), 194 }); 195 196 if (!res.ok) { 197 console.warn(`Failed to fetch from ${network}:`, res.status); 198 return []; 199 } 200 201 const json = await res.json(); 202 203 // Parse the response and convert hex balances to decimal 204 if (json.result && json.result.tokenBalances) { 205 const tokenBalances = json.result.tokenBalances; 206 const networkTokens: TokenRow[] = []; 207 208 for (const token of tokenBalances) { 209 const decimalBalance = hexToDecimal(token.tokenBalance); 210 211 // Only include tokens with non-zero balances 212 if (decimalBalance !== "0") { 213 networkTokens.push({ 214 network: network, 215 tokenAddress: token.contractAddress, 216 tokenBalance: decimalBalance, 217 symbol: undefined, 218 name: undefined, 219 decimals: undefined 220 }); 221 } 222 } 223 224 return networkTokens; 225 } 226 } catch (error) { 227 console.warn(`Error fetching from ${network}:`, error); 228 } 229 230 return []; 231 }); 232 233 // Wait for all network requests to complete 234 const networkResults = await Promise.all(networkPromises); 235 236 // Flatten all tokens into a single array 237 allTokens = networkResults.flat(); 238 239 // ======================================== 240 // ALCHEMY API #3: alchemy_getTokenMetadata 241 // Fetches token metadata (name, symbol, decimals) for each token contract 242 // ======================================== 243 if (allTokens.length > 0) { 244 // Group tokens by network for metadata fetching 245 const tokensByNetwork: Record<string, TokenRow[]> = {}; 246 allTokens.forEach(token => { 247 if (!tokensByNetwork[token.network]) { 248 tokensByNetwork[token.network] = []; 249 } 250 tokensByNetwork[token.network].push(token); 251 }); 252 253 // Fetch metadata for each network 254 const metadataPromises = Object.entries(tokensByNetwork).map(async ([network, tokens]) => { 255 const url = networkEndpoints[network as NetworkId]; 256 if (!url) return; 257 258 const tokenAddresses = tokens.map(t => t.tokenAddress).filter(Boolean); 259 260 for (const address of tokenAddresses) { 261 try { 262 const metadataBody = { 263 jsonrpc: "2.0", 264 method: "alchemy_getTokenMetadata", // ← Token metadata API 265 params: [address], 266 id: Math.floor(Math.random() * 10000) 267 }; 268 269 const metadataRes = await fetch(url, { 270 method: "POST", 271 headers, 272 body: JSON.stringify(metadataBody), 273 }); 274 275 if (metadataRes.ok) { 276 const metadataJson = await metadataRes.json(); 277 if (metadataJson.result) { 278 // Find and update the token with metadata 279 const token = tokens.find(t => t.tokenAddress?.toLowerCase() === address.toLowerCase()); 280 if (token) { 281 token.symbol = metadataJson.result.symbol; 282 token.name = metadataJson.result.name; 283 token.decimals = metadataJson.result.decimals; 284 } 285 } 286 } 287 } catch (err) { 288 console.warn(`Failed to fetch metadata for ${address} on ${network}:`, err); 289 } 290 } 291 }); 292 293 // Wait for all metadata requests to complete 294 await Promise.all(metadataPromises); 295 } 296 297 // Group by network 298 const map: Record<string, TokenRow[]> = {}; 299 for (const t of allTokens) { 300 const net = t.network || "unknown"; 301 if (!map[net]) map[net] = []; 302 map[net].push(t); 303 } 304 305 if (!cancelled) { 306 setByNetwork(map); 307 setError(null); 308 setHasData(Object.keys(map).length > 0); 309 } 310 } catch (e: unknown) { 311 if (!cancelled) { 312 setError(e instanceof Error ? e.message : "Unknown error"); 313 } 314 } finally { 315 if (!cancelled) setLoading(false); 316 } 317 } 318 319 run(); 320 return () => { 321 cancelled = true; 322 }; 323 }, [address, apiKey]); // Removed networks from dependencies to prevent endless calls 324 325 if (loading) return <div className="p-4 text-sm">Loading balances…</div>; 326 if (error) return <div className="p-4 text-sm text-red-600">Error: {error}</div>; 327 328 const networkKeys = Object.keys(byNetwork).sort(); 329 330 return ( 331 <div className="p-4 max-w-4xl space-y-4"> 332 <h2 className="text-lg font-semibold">Cross-chain balances</h2> 333 334 <div className="space-y-2"> 335 <label htmlFor="address-input" className="block text-sm font-medium text-gray-700"> 336 Wallet Address 337 </label> 338 <div className="flex gap-2"> 339 <div className="flex-1"> 340 <input 341 id="address-input" 342 type="text" 343 value={address} 344 onChange={(e) => handleAddressChange(e.target.value)} 345 placeholder="Enter 0x address or ENS name (e.g., vitalik.eth)" 346 className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${ 347 inputError 348 ? 'border-red-300 focus:border-red-500 focus:ring-red-500' 349 : 'border-gray-300 focus:border-blue-500' 350 }`} 351 /> 352 {inputError && ( 353 <p className="mt-1 text-sm text-red-600">{inputError}</p> 354 )} 355 </div> 356 <button 357 onClick={() => handleAddressChange(address)} 358 disabled={!!inputError || !address} 359 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" 360 > 361 Load 362 </button> 363 </div> 364 </div> 365 366 {!hasData ? ( 367 <div className="text-sm text-gray-600">No tokens found.</div> 368 ) : ( 369 <div className="space-y-6"> 370 {networkKeys.map((net) => { 371 const tokens = byNetwork[net] || []; 372 373 return ( 374 <section key={net} className="border rounded-xl p-3"> 375 <div className="flex items-center justify-between"> 376 <h3 className="font-medium">{net}</h3> 377 <span className="text-xs text-gray-500"> 378 {tokens.length} tokens 379 </span> 380 </div> 381 382 <ul className="mt-2 divide-y text-sm"> 383 {tokens.map((t, i) => { 384 const tokenName = t.symbol || t.name || (t.tokenAddress ? `Token ${t.tokenAddress.slice(0, 6)}...` : "Native Token"); 385 const rawBalance = t.tokenBalance || "0"; 386 const formattedBalance = formatBalance(rawBalance, t.decimals); 387 const displayName = t.symbol && t.name ? `${t.symbol} (${t.name})` : tokenName; 388 389 return ( 390 <li key={`${t.tokenAddress ?? "native"}-${i}`} className="py-2"> 391 <div className="flex items-center justify-between"> 392 <div className="min-w-0 flex-1"> 393 <div className="truncate font-medium"> 394 {displayName} 395 </div> 396 {t.tokenAddress && ( 397 <div className="text-xs text-gray-500 truncate"> 398 {t.tokenAddress} 399 </div> 400 )} 401 </div> 402 <div className="ml-4 text-right"> 403 <div className="font-mono text-sm font-semibold"> 404 {formattedBalance} 405 </div> 406 {t.symbol && ( 407 <div className="text-xs text-gray-500"> 408 {t.symbol} 409 </div> 410 )} 411 </div> 412 </div> 413 </li> 414 ); 415 })} 416 </ul> 417 </section> 418 ); 419 })} 420 </div> 421 )} 422 423 <p className="text-xs text-gray-500"> 424 Tip: For human-readable amounts and prices, join with token metadata and a prices source later. 425 </p> 426 </div> 427 ); 428 }
5) Tips
Pagination: If pageKey
is returned, repeat the call with it to fetch the next page.
Accuracy: Use token metadata decimals per contract instead of assuming 18; the Token API returns decimals
, symbol
, etc.
Beyond fungibles: Pair with getNfts
(view in docs) for NFTs if you want a full wallet portfolio view.