How to Read NFT Data from the Blockchain via RPC
Most NFT data guides point you at marketplace APIs or provider-specific NFT APIs. Those work until they do not: rate limits, outages, or terms-of-service changes can break your application overnight.
Reading NFT data directly from the blockchain via RPC is more resilient: no third-party dependency, no API key to manage beyond your RPC endpoint, and the data is always authoritative.
This guide covers ERC-721 and ERC-1155 data patterns via standard RPC calls.
ERC-721: Core Interface
ERC-721 is the standard for non-fungible tokens. Each token ID is unique and owned by exactly one address.
import { ethers } from 'ethers'
const ERC721_ABI = [
'function name() view returns (string)',
'function symbol() view returns (string)',
'function totalSupply() view returns (uint256)',
'function ownerOf(uint256 tokenId) view returns (address)',
'function tokenURI(uint256 tokenId) view returns (string)',
'function balanceOf(address owner) view returns (uint256)',
'function getApproved(uint256 tokenId) view returns (address)',
'function isApprovedForAll(address owner, address operator) view returns (bool)',
'event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)',
'event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId)',
]
const provider = new ethers.JsonRpcProvider(
'https://eu.endpoints.matrixed.link/rpc/ethereum?auth=YOUR_KEY'
)
const BAYC_ADDRESS = '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D'
const bayc = new ethers.Contract(BAYC_ADDRESS, ERC721_ABI, provider)
Check Token Ownership
async function getTokenOwner(contractAddress, tokenId) {
const contract = new ethers.Contract(contractAddress, ERC721_ABI, provider)
try {
const owner = await contract.ownerOf(tokenId)
return { tokenId, owner, exists: true }
} catch (error) {
// ownerOf reverts if token does not exist
if (error.code === 'CALL_EXCEPTION') {
return { tokenId, owner: null, exists: false }
}
throw error
}
}
const result = await getTokenOwner(BAYC_ADDRESS, 1234)
console.log(`BAYC #1234 owner: ${result.owner}`)
Get Token Metadata URI
async function getTokenURI(contractAddress, tokenId) {
const contract = new ethers.Contract(contractAddress, ERC721_ABI, provider)
const uri = await contract.tokenURI(tokenId)
// Many NFTs use IPFS URIs
// Convert ipfs:// to an HTTP gateway for fetching
const httpUri = uri.startsWith('ipfs://')
? uri.replace('ipfs://', 'https://ipfs.io/ipfs/')
: uri
return { tokenId, uri, httpUri }
}
const { httpUri } = await getTokenURI(BAYC_ADDRESS, 1234)
// Fetch the metadata JSON
const response = await fetch(httpUri)
const metadata = await response.json()
console.log(`Name: ${metadata.name}`)
console.log(`Description: ${metadata.description}`)
console.log(`Image: ${metadata.image}`)
console.log(`Traits:`, metadata.attributes)
Get All Tokens Owned by an Address
ERC-721 does not have a built-in tokensOfOwner function in the base standard. Two approaches depending on the contract:
Option A: Use eth_getLogs (works for any ERC-721)
Build the ownership set from Transfer events:
async function getOwnedTokens(contractAddress, ownerAddress) {
const contract = new ethers.Contract(contractAddress, ERC721_ABI, provider)
const TRANSFER_TOPIC = ethers.id('Transfer(address,address,uint256)')
const paddedOwner = ethers.zeroPadValue(ownerAddress, 32)
const currentBlock = await provider.getBlockNumber()
// Transfers TO this address (received)
const receivedLogs = await provider.getLogs({
address: contractAddress,
topics: [TRANSFER_TOPIC, null, paddedOwner],
fromBlock: 0,
toBlock: 'latest',
})
// Transfers FROM this address (sent)
const sentLogs = await provider.getLogs({
address: contractAddress,
topics: [TRANSFER_TOPIC, paddedOwner],
fromBlock: 0,
toBlock: 'latest',
})
// Build current ownership set
const owned = new Set()
// Sort by block + log index to process in order
const allLogs = [...receivedLogs, ...sentLogs].sort((a, b) => {
if (a.blockNumber !== b.blockNumber) return a.blockNumber - b.blockNumber
return a.index - b.index
})
for (const log of allLogs) {
const tokenId = BigInt(log.topics[3])
const to = ethers.getAddress('0x' + log.topics[2].slice(26))
const from = ethers.getAddress('0x' + log.topics[1].slice(26))
if (to.toLowerCase() === ownerAddress.toLowerCase()) {
owned.add(tokenId)
} else if (from.toLowerCase() === ownerAddress.toLowerCase()) {
owned.delete(tokenId)
}
}
return [...owned]
}
const tokens = await getOwnedTokens(BAYC_ADDRESS, '0xOwnerAddress')
console.log(`BAYC tokens owned: ${tokens.join(', ')}`)
Option B: Use ERC-721 Enumerable (if supported)
Some contracts implement ERC721Enumerable with tokenOfOwnerByIndex:
const ERC721_ENUMERABLE_ABI = [
...ERC721_ABI,
'function tokenOfOwnerByIndex(address owner, uint256 index) view returns (uint256)',
]
async function getOwnedTokensEnumerable(contractAddress, ownerAddress) {
const contract = new ethers.Contract(contractAddress, ERC721_ENUMERABLE_ABI, provider)
const balance = await contract.balanceOf(ownerAddress)
const tokenIds = await Promise.all(
Array.from({ length: Number(balance) }, (_, i) =>
contract.tokenOfOwnerByIndex(ownerAddress, i)
)
)
return tokenIds
}
Batch Ownership Checks with Multicall3
Checking ownership for many token IDs in one call:
const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11'
const MULTICALL3_ABI = [
'function aggregate3(tuple(address target, bool allowFailure, bytes callData)[] calls) view returns (tuple(bool success, bytes returnData)[] returnData)',
]
const erc721Interface = new ethers.Interface(ERC721_ABI)
const multicall = new ethers.Contract(MULTICALL3_ADDRESS, MULTICALL3_ABI, provider)
async function batchGetOwners(contractAddress, tokenIds) {
const calls = tokenIds.map(tokenId => ({
target: contractAddress,
allowFailure: true, // Some tokens may not exist
callData: erc721Interface.encodeFunctionData('ownerOf', [tokenId]),
}))
const results = await multicall.aggregate3(calls)
return tokenIds.map((tokenId, i) => ({
tokenId,
owner: results[i].success
? erc721Interface.decodeFunctionResult('ownerOf', results[i].returnData)[0]
: null,
exists: results[i].success,
}))
}
// Check 100 token owners in 1 RPC call
const tokenIds = Array.from({ length: 100 }, (_, i) => i + 1)
const owners = await batchGetOwners(BAYC_ADDRESS, tokenIds)
ERC-1155: Multi-Token Standard
ERC-1155 allows both fungible and non-fungible tokens in one contract. Unlike ERC-721, multiple addresses can own amounts of the same token ID.
const ERC1155_ABI = [
'function uri(uint256 tokenId) view returns (string)',
'function balanceOf(address account, uint256 id) view returns (uint256)',
'function balanceOfBatch(address[] accounts, uint256[] ids) view returns (uint256[])',
'function isApprovedForAll(address account, address operator) view returns (bool)',
'event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value)',
'event TransferBatch(address indexed operator, address indexed from, address indexed to, uint256[] ids, uint256[] values)',
]
const OPENSEA_SHARED_ADDRESS = '0x495f947276749Ce646f68AC8c248420045cb7b5e'
const contract = new ethers.Contract(OPENSEA_SHARED_ADDRESS, ERC1155_ABI, provider)
// Check balance of one token
const balance = await contract.balanceOf('0xOwnerAddress', 12345n)
console.log(`Balance of token #12345: ${balance}`)
// Check balances of multiple tokens in one call (built into ERC-1155)
const addresses = ['0xOwner1', '0xOwner1', '0xOwner2']
const ids = [1n, 2n, 1n]
const balances = await contract.balanceOfBatch(addresses, ids)
// Returns: [balance of owner1 token1, balance of owner1 token2, balance of owner2 token1]
Get ERC-1155 Metadata
async function getERC1155Metadata(contractAddress, tokenId) {
const contract = new ethers.Contract(contractAddress, ERC1155_ABI, provider)
let uri = await contract.uri(tokenId)
// ERC-1155 URIs may contain {id} placeholder
// Replace with zero-padded hex token ID
const hexId = tokenId.toString(16).padStart(64, '0')
uri = uri.replace('{id}', hexId)
// Convert IPFS URIs
const httpUri = uri.startsWith('ipfs://')
? uri.replace('ipfs://', 'https://ipfs.io/ipfs/')
: uri
const response = await fetch(httpUri)
return await response.json()
}
Monitor NFT Transfers in Real Time
const wsProvider = new ethers.WebSocketProvider(
'wss://eu.endpoints.matrixed.link/ws/ethereum?auth=YOUR_KEY'
)
const bayc = new ethers.Contract(BAYC_ADDRESS, ERC721_ABI, wsProvider)
// Watch all BAYC transfers
bayc.on('Transfer', (from, to, tokenId, event) => {
if (from === ethers.ZeroAddress) {
console.log(`BAYC #${tokenId} minted to ${to}`)
} else if (to === ethers.ZeroAddress) {
console.log(`BAYC #${tokenId} burned by ${from}`)
} else {
console.log(`BAYC #${tokenId}: ${from} → ${to}`)
}
console.log(`TX: ${event.log.transactionHash}`)
})
Check If a Contract is ERC-721 or ERC-1155
Use ERC-165 introspection to detect the standard:
const ERC165_ABI = [
'function supportsInterface(bytes4 interfaceId) view returns (bool)',
]
const ERC721_INTERFACE_ID = '0x80ac58cd'
const ERC1155_INTERFACE_ID = '0xd9b67a26'
async function detectNFTStandard(contractAddress) {
const contract = new ethers.Contract(contractAddress, ERC165_ABI, provider)
try {
const [is721, is1155] = await Promise.all([
contract.supportsInterface(ERC721_INTERFACE_ID),
contract.supportsInterface(ERC1155_INTERFACE_ID),
])
if (is721) return 'ERC-721'
if (is1155) return 'ERC-1155'
return 'Unknown'
} catch {
return 'Unknown (no ERC-165 support)'
}
}
console.log(await detectNFTStandard(BAYC_ADDRESS)) // 'ERC-721'
FAQ
Why does ownerOf revert instead of returning null for non-existent tokens?
The ERC-721 standard specifies that ownerOf MUST throw for non-existent tokens. There is no zero address return. The call reverts. Always wrap ownerOf calls in try/catch and check for CALL_EXCEPTION.
How do I get NFT data without knowing all token IDs?
Use eth_getLogs to query the Transfer event from block 0 to build the full set of minted token IDs (transfers from the zero address are mints). Filter by topics[1] === address(0) to get all mint events.
What is the difference between tokenURI and the actual image?
tokenURI returns a URL pointing to a JSON metadata file. That file contains the image URL (usually under metadata.image). The image itself is typically stored on IPFS or Arweave, not directly on-chain. To get the image, you need two fetches: one for the metadata JSON, one for the image.
Can I read NFT data on Polygon or other chains?
Yes. ERC-721 and ERC-1155 are the same standards on all EVM chains. Use the contract address on the target chain and the corresponding RPC endpoint. Many collections bridge or deploy independently on multiple chains. They will have different contract addresses per chain.
Does this work for Solana NFTs?
No. Solana uses a completely different NFT standard (Metaplex). The ERC-721/1155 patterns in this guide are EVM-specific. Solana NFT data is read via Solana’s JSON-RPC using getAccountInfo and the Metaplex token metadata program.
BoltRPC provides the eth_call throughput and eth_getLogs access your NFT data pipeline needs: Ethereum, Polygon, Base, Arbitrum and 17 other networks available.
Start your free 2-week trial: trial.boltrpc.io
Related: Building a Production eth_getLogs Pipeline | ERC-20 Token Data via RPC