Skip to main content

How to Build a Token Holder Indexer and Wallet API

Updated on
Nov 7, 2024

14 min read

Streams

Streams is available to all users with a QuickNode plan. For teams with unique requirements, we offer tailored datasets, dedicated support, and custom integrations. Contact our team for more information.

Overview

The Token Holder Indexer and Wallet API provides a comprehensive solution for tracking token ownership related to a specific ERC721 contract. This guide walks you through the process of setting up an indexing system that monitors token transfers and returns holdings and token metadata by wallet address using QuickNode Streams, Functions, Key-Value Store, and RPC.

What You Will Do


  • Set up a Stream on QuickNode to monitor token transfers.
  • Filter incoming Stream data to update the token holdings in a Key-Value Store.
  • Implement a Function to retrieve token metadata via RPC.
  • Call your Function's built-in API endpoint to return wallet holdings and metadata.

What You Will Need


  • A QuickNode account.
  • Basic understanding of Ethereum transactions and events. Check out our guide on Ethereum transactions and events to learn more.
  • A QuickNode RPC endpoint on Ethereum Mainnet.
  • A webhook URL to receive notifications when transfers are detected. You can get a free webhook URL at webhook.site.

Why QuickNode Functions

Functions is a serverless computing platform for deploying lightweight, scalable code that’s optimized for web3, with built-in access to common web3 packages like ethers.js, web3.js, and the QuickNode SDK.

  • Cost-Effective Serverless Compute: You only pay for the resources you use, making Functions an ideal solution for handling on-demand tasks such as retrieving and processing blockchain data. Functions usage is billed at a flat rate of $0.0000159 per GB-sec after your included usage, which starts at 500 GB-sec included for Free users.
  • Built-in Support for Useful Packages and Web3 Libraries: QuickNode Functions come with built-in support for various useful packages and web3 libraries, which simplifies the development process. This means you can quickly integrate web3 functionality without the need for extensive setup.
  • Built-in API Endpoint: Each Function you create includes a built-in API endpoint. This makes it easy to expose your Function as a web service, enabling you to create powerful and scalable blockchain-based applications.
  • Performance at Scale: QuickNode's globally balanced, auto-scaling infrastructure ensures smooth operation, even at peak loads. This allows your Functions to perform reliably regardless of demand.
  • Blockchain Optimized: Use your Function as a destination for Streams, and it will automatically activate when new data is piped in from your Stream. You can also activate your Function via API and optionally specify a specific blockchain dataset to access within your Function during activation.
  • Storage Access: Access and manage your Key-Value Store data seamlessly within your Function, providing a unified and efficient workflow.

Building a Blockchain Indexer

Create a Stream on QuickNode

First, navigate to QuickNode Streams page in the Dashboard and click "Create Stream".

Next, create a Stream with the following settings:

QuickNode Stream settings


  • Chain: Ethereum
  • Network: Mainnet
  • Dataset: Receipts
  • Stream start: 16985981 (Set to the block just prior to the specified contract’s deployment block)
  • Stream payload: Modify the Stream before streaming
  • Latest block delay: 12 (This will help us to avoid reorgs)

QuickNode Stream block delay

Select the option fo modify the payload before streaming. This allows you to filter the streaming data and interact with Key-Value Store.

QuickNode Stream modify payload

Next, copy and paste the following code to filter transfer events for the (PIRATE) token ERC721 contract. The filter listens for transfer events on the token contract we are monitoring, and then creates a payload to update token holdings in the Key-Value Store. The filter will create lists in Key-Value Store, identified by the wallet address, and will add items to the list in the format contractAddress-tokenId when a transfer is sent to a wallet, and remove the item from the list of the sender's wallet address.

const test = false; // change this value to true if you are testing your stream 

function stripPadding(logTopic) {
return '0x' + logTopic.slice(-40).toLowerCase();
}

