w3-kitdocs

fetch-nft-collection

List all NFTs owned by a wallet address — on EVM (ERC-721 Enumerable) or Solana (SPL token accounts filtered for NFTs).

evmsolana

Dependencies

viem@solana/web3.js
fetch-nft-collection/evm.tsx
"use client";

import { useAccount, usePublicClient } from "wagmi";
import { useState } from "react";

// ★ ERC-721 Enumerable ABI — requires contract to implement ERC721Enumerable
const erc721EnumerableAbi = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "owner", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
  },
  {
    name: "tokenOfOwnerByIndex",
    type: "function",
    stateMutability: "view",
    inputs: [
      { name: "owner", type: "address" },
      { name: "index", type: "uint256" },
    ],
    outputs: [{ name: "", type: "uint256" }],
  },
  {
    name: "tokenURI",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "tokenId", type: "uint256" }],
    outputs: [{ name: "", type: "string" }],
  },
] as const;

type NFTItem = {
  tokenId: bigint;
  tokenUri: string;
};

export function FetchNFTCollection() {
  const { address } = useAccount();
  const client = usePublicClient();
  const [contractAddress, setContractAddress] = useState("");
  const [walletAddress, setWalletAddress] = useState("");
  const [nfts, setNfts] = useState<NFTItem[]>([]);
  const [isFetching, setIsFetching] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const owner = (walletAddress || address) as `0x${string}`;

  const handleFetch = async () => {
    if (!client || !contractAddress || !owner) return;
    setIsFetching(true);
    setError(null);
    setNfts([]);

    try {
      // 1. Get the balance
      const balance = await client.readContract({
        address: contractAddress as `0x${string}`,
        abi: erc721EnumerableAbi,
        functionName: "balanceOf",
        args: [owner],
      });

      // 2. Fetch each tokenId by index, then its URI
      const items: NFTItem[] = [];
      for (let i = 0n; i < balance; i++) {
        const tokenId = await client.readContract({
          address: contractAddress as `0x${string}`,
          abi: erc721EnumerableAbi,
          functionName: "tokenOfOwnerByIndex",
          args: [owner, i],
        });
        const tokenUri = await client.readContract({
          address: contractAddress as `0x${string}`,
          abi: erc721EnumerableAbi,
          functionName: "tokenURI",
          args: [tokenId],
        });
        items.push({ tokenId, tokenUri });
      }
      setNfts(items);
    } catch (e: unknown) {
      setError(e instanceof Error ? e.message : String(e));
    } finally {
      setIsFetching(false);
    }
  };

  return (
    <div>
      <h2>Fetch NFT Collection (EVM)</h2>
      <input
        value={contractAddress}
        onChange={(e) => setContractAddress(e.target.value)}
        placeholder="ERC-721 contract address (0x...)"
      />
      <input
        value={walletAddress}
        onChange={(e) => setWalletAddress(e.target.value)}
        placeholder={`Owner address (default: ${address ?? "connect wallet"})`}
      />
      <button onClick={handleFetch} disabled={isFetching || !contractAddress}>
        {isFetching ? "Fetching..." : "Fetch NFTs"}
      </button>
      {nfts.length > 0 && (
        <ul>
          {nfts.map((nft) => (
            <li key={nft.tokenId.toString()}>
              Token #{nft.tokenId.toString()} — {nft.tokenUri}
            </li>
          ))}
        </ul>
      )}
      {nfts.length === 0 && !isFetching && !error && <p>No NFTs found.</p>}
      {error && <p>Error: {error}</p>}
    </div>
  );
}

Fetch NFT Collection — Learn

How NFT ownership is tracked

EVM: mappings inside the contract

Every ERC-721 contract maintains its own ownership mapping:

mapping(uint256 => address) private _owners; // tokenId → current owner
mapping(address => uint256) private _balances; // owner → how many tokens

To list NFTs, you need the contract address and either:

  1. ERC721Enumerable extension — adds tokenOfOwnerByIndex(address, index) for O(n) iteration
  2. Transfer events — scan Transfer events from the contract logs (requires an indexer)
  3. Third-party indexer — Alchemy, Moralis, or The Graph maintain cross-contract indexes

Solana: token accounts

On Solana, every token holding is a separate "token account" — a small account that stores:

  • mint — which token/NFT this account is for
  • owner — which wallet controls it
  • amount — how many tokens (1 for NFTs)
  • decimals — 0 for NFTs

You can fetch all token accounts for a wallet in a single RPC call:

const tokenAccounts = await connection.getParsedTokenAccountsByOwner(owner, {
  programId: TOKEN_PROGRAM_ID,
});
// Filter for NFTs
const nfts = tokenAccounts.value.filter(
  (ta) =>
    ta.account.data.parsed.info.tokenAmount.decimals === 0 &&
    ta.account.data.parsed.info.tokenAmount.uiAmount === 1,
);

Enumeration patterns

EVM: ERC721Enumerable

interface IERC721Enumerable {
  function totalSupply() external view returns (uint256);
  function tokenByIndex(uint256 index) external view returns (uint256);
  function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256);
}

tokenOfOwnerByIndex(owner, 0) through tokenOfOwnerByIndex(owner, balanceOf(owner) - 1) gives you all token IDs. This requires the contract to implement the Enumerable extension — not all do.

EVM: Event-based enumeration

An alternative that works with any ERC-721 contract:

// Get all Transfer events where the recipient is the target address
const transferLogs = await client.getLogs({
  address: contractAddress,
  event: parseAbiItem(
    "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)",
  ),
  args: { to: ownerAddress },
  fromBlock: 0n,
});
// Deduplicate (tokens may have been transferred out then back)

This is how most indexers work. It's more complete but requires scanning historical logs.

Off-chain metadata: IPFS, Arweave, HTTP

The tokenURI (EVM) or metadata URI (Solana) typically resolves to one of:

IPFS URIs

ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG/1.json

To resolve, use an IPFS gateway:

const gatewayUrl = uri.replace("ipfs://", "https://ipfs.io/ipfs/");
const metadata = await fetch(gatewayUrl).then((r) => r.json());

Public gateways: ipfs.io, cloudflare-ipfs.com, gateway.pinata.cloud

Arweave URIs

ar://TxId123.../1.json
// or
https://arweave.net/TxId123.../1.json

Base64-encoded on-chain data

Some NFTs (like early CryptoPunks or Nouns) store metadata directly on-chain:

data:application/json;base64,eyJuYW1lIjogIk5vdW4...

Decode with atob() or Buffer.from(data, "base64").toString().

On-chain vs off-chain metadata

On-chainOff-chain (IPFS/Arweave)
PermanencePermanent as long as chain existsDepends on storage service
CostHigh — storage on-chain is expensiveLow — storing a URI is cheap
MutabilityImmutable once set (usually)Could change if not on Arweave
ExamplesNouns, AutoglyphsMost ERC-721 collections

Fully on-chain NFTs are considered more "pure" — the art and metadata survive as long as the blockchain does. Most collections use off-chain storage for cost reasons.

Once you have token IDs and URIs, display the collection:

async function resolveMetadata(uri: string) {
  const url = uri.startsWith("ipfs://")
    ? uri.replace("ipfs://", "https://ipfs.io/ipfs/")
    : uri;
  return fetch(url).then((r) => r.json());
}

// metadata shape
type NFTMetadata = {
  name: string;
  description: string;
  image: string;
  attributes: Array<{ trait_type: string; value: string }>;
};

For large collections, use a dedicated NFT indexer API (Alchemy, Moralis) rather than resolving each URI individually — they cache metadata and handle rate limits.