Skip to main content

Solana NFT Fetcher

Updated on
Jan 02, 2025

Overview

NFTs are crucial part of any blockchain ecosystem be it for ownership, art or collection. On the Solana blockchain, Non-Fungible Tokens (NFTs) represent a vibrant and dynamic digital asset landscape that goes far beyond simple digital art.

In this Function example, we will fetch and analyze NFTs from the Solana Mainnet blockchain for a given wallet address.

Sample Function

The following is the code for the Function to get NFT data in JavaScript Node.js v20 runtime:

// Initialize the main function
const { Connection, PublicKey } = require('@solana/web3.js');
const axios = require('axios');
const BN = require('bn.js');

const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const METADATA_PROGRAM_ID = new PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s');

function cleanString(str) {
if (typeof str !== 'string') return '';
return str.replace(/\0/g, '').trim();
}

async function fetchNFTs(params) {
const wallet = params.user_data.wallet;
const network = params.user_data['x-qn-network'] || 'mainnet-beta';
const chain = params.user_data['x-qn-chain'] || 'SOL';
const page = Math.max(1, parseInt(params.user_data.page) || 1);
const perPage = Math.min(40, Math.max(1, parseInt(params.user_data.perPage) || 20));
const omitFields = params.user_data.omitFields ? params.user_data.omitFields.split(',') : [];

const rpcUrl = 'https://my-sol-endpoint.quiknode.pro/SECRET/'; //Replace with your actual Solana endpoint URL

const connection = new Connection(rpcUrl);

try {
const walletPublicKey = new PublicKey(wallet);

// Fetch all token accounts
const tokenAccounts = await connection.getParsedTokenAccountsByOwner(walletPublicKey, {
programId: TOKEN_PROGRAM_ID
});

// Count total NFTs and sort token accounts
let totalItems = 0;
const sortedTokenAccounts = tokenAccounts.value
.filter(({ account }) => {
const parsedInfo = account.data.parsed.info;
const amount = new BN(parsedInfo.tokenAmount.amount);
const decimals = parsedInfo.tokenAmount.decimals;
if ((amount.eq(new BN(1)) || amount.eq(new BN(0))) && decimals === 0) {
totalItems++;
return true;
}
return false;
})
.sort((a, b) => a.pubkey.toBase58().localeCompare(b.pubkey.toBase58()));

const totalPages = Math.ceil(totalItems / perPage);
const startIndex = (page - 1) * perPage;
const endIndex = Math.min(startIndex + perPage, totalItems);
const paginatedAccounts = sortedTokenAccounts.slice(startIndex, endIndex);

const nftMetadata = await Promise.all(paginatedAccounts.map(async ({ account }) => {
const parsedInfo = account.data.parsed.info;
const mintAddress = new PublicKey(parsedInfo.mint);
return await fetchNFTMetadata(connection, mintAddress);
}));

const assets = await Promise.all(nftMetadata.filter(Boolean).map(async (metadata) => {
const provenance = await fetchProvenance(connection, new PublicKey(metadata.mint));
const uri = cleanString(metadata.data.uri);
const externalMetadata = await fetchExternalMetadata(uri);

const asset = {
name: cleanString(metadata.data.name),
collectionName: metadata.collection?.name ? cleanString(metadata.collection.name) : "Unknown",
tokenAddress: metadata.mint,
collectionAddress: metadata.collection?.key || '',
imageUrl: cleanString(metadata.data.uri),
traits: externalMetadata.attributes || [],
chain: chain,
creators: metadata.data.creators ? metadata.data.creators.map(creator => ({
address: creator.address,
verified: creator.verified,
share: creator.share
})) : [],
network: network,
description: cleanString(externalMetadata.description),
symbol: cleanString(metadata.data.symbol),
external_url: cleanString(externalMetadata.external_url),
properties: externalMetadata.properties || [],
provenance: provenance,
tokenType: metadata.tokenStandard || 'Unknown'
};

omitFields.forEach(field => {
delete asset[field];
});

return asset;
}));

return {
owner: wallet,
assets: assets,
totalPages: totalPages,
pageNumber: page,
totalItems: totalItems
};
} catch (error) {
console.error('Error in fetchNFTs:', error);
throw error;
}
}

async function fetchNFTMetadata(connection, mintAddress) {
try {
const [metadataPDA] = await PublicKey.findProgramAddress(
[Buffer.from('metadata'), METADATA_PROGRAM_ID.toBuffer(), mintAddress.toBuffer()],
METADATA_PROGRAM_ID
);
const accountInfo = await connection.getAccountInfo(metadataPDA);
if (accountInfo) {
const metadata = decodeMetadata(accountInfo.data);
return {
...metadata,
mint: mintAddress.toBase58()
};
}
} catch (error) {
console.error(`Error fetching metadata for ${mintAddress.toBase58()}:`, error);
}
return null;
}