function main(stream) {
var results = {
fromActions: [],
toActions: []
};
var actionsTaken = false;

try {
var stream = stream[0];
var erc721TransferEvent = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
var contractAddress = '0x1B41d54B3F8de13d58102c50D7431Fd6Aa1a2c48'.toLowerCase(); // token contract

stream.forEach(receipt => {
receipt.logs.forEach(log => {
if (log.topics[0] === erc721TransferEvent && log.address.toLowerCase() === contractAddress) {
var from = stripPadding(log.topics[1]);
var to = stripPadding(log.topics[2]);
var tokenId = BigInt(log.topics[3]).toString();
var item = `${contractAddress}-${tokenId}`;

if (from !== '0x0000000000000000000000000000000000000000') {
results.fromActions.push({
wallet: from,
removed: [item],
action: test ? "Simulated remove action" : qnUpsertList(from, {
remove_items: [item]
})
});
actionsTaken = true;
}

if (to !== '0x0000000000000000000000000000000000000000') {
results.toActions.push({
wallet: to,
added: [item],
action: test ? "Simulated add action" : qnUpsertList(to, {
add_items: [item]
})
});
actionsTaken = true;
}
}
});
});

if (!actionsTaken) {
return null;
} else {
return {
timestamp: new Date().toISOString(),
actions: results
};
}
} catch (e) {
return { error: e.message };
}
}

Test the Stream filter

Key-Value Store

The Key-Value Store lists created from wallets within this stream are globally accessible through Streams, Functions, and the Key-Value Store REST API. When we test the Stream in this step, please make sure that const test = true; to avoid actually editing the list in Key-Value Store. Once you have finished testing, please make sure to set const test = false;.

You can test your stream by setting const test = true; in your filter and using block number 20290986 inside the Test block field. Then click the Run test button to test your filter. Once you have tested, remember to update your filter code const test = false; before clicking Next.

QuickNode Stream filter test block QuickNode Stream filter test results

Click the Next button and then choose "webhook" for your Stream destination. Input your webhook URL where the actions summaries will be sent. You can generate a free webhook URL at webhook.site. Note: Keep any unmentioned settings as they are.

Implement the Function

Use the following code to implement the Function that retrieves token metadata via RPC. Make sure to replace your-quicknode-rpc-endpoint with a real endpoint URL.

    const { Web3 } = require('web3');
const axios = require('axios');
const https = require('https');

const QUICKNODE_URL = 'your-quicknode-rpc-endpoint';

const web3 = new Web3(QUICKNODE_URL);

const httpsAgent = new https.Agent({
rejectUnauthorized: false, // Warning: This bypasses SSL certificate validation. Use with caution.
secureProtocol: 'TLSv1_2_method'
});

/**
*
* main() will be run when you invoke this action.
*
* @param params The parameters passed to the function.
*
* @return The output of this action, which must be a JSON object.
*
*/

async function main(params) {
let wallet = params.user_data.wallet;
let tokens = await qnLib.qnGetList(wallet); // Assuming this returns an array of token strings

if (!tokens || tokens.length === 0) {
return {
message: 'No tokens found'
};
} else {
const tokenMetadata = await getTokenMetadata(tokens);
return {
message: wallet,
tokens: tokenMetadata
};
}
}

async function getTokenMetadata(tokens) {
const tokenMetadata = [];

for (const token of tokens) {
const [contractAddress, tokenId] = token.split('-');
const metadata = await fetchTokenMetadata(contractAddress, tokenId);
tokenMetadata.push({ contractAddress, tokenId, metadata });
}

return tokenMetadata;
}

