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

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";
3import 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 */
9type NetworkId =
10 | "eth-mainnet"
11 | "polygon-mainnet"
12 | "base-mainnet"
13 | "arb-mainnet"
14 | "opt-mainnet";
15
16type 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
25type PortfolioResponse = {
26 data: {
27 tokens: TokenRow[];
28 pageKey?: string;
29 };
30};
31
32type 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 */
45export 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.