function decodeMetadata(buffer) {
let offset = 0;
const readString = () => {
const length = buffer.readUInt32LE(offset);
offset += 4;
const str = buffer.slice(offset, offset + length).toString('utf8');
offset += length;
return str;
};

const key = buffer.readUInt8(0);
offset = 1;
const updateAuthority = new PublicKey(buffer.slice(offset, offset + 32)).toBase58();
offset += 32;
const mint = new PublicKey(buffer.slice(offset, offset + 32)).toBase58();
offset += 32;
const name = readString();
const symbol = readString();
const uri = readString();
const sellerFeeBasisPoints = buffer.readUInt16LE(offset);
offset += 2;

let creators = null;
if (buffer[offset] === 1) {
offset += 1;
const creatorCount = buffer.readUInt32LE(offset);
offset += 4;
creators = [];
for (let i = 0; i < creatorCount; i++) {
const creator = new PublicKey(buffer.slice(offset, offset + 32));
offset += 32;
const verified = buffer[offset] === 1;
offset += 1;
const share = buffer[offset];
offset += 1;
creators.push({ address: creator.toBase58(), verified, share });
}
} else {
offset += 1;
}

let collection = null;
if (offset < buffer.length && buffer[offset] === 1) {
offset += 1;
collection = {
verified: buffer[offset] === 1,
key: new PublicKey(buffer.slice(offset + 1, offset + 33)).toBase58()
};
offset += 33;
}

let uses = null;
if (offset < buffer.length && buffer[offset] === 1) {
offset += 1;
uses = {
useMethod: buffer.readUInt8(offset),
remaining: buffer.readBigUInt64LE(offset + 1),
total: buffer.readBigUInt64LE(offset + 9)
};
offset += 17;
}

return {
key,
updateAuthority,
mint,
data: {
name,
symbol,
uri,
sellerFeeBasisPoints,
creators,
collection,
uses
},
primarySaleHappened: buffer[offset] === 1,
isMutable: buffer[offset + 1] === 1
};
}

async function fetchProvenance(connection, mintAddress) {
const signatures = await connection.getSignaturesForAddress(mintAddress, { limit: 100 });
const provenance = [];

for (const sig of signatures) {
try {
const tx = await connection.getTransaction(sig.signature, {
maxSupportedTransactionVersion: 1
});
if (tx) {
provenance.push({
txHash: sig.signature,
blockNumber: tx.slot,
date: new Date(tx.blockTime * 1000).toISOString()
});
}
} catch (error) {
console.error(`Error fetching transaction ${sig.signature}:`, error);
}
}

return provenance;
}

async function fetchExternalMetadata(uri) {
try {
const response = await axios.get(uri);
return response.data;
} catch (error) {
console.error('Error fetching external metadata:', error);
return {};
}
}

function main(params) {
if (!params.user_data || !params.user_data.wallet) {
return {
statusCode: 400,
body: {
jsonrpc: "2.0",
id: 1,
error: { message: 'Wallet address is required' }
}
};
}

return fetchNFTs(params)
.then(result => ({
statusCode: 200,
body: {
jsonrpc: "2.0",
id: 1,
result: result
}
}))
.catch(error => ({
statusCode: 500,
body: {
jsonrpc: "2.0",
id: 1,
error: { message: 'An error occurred while fetching NFT data', details: error.message }
}
}));
}

module.exports.main = main;

Replace https://my-sol-endpoint.quiknode.pro/SECRET/ one line 22 with your actual Solana mainnet RPC URL.

Request

We will invoke the function with the following cURL command. A few notes:

  • Replace the YOUR_API_KEY with your own QuickNode API key - follow this guide for creating an API key.
  • Replace the FUNCTION_ID with the ID of your Function - you can find this in the URL when viewing your Function in the QuickNode Dashboard.
  • Replace the WALLET_TO_TRACK with the Solana wallet address to fetch NFTs of that wallet.
  • Use the perPage parameter to the number of NFTs per page, default is 20.
  • Use the page parameter to specify page number for pagination, default is 1.
  • Use the block_number parameter to specify the block number you want to analyze. You can also omit this property for the function to run against the latest block.
curl -X POST "https://api.quicknode.com/functions/rest/v1/functions/FUNCTION_ID/call?result_only=true" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"user_data": {
"wallet": "WALLET_TO_TRACK"
}
}'

Response

Resulting in the following response:

{
"statusCode": 200,
"body": {
"jsonrpc": "2.0",
"id": 1,
"result": {
"owner": "9erdVynZfBRsxdoQXPKbxZ8oKCP98sYKQf4Bbi8ADYpL",
"assets": [
{
"name": "Key2 Silver Las Vegas",
"collectionName": "Unknown",
"tokenAddress": "96P5B6zUMWiyCrDNFQxfiJ1hTCKEeNCBTTrpoUNKZCiX",
"collectionAddress": "",
"imageUrl": "https://arweave.net/_jUIzw9j-pBFjioFjcsnM0hC6CNBfqs7R1PKAJYfCb8",
"traits": [
{
"trait_type": "Level",
"value": "Silver"
},
{
"trait_type": "Branch",
"value": "Las Vegas"
},
{
"trait_type": "Serial number",
"value": "16652"
},
{
"trait_type": "Created",
"value": "1713014510068",
"display_type": "date"
}
],
"chain": "SOL",
"creators": [
{
"address": "key2ioV2TtEGS6so624PyH4pgULrd7swXDxnP4nTBSF",
"verified": true,
"share": 100
}
],
"network": "mainnet-beta",
"description": "Unlock Vegas with the Silver Key! Navigate Sin City like a true local, gaining access to the best experiences Las Vegas has to offer. What Happens in Vegas Stays in Vegas.",
"symbol": "K2-S-LAS",
"external_url": "https://key2.io/r/K2-S-LAS?m=96P5B6zUMWiyCrDNFQxfiJ1hTCKEeNCBTTrpoUNKZCiX",
"properties": {
"files": [
{
"type": "image/png",
"uri": "https://arweave.net/48c2abpaaZcqpA3aaiGGOyWpCtkYzaggrsTyrLOwwOU"
}
]
},
"provenance": [
{
"txHash": "37MuQcpr21ZyHNneMWMhASLWj5FrWnqXAGWHZ2irfSkJ9Tn2xjRZAUS4Dvi18bceSLYppw8nW7XeRDjwfKV6P8ro",
"blockNumber": 259901676,
"date": "2024-04-13T13:22:26.000Z"
}
],
"tokenType": "Unknown"
}
],
"totalPages": 1,
"pageNumber": 1,
"totalItems": 1
}
}
}

Learn more about QuickNode Functions.

We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this doc