async function fetchTokenMetadata(contractAddress, tokenId) {
const contractABI = [
// ABI fragment for the function to fetch token metadata
{
"constant": true,
"inputs": [
{
"name": "tokenId",
"type": "uint256"
}
],
"name": "tokenURI",
"outputs": [
{
"name": "",
"type": "string"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}
];

const contract = new web3.eth.Contract(contractABI, contractAddress);

try {
const tokenURI = await contract.methods.tokenURI(tokenId).call();
// Fetch metadata from the tokenURI using axios
const response = await axios.get(tokenURI, { httpsAgent });
return response.data;
} catch (error) {
console.error(`Error fetching metadata for token ${tokenId} at ${contractAddress}:`, error);
return null;
}
}

module.exports = { main };

Next, in the right column of the Functions code editor, find the box for "User data", and inside that text box, add a test wallet, and then click the "Save and Test" button in the Functions code editor.

{"Wallet":"0xde27d2e6b5009ead76ebc07452b54364fb54fdcd"}

QuickNode Function test

Test the API

Once your Stream has caught up to the current block, you can check a wallet for holdings of (PIRATE) token. You will make an API call to your Function, sending in the wallet address inside user_data. If the wallet contains tokens from the specified contract and the base URL is accessible, the API will return the wallet’s holdings and metadata.

Example API Call

curl -X POST "https://api.quicknode.com/functions/rest/v1/namespaces/{your-namespace-id}/functions/{your-function-name}/call?result_only=true" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: QN_your-api-key" \
-d '{
"user_data": {
"Wallet":"0xde27d2e6b5009ead76ebc07452b54364fb54fdcd"
}
}'

Example Response

{
"wallet": "0xde27d2e6b5009ead76ebc07452b54364fb54fdcd",
"tokens": [
{
"contractAddress": "0x1b41d54b3f8de13d58102c50d7431fd6aa1a2c48",
"metadata": {
"animation_url": "ipfs://QmQYSdcxKKkWcSb2jMiii6nY5x67gsifRxZihMuSH3EEem/1005",
"attributes": [
{"trait_type": "Background", "value": "Cutlass"},
{"trait_type": "Character Type", "value": "Human Male"},
{"trait_type": "Coat", "value": "Purple"},
{"trait_type": "Dice Roll 1", "value": 5},
{"trait_type": "Dice Roll 2", "value": 4},
{"trait_type": "Eyes", "value": "Deep"},
{"trait_type": "Facial Hair", "value": "Vintage Modern"},
{"trait_type": "Headwear", "value": "Cleaver"},
{"trait_type": "Skin", "value": "Light Brown"},
{"trait_type": "Star Sign", "value": "Leo"},
{"trait_type": "generation", "value": 0},
{"trait_type": "xp", "value": 28100},
{"trait_type": "level", "value": 30},
{"trait_type": "Chests Claimed", "value": 5},
{"trait_type": "Elemental Affinity", "value": "Earth"},
{"trait_type": "Expertise", "value": "Health"},
{"trait_type": "Level", "value": 30},
{"trait_type": "XP", "value": 28100}
],
"description": "Take to the seas with your pirate crew! Explore the world and gather XP, loot, and untold riches in a race to become the world's greatest pirate captain! Play at https://piratenation.game",
"external_url": "https://piratenation.game/pirate/0x1E52c21b9DfCd947d03E9546448f513F1EE8706c/1005",
"image": "ipfs://QmRjgEp89ovHdr1M8rkjoC6iEgbNna5kBDjbfubm4zeVDd/1005",
"image_png_1024": "ipfs://QmRjgEp89ovHdr1M8rkjoC6iEgbNna5kBDjbfubm4zeVDd/1005",
"image_png_128": "ipfs://QmdqWrDHCH2VoXpwkGskBjooVj99Sqw5zqTBKvwEAYuF3J/1005",
"image_png_2048": "ipfs://QmRjgEp89ovHdr1M8rkjoC6iEgbNna5kBDjbfubm4zeVDd/1005",
"image_png_256": "ipfs://Qmdfts291hNpr8SmqkN6sQgKLzpfabHQxXme7oBekHWDo3/1005",
"image_png_32": "ipfs://Qma2rcLonwgUR7dmz6zUVRHeTTfT8FwnNsrC7bmKnQrScU/1005",
"image_png_512": "ipfs://QmUZ4JXK5VhoAMt3UTMyGML9jE4TaTx5EakMHdU4o5V5Cz/1005",
"image_png_64": "ipfs://QmfHwFYQY5rV3pQ2J5UbNyeWstx384UWV16K1PvCoDmV7q/1005",
"image_svg": "ipfs://QmdnsH8mf1rN8C8w9Km3Et84VEqfTgDb2yJP5CWtKVwFxw/1005",
"model_gltf_url": "ipfs://QmeBen9iF2RQQP7h5haQjWKKxBUmmVMVzc3wGmveENuEZ5/1005",
"name": "Founder's Pirate #1005"
},
"tokenId": "1005"
}
]
}

More resources


Possible Ideas to Explore with Functions


  • Building a custom NFT marketplace with real-time updates
  • Creating automated trading bots that react to specific on-chain events
  • Developing advanced analytics platforms for blockchain data

Conclusion

In this guide, we've walked through setting up a comprehensive solution for tracking token ownership related to a specific ERC721 contract. By leveraging QuickNode Streams, Functions, and Key-Value Store, you can efficiently monitor and retrieve token holdings and metadata by wallet address, providing a powerful tool for various blockchain-based applications.

We ❤️ Feedback!

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

Share this guide