<%= new Date(activity.block_time).toLocaleDateString(); %>
<% if (activity.type === 'call') { %>
Interaction
<% } else if (activity.value) { %>
<%
let amountDisplay = '0';
const decimals = typeof activity.token_metadata?.decimals === 'number' ? activity.token_metadata.decimals : 18;
if (decimals !== null) {
const valueStr = activity.value.toString();
const padded = valueStr.padStart(decimals + 1, '0');
let intPart = padded.slice(0, -decimals);
let fracPart = padded.slice(-decimals).replace(/0+$/, '');
if (!intPart) intPart = '0';
if (fracPart) {
amountDisplay = `${intPart}.${fracPart}`;
} else {
amountDisplay = intPart;
}
const amountNum = parseFloat(amountDisplay);
if (amountNum > 0 && amountNum < 0.0001) {
amountDisplay = '<0.0001';
}
if (amountNum > 1e12 || amountDisplay.length > 12) {
amountDisplay = amountNum.toExponential(2);
}
} else {
amountDisplay = activity.id || String(activity.value);
}
// Clean up the symbol: remove $ and anything after space/dash/bracket, and limit length
let symbol = activity.token_metadata?.symbol || (activity.asset_type === 'native'
? (activity.chain_id === 1 || activity.chain_id === 8453 || activity.chain_id === 10 ? 'ETH' : 'NTV')
: (activity.id ? '' : 'Tokens'));
if (symbol) {
symbol = symbol.replace('$', '').split(/[\s\-\[]/)[0].substring(0, 8);
}
%>
<% if (activity.type === 'receive') { %>+<% } else if (activity.type === 'send') { %>-<% } %><%= amountDisplay %> <%= symbol %>
<% } %>
<% }); %>
<% } else if (walletAddress) { %>
No activity found for this wallet.
<% } else { %>
Enter a wallet address to see activity.
<% } %>
```
This EJS transforms the raw data from the Activity API into an intuitive and visual transaction history.
Here's a breakdown of how it processes each activity item:
* A **list entry** is generated for each transaction.
* An **icon** visually indicates the transaction's nature: receive (↓), send (↑), or contract call (⇆).
* A **descriptive title** is dynamically constructed using the `activity.type` (and `activity.function.name` for contract calls).
* The transaction's **timestamp** (`block_time`) is converted to a readable local date/time string.
* The **chain ID** (`chain_id`) is displayed, providing important multichain context.
Beyond these descriptive elements, the template also focuses on presenting the value and financial aspects of each transaction:
* The **transaction amount** (raw `value`) is converted into a user-friendly decimal format (e.g., "1.5 ETH"). This conversion utilizes the `decimals` property from `token_metadata`.
* For **NFTs**, if a standard decimal value isn't applicable, the template displays the `token_id`.
* The **USD value** (`value_usd`), if provided by the API, is formatted to two decimal places and shown, giving a sense of the transaction's monetary worth.
***
Restart your server by running `node server.js` and refresh the app in the browser.
When you click on the *Activity* tab, you should now see a list of the latest transactions, similar to the screenshot at the beginning of this guide.
## Conclusion
You successfully added a realtime, fully functional activity feed to your multichain wallet with a single API request.
In the next and final guide of this series, [Display NFTs & Collectibles](/evm/show-nfts-collectibles), we will complete the wallet by adding support for viewing NFT collections.
# Balances
Source: https://docs.sim.dune.com/evm/balances
/openapi.json GET /v1/evm/balances/{address}
Access realtime token balances. Get comprehensive details about native and ERC20 tokens, including token metadata and USD valuations.
The Token Balances API provides accurate and fast real time balances of the native and ERC20 tokens of accounts on supported EVM blockchains.
Looking for the balance of a single token for a wallet? See Single token balance.
Looking for only stablecoin balances? Use the dedicated Stablecoins endpoint or add `asset_class=stablecoin` to your request.
## Token Prices
Sim looks up prices onchain. We use the most liquid onchain pair to determine a USD price. We return the available liquidity in `pool_size` as part of the response, and show a warning `low_liquidity: true` if this value is less than \$10k.
You can include `metadata=pools` in your request to see which liquidity pool was used for pricing each token. The returned `pool` object includes `pool_type`, `address`, `chain_id`, and `tokens`.
## Token Filtering
We also include the `pool_size` field in all responses, allowing you to implement custom filtering logic based on your specific requirements. For a detailed explanation of our approach, see our [Token Filtering](/token-filtering) guide.
### Exclude unpriced balances
By default, balances for tokens without available pricing data are included in responses. To filter them out, pass `exclude_unpriced=true`.
### Exclude tokens with less than 100 USD liquidity
Use the optional `exclude_spam_tokens` query parameter to automatically filter out tokens with less than 100 USD of liquidity. Include the query parameter `exclude_spam_tokens=true` so that those tokens are excluded from the response entirely. This is distinct from the `low_liquidity` field in the response, which is `true` when liquidity is below 10,000. To learn more about how Sim calculates liquidity data, visit the [Token Filtering](/token-filtering) guide.
## Compute Unit Cost
The Balances endpoint’s CU cost equals the number of chains you include via the `chain_ids` query parameter.
For example, `?chain_ids=1,8453,137` processes three chains and consumes three CUs.
If you omit `chain_ids`, the endpoint uses its `default` chain set (low-latency networks), which equals chains at request time (subject to change). See the tags section of the Supported Chains page and the Compute Units page for details.
## Historical prices
You can request historical point-in-time prices by adding the `historical_prices` query parameter. Use whole numbers to specify the number of hours in the past. You can request up to three offsets. For example, `&historical_prices=168` returns the price 168 hours (1 week) ago. `&historical_prices=720,168,24` returns prices 720 hours (1 month) ago, 168 hours (1 week) ago, and 24 hours ago.
The `historical_prices` parameter is currently supported only on the EVM Balances and EVM Token Info endpoints.
When set, each balance includes a `historical_prices` array with one entry per offset:
```json theme={null}
{
"balances": [
{
"symbol": "ETH",
"price_usd": 3896.8315,
"historical_prices": [
{ "offset_hours": 8760, "price_usd": 2816.476803 },
{ "offset_hours": 720, "price_usd": 3710.384068 },
{ "offset_hours": 168, "price_usd": 3798.632723 }
]
}
]
}
```
**Percent changes are not returned**. You can compute your own differences using the `price_usd` on the balance and the values in `historical_prices[].price_usd`.
The maximum number of historical price offsets is 3. If more than 3 are provided, the API returns an error.
## Warnings
When requesting balances for specific chains using the `chain_ids` parameter, the API may return warnings if some requested chain IDs are not supported. Unlike errors, warnings indicate non-fatal issues where the request can still be partially fulfilled.
When unsupported chain IDs are included in your request, the API will:
* Return balances for all supported chains you requested
* Include a `warnings` array in the response with details about the unsupported chains
### Example: Request with Unsupported Chain IDs
If you request `?chain_ids=1,9999,10,77777777777`, the API returns balances for chains 1 and 10 (supported), and includes a warning about chains 9999 and 77777777777 (unsupported):
```json theme={null}
{
"wallet_address": "0x37305b1cd40574e4c5ce33f8e8306be057fd7341",
"balances": [
{
"chain": "ethereum",
"chain_id": 1,
"address": "native",
"amount": "1000000000000000000",
"symbol": "ETH",
"decimals": 18,
"price_usd": 3896.8315,
"value_usd": 3896.8315
},
{
"chain": "optimism",
"chain_id": 10,
"address": "native",
"amount": "500000000000000000",
"symbol": "ETH",
"decimals": 18,
"price_usd": 3896.8315,
"value_usd": 1948.41575
}
],
"warnings": [
{
"code": "UNSUPPORTED_CHAIN_IDS",
"message": "Some requested chain_ids are not supported. Balances are returned only for supported chains.",
"chain_ids": [9999, 77777777777],
"docs_url": "https://docs.sim.dune.com/evm/supported-chains"
}
]
}
```
Check the [Supported Chains](/evm/supported-chains) page to see which chains are currently supported for the Balances endpoint.
## Pagination
This endpoint is using cursor based pagination. You can use the `limit` query parameter to define the maximum page size.
Results might at times be less than the maximum page size.
The `next_offset` value is passed back by the initial response and can be used to fetch the next page of results, by passing it as the `offset` query parameter in the next request.
You can only use the value from `next_offset` to set the `offset` query parameter of the next page of results.
## Single token balance
Use the token sub-path of the Balances endpoint to fetch a wallet’s balance for one token on one chain.
This sub-path accepts exactly one chain via the `chain_ids` query parameter.
Use the ERC-20 contract address in `{token_address}`.
```bash theme={null}
curl -s -X GET 'https://api.sim.dev.dune.com/v1/evm/balances/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/token/0x146523e8db6337291243a63a5555f446fa6c279f?chain_ids=1' \
-H 'X-Sim-Api-Key: YOUR_API_KEY'
```
```json theme={null}
{
"request_time": "2025-10-11T03:47:29.364380268+00:00",
"response_time": "2025-10-11T03:47:29.380032150+00:00",
"wallet_address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
"balances": [
{
"chain": "ethereum",
"chain_id": 1,
"address": "0x146523e8db6337291243a63a5555f446fa6c279f",
"amount": "7156868995423049840501842481",
"symbol": "AiMeme",
"name": "Ai Meme",
"decimals": 18,
"price_usd": 102826.739324412,
"value_usd": 735917502571333,
"pool_size": 9.09741149400001,
"low_liquidity": true
}
]
}
```
Set `{token_address}` to `native`.
```bash theme={null}
curl -s -X GET 'https://api.sim.dev.dune.com/v1/evm/balances/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/token/native?chain_ids=1' \
-H 'X-Sim-Api-Key: YOUR_API_KEY'
```
```json theme={null}
{
"request_time": "2025-10-11T03:48:00.471500727+00:00",
"response_time": "2025-10-11T03:48:00.485194141+00:00",
"wallet_address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
"balances": [
{
"chain": "ethereum",
"chain_id": 1,
"address": "native",
"amount": "783060447601684229",
"symbol": "ETH",
"decimals": 18,
"price_usd": 3779.154092,
"value_usd": 2959.30609483726
}
]
}
```
### Compute Unit Cost
This sub-path has a fixed CU cost of **1** per request.
## Real-Time Updates
**Skip the polling.** Use the [Subscriptions API](/evm/subscriptions) to receive webhook notifications the moment balance changes occur. [Set up a webhook in minutes](/evm/subscriptions/create-webhook).
# Build a Realtime Chat Agent
Source: https://docs.sim.dune.com/evm/build-a-realtime-chat-agent
Create an AI-powered chat app that can answer questions about blockchain data using Dune's Sim APIs and OpenAI's function calling feature.
In this guide, you'll learn how to build **Simchat**, an AI chat agent that can provide realtime blockchain insights through natural conversation.
Users can ask questions about wallet balances, transaction history, NFT collections, and token information across 60+ EVM chains and Solana, and the agent will fetch and explain the data in a friendly way.
By combining OpenAI's LLMs with the realtime blockchain data provided by Sim APIs, you'll create a chat agent that makes onchain data accessible to everyone, regardless of their technical expertise.
Access the complete source code for Simchat on GitHub
Chat with the finished assistant
## Prerequisites
Before we begin, ensure you have:
* Node.js >= 18.0.0
* A [Sim API key](/#authentication)
* An [OpenAI API key](https://platform.openai.com/docs/api-reference/introduction)
Learn how to obtain your Sim API key
## Features
When you complete this guide, your chat agent will have these capabilities:
Automatically triggers API requests based on user queries using OpenAI's function calling feature
Retrieves native and ERC20/SPL token balances with USD values for any wallet address across EVM chains and Solana
Displays chronological wallet activity including transfers and contract interactions on EVM networks
Shows ERC721 and ERC1155 collectibles owned by wallet addresses across supported EVM chains
Provides detailed token information, pricing, and holder distributions for EVM and Solana tokens
Users ask questions in plain English about blockchain data, no technical knowledge required
## Try the Live Demo
Before diving into building, you can interact with the live chat agent app below.
Try these example questions:
* What tokens does vitalik.eth have?
* Show me the NFTs in wallet `0xd8da6bf26964af9d7eed9e03e53415d37aa96045`
* What's the price of USDC?
* Get token balances for `DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK` on Solana
## Project Setup
Let's start by creating the project structure and installing dependencies.
Open your terminal and create a new directory:
```bash theme={null}
mkdir simchat
cd simchat
```
Initialize a new Node.js project:
```bash theme={null}
npm init -y
npm pkg set type="module"
```
Install the required packages:
```bash theme={null}
npm install express openai dotenv
```
These packages provide:
* **express**: Web server framework
* **openai**: Official OpenAI client library
* **dotenv**: Environment variable management
Create a `.env` file in your project root:
```bash theme={null}
touch .env
```
Add your API keys:
```plaintext .env theme={null}
# Required API keys
OPENAI_API_KEY=your_openai_api_key_here
SIM_API_KEY=your_sim_api_key_here
```
Never commit your `.env` file to version control. Add it to `.gitignore` to keep your API keys secure.
Create the main app files:
```bash theme={null}
touch server.js
touch chat.html
```
The `server.js` file will handle our backend Express server and API logic, while `chat.html` contains our frontend chat interface.
Populate the `server.js` with this basic Express code:
```javascript [expandable] theme={null}
import express from 'express';
import { OpenAI } from 'openai';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
dotenv.config();
// Set up __dirname for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Initialize Express
const app = express();
app.use(express.json());
// Initialize OpenAI client
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
// Sim API key
const SIM_API_KEY = process.env.SIM_API_KEY;
// Serve the HTML file
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'chat.html'));
});
// Start server
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
```
Add the initial frontend template to `chat.html`:
```html [expandable] theme={null}
Sim APIs Chat
```
Your project structure should now look like:
```
simchat/
├── server.js # Express server with OpenAI integration
├── chat.html # Chat interface
├── package.json # Project configuration
├── .env # API keys (keep private)
└── node_modules/ # Dependencies
```
Run `node server.js` in the terminal to start the server.
Visit `http://localhost:3001` to see the newly scaffolded chat app.
If you try to send a message at this point, you'll get a server error since we haven't implemented the back-end functionality yet.
If you encounter errors, make sure your `.env` file contains the correct `OPENAI_API_KEY` and `SIM_API_KEY`.
Check your terminal for any error messages from `server.js`.
## Add OpenAI LLM Chat
Now let's add the core chat functionality to our Express server using OpenAI's GPT-4o-mini.
We'll start by defining a system prompt that instructs the LLM on its role and capabilities.
Add this `SYSTEM_PROMPT` variable to your `server.js` file:
```javascript (server.js) theme={null}
// System prompt that instructs the AI assistant
const SYSTEM_PROMPT = `You are a helpful assistant that can answer questions about blockchain data using Dune's Sim APIs. You have access to various functions that can fetch realtime blockchain data including:
- Token balances for wallets across 60+ EVM chains
- Transaction activity and history
- NFT collections and collectibles
- Token metadata and pricing information
- Token holder distributions
- Supported blockchain networks
When users ask about blockchain data, wallet information, token details, or transaction history, use the appropriate functions to fetch realtime data. Always provide clear, helpful explanations of the data you retrieve.
Keep your responses concise and focused. When presenting large datasets, summarize the key findings rather than listing every detail.`;
```
This system prompt sets the context for the LLM, explaining its capabilities and how it should behave when interacting with users.
Now let's implement the basic chat endpoint with Express.js that uses this system prompt.
The `/chat` endpoint will receive POST requests from our frontend chat interface, process them through the LLM, and return responses to display in the chat:
```javascript theme={null}
// Basic chat endpoint
app.post('/chat', async (req, res) => {
try {
const { message } = req.body;
if (!message) return res.status(400).json({ error: 'Message is required' });
// Create conversation with system prompt
const messages = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: message }
];
// Call OpenAI
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: messages,
max_tokens: 2048
});
const assistantMessage = response.choices[0].message.content;
res.json({
message: assistantMessage
});
} catch (error) {
console.error('Chat error:', error);
res.status(500).json({
error: 'An error occurred while processing your request',
details: error.message
});
}
});
```
Run `node server.js` again and visit `http://localhost:3001`.
You'll have a working chat interface powered by OpenAI's `gpt-4o-mini` model with a custom system prompt, but it won't be able to fetch realtime blockchain data yet.
## Define OpenAI Functions
To make our chatbot fetch realtime blockchain data, we need to use OpenAI's [function calling](https://platform.openai.com/docs/guides/function-calling?api-mode=responses) feature.
When the model determines it needs external data, it will call one of these functions with appropriate parameters, and we can then execute the actual API call and provide the results back to the model.
Add this `functions` array to your `server.js` file:
```javascript [expandable] theme={null}
// Function definitions for OpenAI function calling
const functions = [
{
type: "function",
function: {
name: "get_token_balances",
description: "Get realtime token balances for an EVM wallet address across multiple chains. Returns native and ERC20 token balances with USD values.",
parameters: {
type: "object",
properties: {
address: {
type: "string",
description: "The wallet address to get balances for (e.g., 0xd8da6bf26964af9d7eed9e03e53415d37aa96045)"
},
description: "Whether to exclude spam tokens from results",
default: true
}
},
required: ["address"],
additionalProperties: false
}
}
},
{
type: "function",
function: {
name: "get_wallet_activity",
description: "Get chronologically ordered transaction activity for an EVM wallet including transfers, contract interactions, and decoded function calls.",
parameters: {
type: "object",
properties: {
address: {
type: "string",
description: "The wallet address to get activity for"
},
limit: {
type: "number",
description: "Maximum number of activities to return (default: 25)",
default: 25
}
},
required: ["address"],
additionalProperties: false
}
}
},
{
type: "function",
function: {
name: "get_nft_collectibles",
description: "Get NFT collectibles (ERC721 and ERC1155) owned by an EVM wallet address.",
parameters: {
type: "object",
properties: {
address: {
type: "string",
description: "The wallet address to get NFTs for"
},
limit: {
type: "number",
description: "Maximum number of collectibles to return (default: 50)",
default: 50
}
},
required: ["address"],
additionalProperties: false
}
}
},
{
type: "function",
function: {
name: "get_token_info",
description: "Get detailed metadata and pricing information for a specific token on EVM chains.",
parameters: {
type: "object",
properties: {
token_address: {
type: "string",
description: "The token contract address or 'native' for native tokens"
},
chain_ids: {
type: "string",
description: "Chain IDs to search on (e.g., '1,137,8453' or 'all')",
default: "all"
}
},
required: ["token_address"],
additionalProperties: false
}
}
},
{
type: "function",
function: {
name: "get_token_holders",
description: "Get token holders for a specific ERC20 token, ranked by balance descending.",
parameters: {
type: "object",
properties: {
chain_id: {
type: "number",
description: "The chain ID where the token exists (e.g., 1 for Ethereum)"
},
token_address: {
type: "string",
description: "The token contract address"
},
limit: {
type: "number",
description: "Maximum number of holders to return (default: 100)",
default: 100
}
},
required: ["chain_id", "token_address"],
additionalProperties: false
}
}
},
{
type: "function",
function: {
name: "get_transactions",
description: "Get detailed transaction information for an EVM wallet address.",
parameters: {
type: "object",
properties: {
address: {
type: "string",
description: "The wallet address to get transactions for"
},
limit: {
type: "number",
description: "Maximum number of transactions to return (default: 25)",
default: 25
}
},
required: ["address"],
additionalProperties: false
}
}
},
{
type: "function",
function: {
name: "get_supported_chains",
description: "Get list of all supported EVM chains and their capabilities.",
parameters: {
type: "object",
properties: {},
additionalProperties: false
}
}
},
{
type: "function",
function: {
name: "get_svm_token_balances",
description: "Get token balances for a Solana (SVM) address. Returns native and SPL token balances with USD values.",
parameters: {
type: "object",
properties: {
address: {
type: "string",
description: "The Solana wallet address to get balances for (e.g., DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK)"
},
limit: {
type: "number",
description: "Maximum number of balances to return (default: 100)",
default: 100
},
chains: {
type: "string",
description: "Comma-separated list of chains to include",
default: "all"
}
},
required: ["address"],
additionalProperties: false
}
}
},
{
type: "function",
function: {
name: "get_svm_token_metadata",
description: "Get metadata for a Solana token mint address.",
parameters: {
type: "object",
properties: {
mint: {
type: "string",
description: "The Solana token mint address (e.g., So11111111111111111111111111111111111111112)"
}
},
required: ["mint"],
additionalProperties: false
}
}
}
];
```
Each function corresponds to a different Sim API endpoint that we'll implement next.
## Integrate Sim APIs
Now we need to connect OpenAI's function calls to actual Sim API requests. When the model calls a function like `get_token_balances`, we need to:
1. Map that OpenAI function call to the correct Sim API endpoint
2. Make the HTTP request with proper authentication
3. Return the data back to the model
We'll implement this with three components: a generic API caller, endpoint configurations, and an execution function that ties them together.
### Build the Generic API Caller
First, let's create a reusable function that handles all HTTP requests to Sim APIs:
```javascript theme={null}
// Generic API call function for Sim APIs
async function apiCall(endpoint, params = {}) {
try {
const queryString = Object.keys(params).length
? '?' + new URLSearchParams(params).toString()
: '';
const response = await fetch(`https://api.sim.dune.com${endpoint}${queryString}`, {
headers: {
'X-Sim-Api-Key': SIM_API_KEY,
'Content-Type': 'application/json'
}
});
if (!response.ok) throw new Error(`API request failed: ${response.statusText}`);
return await response.json();
} catch (error) {
return { error: error.message };
}
}
```
This function handles all the common functionality needed for Sim API requests:
* URL construction with query parameters
* Authentication headers
* Error handling
* JSON parsing
By centralizing this logic, we avoid code duplication and ensure consistent error handling across all API calls.
### Configure API Endpoints
Next, we'll create a more comprehensive configuration object that handles all the different parameter patterns used by Sim APIs:
```javascript theme={null}
// API endpoint configurations
const API_CONFIGS = {
get_token_balances: (address) => {
const queryParams = new URLSearchParams({ metadata: 'url,logo' });
return [`/v1/evm/balances/${address}`, queryParams];
},
get_wallet_activity: (address, limit = 25) =>
[`/v1/evm/activity/${address}`, { limit: Math.min(limit, 10) }],
get_nft_collectibles: (address, limit = 50) =>
[`/v1/evm/collectibles/${address}`, { limit: Math.min(limit, 10) }],
get_token_info: (token_address, chain_ids = 'all') =>
[`/v1/evm/token-info/${token_address}`, { chain_ids }],
get_token_holders: (chain_id, token_address, limit = 100) =>
[`/v1/evm/token-holders/${chain_id}/${token_address}`, { limit: Math.min(limit, 10) }],
get_transactions: (address, limit = 25) =>
[`/v1/evm/transactions/${address}`, { limit: Math.min(limit, 10) }],
get_supported_chains: () =>
['/v1/evm/supported-chains', {}],
get_svm_token_balances: (address, limit = 100, chains = 'all') => {
const queryParams = new URLSearchParams();
if (chains) queryParams.append('chains', chains);
if (limit) queryParams.append('limit', Math.min(limit, 20));
return [`/beta/svm/balances/${address}`, queryParams];
},
get_svm_token_metadata: (mint) =>
[`/beta/svm/token-metadata/${mint}`, {}]
};
```
This configuration handles all the different patterns of Sim APIs: simple objects for basic query parameters, `URLSearchParams` for complex query strings, multiple path parameters, and endpoints with no parameters.
### Execute Function Calls
Now we need an enhanced `callFunction` that can handle both regular objects and `URLSearchParams`:
```javascript theme={null}
// Function to execute API calls based on function name
async function callFunction(name, args) {
if (!API_CONFIGS[name]) return JSON.stringify({ error: `Unknown function: ${name}` });
const [endpoint, params] = API_CONFIGS[name](...Object.values(args));
const result = await apiCall(endpoint, params);
return JSON.stringify(result);
}
```
This approach maintains the streamlined `API_CONFIGS` pattern while properly handling all the different parameter types and patterns used by the various Sim API endpoints.
The `apiCall` function can handle both `URLSearchParams` objects (for complex queries) and regular objects (for simple query parameters).
### Update the Chat Endpoint
Finally, we need to update our chat endpoint to handle function calls.
Replace your existing `/chat` endpoint with this version that includes function calling support:
```javascript [expandable] theme={null}
// Enhanced chat endpoint with function calling
app.post('/chat', async (req, res) => {
try {
const { message } = req.body;
if (!message) return res.status(400).json({ error: 'Message is required' });
// Create conversation with system prompt
const messages = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: message }
];
// Call OpenAI with function definitions
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: messages,
tools: functions,
tool_choice: "auto",
max_tokens: 2048
});
let assistantMessage = response.choices[0].message;
// Handle function calls if present
if (assistantMessage.tool_calls) {
messages.push(assistantMessage);
// Execute each function call
for (const toolCall of assistantMessage.tool_calls) {
const functionResult = await callFunction(
toolCall.function.name,
JSON.parse(toolCall.function.arguments)
);
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: functionResult
});
}
// Get final response with function results
const finalResponse = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: messages,
tools: functions,
tool_choice: "auto",
max_tokens: 2048
});
assistantMessage = finalResponse.choices[0].message;
}
res.json({
message: assistantMessage.content,
function_calls: assistantMessage.tool_calls || []
});
} catch (error) {
console.error('Chat error:', error);
res.status(500).json({
error: 'An error occurred while processing your request',
details: error.message
});
}
});
```
This enhanced endpoint now supports the full function calling workflow: it sends the user's message to OpenAI with the available functions, executes any function calls that the model makes, and then sends the function results back to get the final conversational response.
Restart your server and test the function calling functionality. Try asking questions like *What tokens does vitalik.eth have?* and watch as your chat agent fetches realtime data from Sim APIs to provide accurate, up-to-date responses.
## Conclusion
You've successfully built a realtime chat agent that makes blockchain data accessible through natural conversation.
By combining OpenAI's LLMs with Sim APIs' comprehensive blockchain data, you've created a tool that can instantly fetch and explain complex onchain information across 60+ EVM chains and Solana.
This foundation provides everything you need to build your own specialized blockchain chat assistants.
Consider extending it for specific use cases like:
* **NFT Discovery Bot**: Integrate marketplace data, rarity rankings, and collection insights
* **Portfolio Manager**: Include transaction categorization, P\&L tracking, and tax reporting features
* **Trading Assistant**: Add price alerts, technical indicators, and market sentiment analysis
The [complete source code on GitHub](https://github.com/duneanalytics/sim-guides/tree/main/simchat) includes additional features like full session management and enhanced error handling that weren't covered in this guide
Explore the repository to see the additional features in action.
# Build a Realtime Wallet
Source: https://docs.sim.dune.com/evm/build-a-realtime-wallet
Create a multichain wallet that displays realtime balances, transactions, and NFTs using Sim APIs and Express.js
This is the first guide in our series on building a realtime, multichain wallet using Sim APIs.
In this one, we will lay the foundation for our wallet.
You will set up a Node project with Express.js, fetch and display token balances from 60+ EVM chains using the Balances API, and calculate the wallet's total portfolio value in USD.
Access the complete source code for this wallet on GitHub
Interact with the finished wallet app
## Prerequisites
Before we begin, make sure you have:
* Node.js >= 18.0.0
* A Sim API key
Learn how to obtain your API key to properly authenticate requests.
## Features
By the end of this series, our wallet will have four main features:
1. **Token Balances**: Realtime balance tracking with USD values using the [Balances API](/evm/balances).
2. **Total Portfolio Value**: Aggregated USD value across all chains.
3. **Wallet Activity**: Comprehensive transaction history showing transfers and contract interactions using the [Activity API](/evm/activity)
4. **NFTs**: A display of owned NFTs using the [Collectibles API](/evm/collectibles)
In this first guide, we will focus on implementing the first two: **Token Balances** and **Total Portfolio Value**.
## Try the Live Demo
Before diving into building, you can interact with the finished wallet app below.
Enter any Ethereum wallet address to explore its token balances, transaction history, and NFT collection across multiple chains.
## Project Setup
Let's start by scaffolding our project.
This initial setup will provide a basic Express.js server and frontend templates, allowing us to focus on integrating Sim APIs.
Open your terminal and create a new directory for the project:
```bash theme={null}
mkdir wallet-ui
cd wallet-ui
```
Now you are in the `wallet-ui` directory.
Next, initialize a new Node.js project with npm:
```bash theme={null}
npm init -y
npm pkg set type="module"
```
These commands create a `package.json` file with default values and configure it to use ES modules.
Afterward, install the required packages:
```bash theme={null}
npm install express ejs dotenv numbro
```
We are using three packages for our wallet:
* **Express.js**: A popular Node.js web framework for creating our server.
* **EJS**: A simple templating engine that lets us generate dynamic HTML.
* **dotenv**: A package to load environment variables from a `.env` file.
* **numbro**: For formatting numbers and currency.
Create a new `.env` file in your project root:
```bash theme={null}
touch .env
```
Open the `.env` file in your code editor and add your Sim API key:
```plaintext .env theme={null}
# Your Sim API key (required)
SIM_API_KEY=your_api_key_here
```
Never commit your `.env` file to version control. If you are using Git, add `.env` to your `.gitignore` file.
Create the necessary directories for views and public assets:
```bash theme={null}
mkdir views
mkdir public
```
`views` will hold our EJS templates, and `public` will serve static assets like CSS.
Now, create the core files:
```bash theme={null}
touch server.js
touch views/wallet.ejs
touch public/styles.css
```
Populate `server.js` with this basic Express server code:
```javascript server.js [expandable] theme={null}
import express from 'express';
import numbro from 'numbro';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
// Load environment variables
dotenv.config();
const SIM_API_KEY = process.env.SIM_API_KEY;
if (!SIM_API_KEY) {
console.error("FATAL ERROR: SIM_API_KEY is not set in your environment variables.");
process.exit(1);
}
// Set up __dirname for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Initialize Express
const app = express();
// Configure Express settings
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.static(path.join(__dirname, 'public')));
// Add our home route
app.get('/', async (req, res) => {
const {
walletAddress = '',
tab = 'tokens' // Default to tokens tab
} = req.query;
let tokens = [];
let totalWalletUSDValue = 0;
let activities = []; // For Guide 2
let collectibles = []; // For Guide 3
// In later steps, these arrays will be populated with API data.
res.render('wallet', {
walletAddress: walletAddress,
currentTab: tab,
totalWalletUSDValue: '$0.00', // Will be calculated later in this guide.
tokens: tokens,
activities: activities,
collectibles: collectibles
});
});
// Start the server
app.listen(3001, () => {
console.log(`Server running at http://localhost:3001`);
});
```
Add the initial frontend template to `views/wallet.ejs`:
```ejs views/wallet.ejs [expandable] theme={null}
Sim APIs Wallet UI
Enter a wallet address above to see token balances.
<% } %>
Activity feature will be added in the next guide.
Collectibles feature will be added in a future guide.
```
Add basic styles to `public/styles.css`:
```css public/styles.css [expandable] theme={null}
:root {
--font-primary: 'IBM Plex Sans', sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
--color-bg-deep: #e1e2f9;
--color-bg-container: #141829;
--color-border-primary: #2c3040;
--color-border-secondary: #222636;
--color-text-primary: #ffffff;
--color-text-secondary: #e0e0e0;
--color-text-muted: #a0a3b1;
--color-text-subtle: #808391;
--color-accent-green: #50e3c2;
--color-accent-purple: #7e87ef;
--color-accent-red: #ff7875;
--color-placeholder-bg: #3a3f58;
}
body {
font-family: var(--font-primary);
background-color: var(--color-bg-deep);
color: var(--color-text-secondary);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding-top: 0;
padding-bottom: 0;
height: 100vh;
}
.mobile-container {
width: 100%;
max-width: 420px;
height: 90vh;
max-height: 800px;
min-height: 600px;
background-color: var(--color-bg-container);
border-radius: 20px;
display: flex;
flex-direction: column;
overflow: hidden;
align-self: center;
box-shadow: 0 8px 32px rgba(20, 24, 41, 0.18), 0 1.5px 6px rgba(20, 24, 41, 0.10);
}
/* Header Styles */
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--color-border-primary);
flex-shrink: 0;
}
.profile-pic-placeholder {
width: 36px;
height: 36px;
background-color: var(--color-placeholder-bg);
border-radius: 50%;
}
.header-title {
font-family: var(--font-primary);
font-size: 1.4em;
font-weight: 600; /* IBM Plex Sans SemiBold */
color: var(--color-text-primary);
}
.settings-icon {
width: 22px;
height: 22px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23e0e0e0'%3E%3Cpath d='M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12-.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-size: contain;
cursor: pointer;
opacity: 0.8;
}
/* Total Balance Section */
.total-balance-section {
padding: 25px 20px;
text-align: center;
border-bottom: 1px solid var(--color-border-primary);
flex-shrink: 0;
}
.total-balance-amount {
font-family: var(--font-mono); /* Mono for large number */
font-size: 2.3em;
font-weight: 700;
margin: 0;
color: var(--color-accent-green);
}
.total-balance-label {
font-family: var(--font-primary);
font-size: 0.85em;
color: var(--color-text-muted);
margin-top: 2px;
cursor: pointer; /* Make it look clickable */
}
.total-balance-label:hover {
color: var(--color-text-primary);
}
/* Tabs Section */
.tabs {
display: flex;
border-bottom: 1px solid var(--color-border-primary);
flex-shrink: 0;
position: relative;
z-index: 1;
}
.tab-button {
font-family: var(--font-primary);
flex-grow: 1;
padding: 14px;
text-align: center;
cursor: pointer;
background-color: transparent;
border: none;
color: var(--color-text-muted);
font-size: 0.95em;
font-weight: 500; /* IBM Plex Sans Medium */
border-bottom: 3px solid transparent;
transition: color 0.2s ease, border-bottom-color 0.2s ease;
}
.tab-button:hover {
color: var(--color-text-primary);
}
.tab-button.active {
color: var(--color-text-primary);
border-bottom: 3px solid var(--color-accent-purple);
}
.tab-content {
padding: 0px 20px 20px 20px;
flex-grow: 1;
min-height: 0;
max-height: calc(100vh - 220px);
overflow-y: auto;
}
.tab-pane { display: none; }
.tab-pane.active { display: block; }
/* Tokens Tab & Activity Tab Styles */
.list-item {
display: flex;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid var(--color-border-secondary);
}
.list-item:last-child { border-bottom: none; }
.item-icon-placeholder {
width: 38px;
height: 38px;
background-color: #2c3040; /* Using a specific color, not var for contrast */
border-radius: 50%;
margin-right: 15px;
display: flex;
justify-content: center;
align-items: center;
font-family: var(--font-mono); /* Mono for symbols like ETH */
font-weight: 500;
font-size: 0.9em;
color: #c0c3d1; /* Using specific color */
flex-shrink: 0;
overflow: hidden; /* Prevents text overflow if symbol is too long */
}
.item-info {
flex-grow: 1;
min-width: 0; /* Prevents text overflow issues in flex children */
}
.item-name { /* Token Name, Activity Type like "Received", "Sent" */
font-family: var(--font-primary);
font-size: 1.05em;
font-weight: 500; /* IBM Plex Sans Medium */
margin: 0 0 3px 0;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-sub-info { /* "1.2345 ETH on Ethereum", "Price: $800.00" */
font-family: var(--font-primary); /* Sans for descriptive part */
font-size: 0.85em;
color: var(--color-text-muted);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-sub-info .mono { /* Span class for monospaced parts within sub-info */
font-family: var(--font-mono);
}
.item-value-right {
text-align: right;
flex-shrink: 0;
padding-left: 10px;
}
.item-usd-value { /* USD value of holding */
font-family: var(--font-mono); /* Mono for numerical USD value */
font-size: 1.05em;
font-weight: 500;
margin: 0 0 3px 0;
color: var(--color-text-primary);
}
/* Activity Tab Specifics */
.activity-direction { /* "Received ETH", "Sent USDC" */
font-family: var(--font-primary);
font-size: 1.05em;
font-weight: 500; /* IBM Plex Sans Medium */
margin: 0 0 3px 0;
}
.activity-direction.sent { color: var(--color-accent-red); }
.activity-direction.receive { color: var(--color-accent-green); } /* Ensure class name consistency with JS */
.activity-address, .activity-timestamp {
font-family: var(--font-primary); /* Sans for "From:", "To:" */
font-size: 0.8em;
color: var(--color-text-subtle);
margin: 0;
}
.activity-address .mono, .activity-timestamp .mono { /* For address itself and timestamp value */
font-family: var(--font-mono);
}
.activity-amount-right { /* "+0.5 ETH" */
font-family: var(--font-mono); /* Mono for amount + symbol */
font-size: 1.05em;
font-weight: 500;
}
.activity-amount-right.sent { color: var(--color-accent-red); }
.activity-amount-right.receive { color: var(--color-accent-green); }
/* NFT Grid Container */
.collectibles-grid {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 1rem;
padding-top: 20px;
width: 100%;
}
/* NFT Item Container */
.collectible-item-link {
text-decoration: none;
color: inherit;
display: block;
transition: transform 0.2s ease;
}
.collectible-item-link:hover {
transform: translateY(-2px);
}
.collectible-item {
position: relative;
border-radius: 12px;
overflow: hidden;
background: var(--color-bg-container);
border: 1px solid var(--color-border-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Image Container */
.collectible-image-container {
position: relative;
width: 100%;
padding-bottom: 100%; /* Creates a square aspect ratio */
background: var(--color-placeholder-bg);
}
.collectible-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Image Placeholder */
.collectible-image-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-family: var(--font-primary);
font-weight: 600;
font-size: 0.875rem;
}
/* NFT Info Container - Static (always visible) */
.collectible-info-static {
padding: 0.75rem;
background: var(--color-bg-container);
border-top: 1px solid var(--color-border-secondary);
}
/* NFT Info Container - Hover (keep for backwards compatibility but not used) */
.collectible-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.75rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
color: white;
opacity: 0;
transition: opacity 0.3s ease;
}
.collectible-item-link:hover .collectible-info {
opacity: 1;
}
/* NFT Text Styles */
.collectible-name {
font-family: var(--font-primary);
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-text-primary);
}
.collectible-collection {
font-family: var(--font-mono);
font-size: 0.8rem;
margin-bottom: 0;
opacity: 0.9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-text-muted);
}
.collectible-chain {
font-family: var(--font-primary);
font-size: 0.7rem;
opacity: 0.8;
color: white;
}
```
Run `ls` in your terminal. Your project directory `wallet-ui/` should now contain:
```bash theme={null}
wallet-ui/
├── server.js # Main application file with Express server
├── views/ # Directory for EJS templates
│ └── wallet.ejs # Main wallet UI template
├── public/ # Directory for static assets
│ └── styles.css # CSS styling for the wallet UI
├── package.json # Project configuration
├── package-lock.json # Dependency lock file (if `npm install` was run)
├── node_modules/ # Installed packages (if `npm install` was run)
└── .env # Your environment variables
```
Run `node server.js` in the terminal to start the server.
Visit `http://localhost:3001` to see the blank wallet.
If you encounter errors, ensure your `.env` file contains the correct `SIM_API_KEY` and that it is loaded correctly.
Also, verify the `walletAddress` in the URL is a valid EVM wallet address.
Check your terminal for any error messages from `server.js`.
Now, let's integrate the Sim API to fetch real data.
## Fetch and Show Token Balances
We will use the [Balances API](/evm/balances) to get realtime token balances for a given wallet address.
This endpoint provides comprehensive details about native and ERC20 tokens, including metadata and USD values across more than 60+ EVM chains.
First, let's create an async function in `server.js` to fetch these balances. Add this function before your `app.get('/')` route handler:
```javascript server.js (getWalletBalances) {7,9,14,28} theme={null}
async function getWalletBalances(walletAddress) {
if (!walletAddress) return []; // Return empty if no address
// Construct the query parameters
// metadata=url,logo fetches token URLs and logo images
const queryParams = `metadata=url,logo`;
const url = `https://api.sim.dune.com/v1/evm/balances/${walletAddress}?${queryParams}`;
try {
const response = await fetch(url, {
headers: {
'X-Sim-Api-Key': SIM_API_KEY, // Your API key from .env
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`API request failed with status ${response.status}: ${response.statusText}`, errorBody);
throw new Error(`API request failed: ${response.statusText}`);
}
const data = await response.json();
// The API returns JSON with a "balances" key. We return that directly.
return data.balances;
} catch (error) {
console.error("Error fetching wallet balances:", error.message);
return []; // Return empty array on error
}
}
```
This function creates the API request using Node's `fetch`.
It includes your `SIM_API_KEY` in the headers and sends a GET request to the `/v1/evm/balances/{address}` endpoint.
The Balances API gives you access to various [*URL query parameters*](/evm/balances) that you can include to modify the response.
We have included `metadata=url,logo` to include a token's URL and logo.
Next, modify your `app.get('/')` route handler in `server.js` to call `getWalletBalances` and pass the fetched tokens to your template:
```javascript server.js {13} theme={null}
app.get('/', async (req, res) => {
const {
walletAddress = '',
tab = 'tokens'
} = req.query;
let tokens = [];
let totalWalletUSDValue = 0;
let errorMessage = null;
if (walletAddress) {
try {
tokens = await getWalletBalances(walletAddress);
} catch (error) {
console.error("Error in route handler:", error);
errorMessage = "Failed to fetch wallet data. Please try again.";
// tokens will remain empty, totalWalletUSDValue will be 0
}
}
res.render('wallet', {
walletAddress: walletAddress,
currentTab: tab,
totalWalletUSDValue: `$0.00`, // We'll calculate this in the next section
tokens: tokens,
activities: [], // Placeholder for Guide 2
collectibles: [], // Placeholder for Guide 3
errorMessage: errorMessage
});
});
```
We've updated the route to:
1. Call `getWalletBalances` if a `walletAddress` is provided.
2. Pass the retrieved `balances` to the `wallet.ejs` template.
The `views/wallet.ejs` file you created earlier is already set up to display these tokens.
Restart your server with `node server.js` and refresh your browser, providing a `walletAddress` in the URL.
For example: [`http://localhost:3001/?walletAddress=0xd8da6bf26964af9d7eed9e03e53415d37aa96045`](http://localhost:3001/?walletAddress=0xd8da6bf26964af9d7eed9e03e53415d37aa96045)
You should now see the wallet populated with token balances, logos, prices for each token, and how much of that token the wallet holds.
## Format Balances
The Balances API returns token amounts in their smallest denomination. This will be in wei for ETH-like tokens.
To display these amounts in a user-friendly way, like `1.23` ETH instead of `1230000000000000000` wei, we need to adjust the amount using the token's `decimals` property, which is also returned from the Balances API.
We can add a new property, `balanceFormatted`, to each token object.
Modify your `getWalletBalances` function in `server.js` as follows. The main change is mapping over `data.balances` to add the `balanceFormatted` property:
```javascript server.js (getWalletBalances with formatting) {24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38} theme={null}
async function getWalletBalances(walletAddress) {
if (!walletAddress) return [];
const queryParams = `metadata=url,logo`;
const url = `https://api.sim.dune.com/v1/evm/balances/${walletAddress}?${queryParams}`;
try {
const response = await fetch(url, {
headers: {
'X-Sim-Api-Key': SIM_API_KEY,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`API request failed with status ${response.status}: ${response.statusText}`, errorBody);
throw new Error(`API request failed: ${response.statusText}`);
}
const data = await response.json();
// Return formatted values and amounts
return (data.balances || []).map(token => {
// 1. Calculate human-readable token amount
const numericAmount = parseFloat(token.amount) / Math.pow(10, parseInt(token.decimals));
// 2. Get numeric USD value
const numericValueUSD = parseFloat(token.value_usd);
// 3. Format using numbro
const valueUSDFormatted = numbro(numericValueUSD).format('$0,0.00');
const amountFormatted = numbro(numericAmount).format('0,0.[00]A');
return {
...token,
valueUSDFormatted,
amountFormatted
};
}).filter(token => token.symbol !== 'RTFKT'); // Removing Spam Tokens. Add more if you like.
} catch (error) {
console.error("Error fetching wallet balances:", error.message);
return [];
}
}
```
Now, each token object returned by `getWalletBalances` will include a `balanceFormatted` string, which our EJS template (`views/wallet.ejs`) already uses: `<%= token.balanceFormatted || token.amount %>`.
Restart the server and refresh the browser. You will now see formatted balances.
## Calculate Total Portfolio Value
The wallet's total value at the top of the UI still says `$0.00`.
Let's calculate the total USD value of the wallet and properly show it.
The Balances API provides a `value_usd` field with each token.
This field represents the total U.S. dollar value of the wallet's entire holding for that specific token.
Let's modify the `app.get('/')` route handler to iterate through the fetched tokens and sum their individual `value_usd` to calculate the `totalWalletUSDValue`.
```javascript server.js (app.get('/') with total value calculation) {16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 37} theme={null}
app.get('/', async (req, res) => {
const {
walletAddress = '',
tab = 'tokens'
} = req.query;
let tokens = [];
let totalWalletUSDValue = 0; // Will be updated
let errorMessage = null;
if (walletAddress) {
try {
tokens = await getWalletBalances(walletAddress);
// Calculate the total USD value from the fetched tokens
if (tokens && tokens.length > 0) {
tokens.forEach(token => {
let individualValue = parseFloat(token.value_usd);
if (!isNaN(individualValue)) {
totalWalletUSDValue += individualValue;
}
});
}
totalWalletUSDValue = numbro(totalWalletUSDValue).format('$0,0.00');
} catch (error) {
console.error("Error in route handler:", error);
errorMessage = "Failed to fetch wallet data. Please try again.";
// tokens will remain empty, totalWalletUSDValue will be 0
}
}
res.render('wallet', {
walletAddress: walletAddress,
currentTab: tab,
totalWalletUSDValue: totalWalletUSDValue, // Pass the calculated total
tokens: tokens,
activities: [], // Placeholder for Guide 2
collectibles: [], // Placeholder for Guide 3
errorMessage: errorMessage
});
});
```
We use the `reduce` method to iterate over the `tokens` array.
For each `token`, we access its `value_usd` property, parse it as a float, and add it to the running `sum`.
The calculated `totalWalletUSDValue` is then formatted to two decimal places and passed to the template.
The `views/wallet.ejs` template already has `
<%= totalWalletUSDValue %>
`, so it will display the calculated total correctly.
Restart your server and refresh the browser page with a wallet address.
You should now see the total wallet value at the top of the UI accurately reflecting the sum of all token balance USD values.
## Add Historical Price Tracking
Now let's improve the wallet by showing price movement over time. Historical price context helps users understand how their tokens are gaining or losing value. The [Balances API](/evm/balances) can return historical price points when you include the `historical_prices` query parameter, which lets you display price changes for any hour offset from 1 to 24 hours ago.
To learn more about historical prices, visit the [historical prices section](/evm/balances#historical-prices) in the Balances API documentation.
### Understanding historical price data
When you pass the `historical_prices` query parameter, the API accepts a comma separated list of hour offsets such as `1,6,24`. Each token in the response will then include a `historical_prices` array. Each entry contains an `offset_hours` value that indicates how many hours ago the price was recorded and a `price_usd` value that represents the token price at that time.
### Create price utilities
Add a small utility module to calculate percentage changes and format them for display. Create `utils/prices.js` and include the following code.
```javascript utils/prices.js theme={null}
export const pct = (curr, past) =>
curr == null || past == null || past === 0 ? null : ((curr - past) / past) * 100;
export const priceAt = (hist, h) =>
Array.isArray(hist) ? (hist.find(p => p && p.offset_hours === h)?.price_usd) : undefined;
export const change1h = (price, hist) => pct(price, priceAt(hist, 1));
export const change6h = (price, hist) => pct(price, priceAt(hist, 6));
export const change24h = (price, hist) => pct(price, priceAt(hist, 24));
export const formatSignedPercent = (x) =>
x == null ? '—' : `${x >= 0 ? '+' : ''}${x.toFixed(2)}%`;
```
These utility functions are necessary because the API returns only raw prices at specific times. You need these utilities to convert the price differences into percentage changes for display.
### Request historical prices in your balances call
Update `getWalletBalances` to optionally include historical prices. Only request this data when viewing the tokens tab to keep responses fast.
```javascript server.js (getWalletBalances with historical prices) [expandable] {5,9,16,17,18,19,20,21,22,23,24} theme={null}
/**
* Fetch wallet balances. Optionally include historical prices for deltas.
* @param {string} walletAddress
* @param {{ includeHistoricalPrices?: boolean }} [options]
*/
async function getWalletBalances(walletAddress, options = {}) {
const { includeHistoricalPrices = false } = options;
if (!walletAddress) return [];
// Construct the query parameters
// metadata=url,logo fetches token URLs and logo images
// exclude_spam_tokens filters out known spam tokens
const baseParams = new URLSearchParams({
'metadata': 'url,logo'
});
if (includeHistoricalPrices) {
baseParams.set('historical_prices', '1,6,24');
}
const queryParams = `${baseParams.toString()}&exclude_spam_tokens`;
const url = `https://api.sim.dune.com/v1/evm/balances/${walletAddress}?${queryParams}`;
try {
const response = await fetch(url, {
headers: {
'X-Sim-Api-Key': SIM_API_KEY,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`API request failed with status ${response.status}: ${response.statusText}`, errorBody);
throw new Error(`API request failed: ${response.statusText}`);
}
const data = await response.json();
return (data.balances || []).map(token => {
const numericAmount = parseFloat(token.amount) / Math.pow(10, parseInt(token.decimals));
const numericValueUSD = parseFloat(token.value_usd);
const valueUSDFormatted = numbro(numericValueUSD).format('$0,0.00');
const amountFormatted = numbro(numericAmount).format('0,0.[00]A');
return {
...token,
valueUSDFormatted,
amountFormatted
};
}).filter(token => token.symbol !== 'RTFKT');
} catch (error) {
console.error("Error fetching wallet balances:", error.message);
return [];
}
}
```
This change adds an optional `includeHistoricalPrices` parameter, conditionally appends `historical_prices=1,6,24` to the request, and preserves the existing formatting behavior.
### Make price utilities available to the template
Import the utilities in `server.js`, request historical prices when the tokens tab is active, and pass the helpers into the template render context.
```javascript server.js (imports and route updates) [expandable] {4,18,30,49} theme={null}
import express from 'express';
import numbro from 'numbro';
import dotenv from 'dotenv';
import * as priceUtils from './utils/prices.js';
import path from 'path';
import { fileURLToPath } from 'url';
// ... existing setup code ...
app.get('/', async (req, res) => {
const {
walletAddress = '',
tab = 'tokens'
} = req.query;
let tokens = [];
let totalWalletUSDValue = 0;
let errorMessage = null;
if (walletAddress) {
try {
const includeHistorical = tab === 'tokens';
tokens = await getWalletBalances(walletAddress, { includeHistoricalPrices: includeHistorical });
if (tokens && tokens.length > 0) {
tokens.forEach(token => {
const individualValue = parseFloat(token.value_usd);
if (!isNaN(individualValue)) {
totalWalletUSDValue += individualValue;
}
});
}
totalWalletUSDValue = numbro(totalWalletUSDValue).format('$0,0.00');
} catch (error) {
console.error("Error in route handler:", error);
errorMessage = "Failed to fetch wallet data. Please try again.";
}
}
res.render('wallet', {
walletAddress: walletAddress,
currentTab: tab,
totalWalletUSDValue: totalWalletUSDValue,
tokens: tokens,
activities: [],
collectibles: [],
errorMessage: errorMessage,
priceUtils
});
});
```
### Show twenty four hour change in the UI
Add a compact badge below the amount display for each token. Insert this snippet in `views/wallet.ejs` within the tokens list item, after the amount text.
```ejs views/wallet.ejs (price change badges) theme={null}
<% if (typeof token.price_usd === 'number' && Array.isArray(token.historical_prices)) { %>
<%
const d24 = priceUtils.change24h(token.price_usd, token.historical_prices);
const d24Text = priceUtils.formatSignedPercent(d24);
const badgeClass = d24 == null ? 'neutral' : (d24 >= 0 ? 'pos' : 'neg');
%>
<% if (d24 != null) { %>
<%= d24Text %> 24h
<% } %>
<% } %>
```
### Style the badges
Add these styles to `public/styles.css` so the badges match the existing design system.
```css public/styles.css (price change styles) [expandable] theme={null}
.price-delta-badges {
display: flex;
gap: 6px;
justify-content: flex-end;
margin-top: 4px;
}
.delta-badge {
font-family: var(--font-mono);
font-size: 0.75em;
padding: 2px 6px;
border-radius: 999px;
border: 1px solid var(--color-border-secondary);
background: rgba(255,255,255,0.03);
color: var(--color-text-muted);
}
.delta-badge.pos {
color: var(--color-accent-green);
border-color: rgba(80, 227, 194, 0.5);
}
.delta-badge.neg {
color: var(--color-accent-red);
border-color: rgba(255, 120, 117, 0.5);
}
.delta-badge.neutral {
color: var(--color-text-muted);
}
```
Historical prices are not available for every token. If the API does not return historical data for a token, the badge will not appear and the layout remains consistent.
## Conclusion
You have successfully set up the basic structure of your multichain wallet and integrated Sim APIs `Balances API` endpoint to display realtime token balances and total portfolio value.
In the next guide, [Add Account Activity](/evm/add-account-activity), we will enhance this wallet by adding a transaction history feature in the UI using the [Activity API](/evm/activity).
# Build a Top Token Holders Tracker Bot
Source: https://docs.sim.dune.com/evm/build-a-top-holders-tracker-bot
Create a Telegram bot that monitors and alerts you when top token holders move funds using Sim's Subscriptions API
In this guide, you'll build a Telegram bot that tracks the top holders of popular ERC20 tokens and sends real-time alerts when they move funds.
We'll use the [Token Holders API](/evm/token-holders) to identify the top holders for each token, then set up the [Subscriptions API](/evm/subscriptions) to receive instant webhook notifications when those wallets move funds.
Access the complete source code for this bot on GitHub
Interact with the finished bot on Telegram
## Prerequisites
Before you begin, ensure you have:
* **Node.js** - v22 or later
* **Sim API Key** - [Get your API key](https://sim.dune.com)
* **Telegram Bot Token** - Create one via [@BotFather](https://t.me/botfather)
* **Supabase Account** - [Create a free account](https://supabase.com)
* **ngrok Account** - [Create a free account](https://dashboard.ngrok.com) for local development
## Features
By the end of this guide, your top token holders tracker will:
1. **Identify Top Token Holders** - Read a CSV of popular tokens exported from Dune and find their top holders
2. **Monitor Balance Changes** - Set up subscription webhooks to receive real-time balance change notifications for those wallets
3. **Send Telegram Alerts** - Deliver formatted Telegram messages with transaction details
4. **Manage Subscriptions** - Pause, resume, and view your webhook subscriptions
## Project Setup
Let's initialize the project.
```bash theme={null}
mkdir top-holders-tracker
cd top-holders-tracker
npm init -y
```
We need Express, the Postgres client, and a CSV parser to handle the Dune export.
```bash theme={null}
npm install express postgres csv-parse
```
1. Go to [supabase.com/dashboard](https://supabase.com/dashboard) and click **Create a new project**
2. Fill in your project details (project name, database password, region) and click **Create new project**
3. Once created, click **Connect** in the top navigation bar
4. Select the **Connection string** tab, then choose type **URI**
5. Select **Transaction pooler** and copy the connection string
6. Replace `[YOUR-PASSWORD]` with your [URL-encoded](https://www.urlencoder.org/) database password
The Transaction pooler connection is recommended for serverless deployments like Vercel.
Create a `.env` file in your project root:
```env .env theme={null}
SIM_API_KEY=your_sim_api_key_here
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
WEBHOOK_BASE_URL=https://your-deployed-url.com
DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
```
If your database password contains special characters (`!`, `@`, `#`, etc.), URL-encode them. For example, `@` becomes `%40`.
Update your `package.json` to enable ES Modules and add a start script:
```json package.json theme={null}
{
"type": "module",
"scripts": {
"dev": "node --env-file=.env index.js",
"start": "node --env-file=.env index.js"
}
}
```
Create the entry point `index.js`. We initialize the database tables on startup.
```javascript index.js expandable theme={null}
import express from "express";
import postgres from "postgres";
import { setTimeout } from "node:timers/promises";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parse } from "csv-parse/sync";
// ES Module directory resolution
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 1. Environment Setup
const SIM_API_KEY = process.env.SIM_API_KEY || "";
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || "";
const WEBHOOK_BASE_URL = process.env.WEBHOOK_BASE_URL || "";
const PORT = process.env.PORT || 3001;
const DATABASE_URL = process.env.DATABASE_URL || "";
if (!SIM_API_KEY || !TELEGRAM_BOT_TOKEN || !DATABASE_URL) {
console.error("Missing required environment variables");
process.exit(1);
}
// 2. Database Setup (PostgreSQL via Supabase)
const sql = postgres(DATABASE_URL);
// Initialize Tables
async function initDatabase() {
await sql`
CREATE TABLE IF NOT EXISTS top_holders (
id SERIAL PRIMARY KEY,
token_address TEXT,
chain_id INTEGER,
symbol TEXT,
blockchain TEXT,
holders_json TEXT,
UNIQUE(token_address, chain_id)
)
`;
await sql`
CREATE TABLE IF NOT EXISTS subscribers (
chat_id TEXT PRIMARY KEY,
subscribed_at TEXT
)
`;
await sql`
CREATE TABLE IF NOT EXISTS webhooks (
id TEXT PRIMARY KEY,
token_address TEXT,
chain_id INTEGER,
active INTEGER DEFAULT 1
)
`;
}
// Initialize database when module loads
initDatabase().catch(err => {
console.error("Failed to initialize database:", err);
});
// 3. Express Setup
const app = express();
app.use(express.json()); // Parse JSON bodies
app.get("/health", (req, res) => {
res.json({ ok: true });
});
// ... route definitions go here (added in subsequent sections) ...
// Start server for local development
// For Vercel deployment, export the app instead (see Deploy section)
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
```
The application creates tables automatically on startup, but you need to enable Row Level Security (RLS) policies in the Supabase dashboard. Go to **SQL Editor** and run:
```sql expandable theme={null}
-- ============================================
-- 1. ENABLE ROW LEVEL SECURITY (Required)
-- ============================================
ALTER TABLE top_holders ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscribers ENABLE ROW LEVEL SECURITY;
ALTER TABLE webhooks ENABLE ROW LEVEL SECURITY;
-- ============================================
-- 2. CREATE PERMISSIVE POLICIES (Required)
-- ============================================
-- Drop existing policies if they exist (prevents errors on re-run)
DROP POLICY IF EXISTS "Allow all operations on top_holders" ON top_holders;
DROP POLICY IF EXISTS "Allow all operations on subscribers" ON subscribers;
DROP POLICY IF EXISTS "Allow all operations on webhooks" ON webhooks;
-- Create new policies
CREATE POLICY "Allow all operations on top_holders" ON top_holders
FOR ALL USING (true) WITH CHECK (true);
CREATE POLICY "Allow all operations on subscribers" ON subscribers
FOR ALL USING (true) WITH CHECK (true);
CREATE POLICY "Allow all operations on webhooks" ON webhooks
FOR ALL USING (true) WITH CHECK (true);
-- ============================================
-- 3. VERIFY SETUP (Run to check everything)
-- ============================================
SELECT
tablename,
rowsecurity as "RLS Enabled"
FROM pg_tables
WHERE schemaname = 'public'
AND tablename IN ('top_holders', 'subscribers', 'webhooks');
```
These RLS policies allow your server full access while keeping the database secure.
## Build the Bot
With the project set up, we'll now implement the core functionality: loading token data, fetching top holders from Sim's Token Holders API, setting up webhook subscriptions, and wiring everything to Telegram.
We need a list of popular tokens to monitor. Create a file called `tokens.csv` in your project root with the following content:
```csv tokens.csv expandable theme={null}
blockchain,symbol,contract_address,rank,volume_24h
abstract,WETH,0x3439153eb7af838ad19d56e1571fbd09333c2809,1,541879
arbitrum,WETH,0x82af49447d8a07e3bd95bd0d56f35241523fbab1,1,215631888
arbitrum,USDC,0xaf88d065e77c8cc2239327c5edb3a432268e5831,2,202791327
avalanche_c,USDC,0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e,1,27753674
base,WETH,0x4200000000000000000000000000000000000006,1,384703859
base,USDC,0x833589fcd6edb6e08f4c7c32d4f71b54bda02913,2,319573649
berachain,WETH,0x2f6f07cdcf3588944bf4c42ac74ff24bf56e7590,1,1241956
bnb,USDC,0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d,1,141562139
celo,USDC,0xceba9300f2b948710d2653dd7b07f33a8b32118c,1,26675517
celo,WETH,0xd221812de1bd094f35587ee8e174b07b6167d9af,2,355806
ethereum,USDC,0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48,1,401923545
ethereum,WETH,0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2,2,399397077
gnosis,WETH,0x6a023ccd1ff6f2045c3309768ead9e68f978f6e1,1,319235
ink,WETH,0x4200000000000000000000000000000000000006,1,632429
ink,USDC,0x2d270e6886d130d724215a266106e6832161eaed,2,181985
linea,WETH,0xe5d7c2a44ffddf6b295a15c148167daaaf5cf34f,1,3931486
linea,USDC,0x176211869ca2b568f2a7d4ee941e073a821ee1ff,2,3639358
mantle,WETH,0xdeaddeaddeaddeaddeaddeaddeaddeaddead1111,1,2213930
mantle,USDC,0x09bc4e0d864854c6afb6eb9a9cdf58ac190d0df9,2,173356
monad,USDC,0x754704bc059f8c67012fed69bc8a327a5aafb603,1,2548822
monad,WETH,0xee8c0e9f1bffb4eb878d8f15f368a02a35481242,2,178282
optimism,USDC,0x0b2c639c533813f4aa9d7837caf62653d097ff85,1,9659093
optimism,WETH,0x4200000000000000000000000000000000000006,2,8755339
plasma,WETH,0x9895d81bb462a195b4922ed7de0e3acd007c32cb,1,1185216
polygon,USDC,0x3c499c542cef5e3811e1192ce70d8cc03d5c3359,1,13687294
polygon,WETH,0x7ceb23fd6bc0add59e62ac25578270cff1b9f619,2,5280750
scroll,USDC,0x06efdbff2a14a7c8e15944d1f4a48f9f95f663a4,1,157489
scroll,WETH,0x5300000000000000000000000000000000000004,2,98385
sei,USDC,0xe15fc38f6d8c56af07bbcbe3baf5708a2bf42392,1,7740957
sei,WETH,0x160345fc359604fc6e70e3c5facbde5f7a9342d8,2,73158
sonic,WETH,0x50c42deacd8fc9773493ed674b675be577f2634b,1,661442
unichain,USDC,0x078d782b760474a361dda0af3839290b0ef57ad6,1,19398150
unichain,WETH,0x4200000000000000000000000000000000000006,2,4860719
zksync,WETH,0x5aea5775959fbc2557cc8789bc1bf90a239d9a91,1,293777
```
This CSV contains the top WETH and USDC tokens by volume across chains. To verify or explore a larger dataset, see [this Dune query](https://dune.com/queries/6355954).
### Load Token Data
We need to read this CSV file and map the blockchain names (e.g., "ethereum") to their respective Chain IDs (e.g., 1).
Add this logic to `index.js`:
```javascript index.js expandable theme={null}
// Helper to map Dune blockchain names to Chain IDs
function getChainId(blockchain) {
const map = {
abstract: 2741,
arbitrum: 42161,
avalanche_c: 43114,
base: 8453,
berachain: 80094,
bnb: 56,
celo: 42220,
ethereum: 1,
gnosis: 100,
ink: 57073,
linea: 59144,
mantle: 5000,
monad: 10143,
optimism: 10,
plasma: 1273227453,
polygon: 137,
scroll: 534352,
sei: 1329,
sonic: 146,
unichain: 130,
zksync: 324,
};
return map[blockchain.toLowerCase()] || null;
}
function loadTokensFromCSV() {
try {
const fileContent = fs.readFileSync(path.join(__dirname, "tokens.csv"), "utf-8");
const records = parse(fileContent, {
columns: true, // Auto-detect headers
skip_empty_lines: true,
trim: true,
});
return records;
} catch (error) {
console.error("Error loading tokens.csv. Make sure the file exists.", error);
return [];
}
}
```
Now we'll identify the top holder addresses for each token using Sim's Token Holders API and store them in our database.
### Fetch Holders for a Token
```javascript index.js expandable theme={null}
async function fetchTokenHolders(tokenAddress, chainId, limit = 3) {
const url = `https://api.sim.dune.com/v1/evm/token-holders/${chainId}/${tokenAddress}?limit=${limit}`;
const response = await fetch(url, {
headers: { "X-Sim-Api-Key": SIM_API_KEY },
});
if (!response.ok) {
console.warn(`Failed to fetch holders for ${tokenAddress}: ${response.status}`);
return [];
}
const data = await response.json();
return data.holders || [];
}
```
### Store Top Holder Addresses
This function iterates through the CSV records, fetches top holders, and saves them to the database.
```javascript index.js expandable theme={null}
async function fetchAllTopHolders() {
const tokens = loadTokensFromCSV();
let totalHolders = 0;
console.log(`Processing ${tokens.length} tokens from CSV...`);
for (const token of tokens) {
const chainId = getChainId(token.blockchain);
// Skip if we don't support this chain
if (!chainId) continue;
const holders = await fetchTokenHolders(token.contract_address, chainId);
if (holders.length > 0) {
const tokenAddress = token.contract_address.toLowerCase();
const holdersJson = JSON.stringify(holders);
await sql`
INSERT INTO top_holders (token_address, chain_id, symbol, blockchain, holders_json)
VALUES (${tokenAddress}, ${chainId}, ${token.symbol}, ${token.blockchain}, ${holdersJson})
ON CONFLICT (token_address, chain_id)
DO UPDATE SET symbol = ${token.symbol}, blockchain = ${token.blockchain}, holders_json = ${holdersJson}
`;
totalHolders += holders.length;
console.log(`Found ${holders.length} top holders for ${token.symbol}`);
}
// Respect rate limits: 4 requests per second
await setTimeout(250);
}
return { totalHolders, tokensProcessed: tokens.length };
}
// Endpoint to trigger this manually
app.post("/setup/fetch-holders", async (req, res) => {
try {
const result = await fetchAllTopHolders();
res.json({ ok: true, ...result });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
```
We'll use the Sim Subscriptions API (`/beta/subscriptions`) to register webhooks. We use the `balances` subscription type.
### Create a Webhook
```javascript index.js expandable theme={null}
async function createWebhook(config) {
const url = "https://api.sim.dune.com/beta/evm/subscriptions/webhooks";
const response = await fetch(url, {
method: "POST",
headers: {
"X-Sim-Api-Key": SIM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to create webhook: ${error}`);
}
return response.json();
}
```
### Create Webhooks for All Top Holders
Iterate through our `top_holders` table and create a subscription for each.
```javascript index.js expandable theme={null}
async function createWebhooksForTopHolders() {
const webhookIds = [];
const rows = await sql`SELECT * FROM top_holders`;
for (const row of rows) {
const holders = JSON.parse(row.holders_json);
const addresses = holders.map((h) => h.wallet_address).filter(Boolean);
if (addresses.length === 0) continue;
const webhook = await createWebhook({
name: `Top Holders Tracker - ${row.symbol} on ${row.blockchain}`,
url: `${WEBHOOK_BASE_URL}/balances`,
type: "balances",
addresses: addresses,
chain_ids: [row.chain_id],
token_address: row.token_address,
});
if (webhook?.id) {
await sql`
INSERT INTO webhooks (id, token_address, chain_id)
VALUES (${webhook.id}, ${row.token_address}, ${row.chain_id})
ON CONFLICT (id) DO UPDATE SET token_address = ${row.token_address}, chain_id = ${row.chain_id}
`;
webhookIds.push(webhook.id);
console.log(`Created webhook for ${row.symbol}`);
}
await setTimeout(250);
}
return { webhooksCreated: webhookIds.length, webhookIds };
}
// Endpoint to trigger webhook creation
app.post("/setup/create-webhooks", async (req, res) => {
try {
const result = await createWebhooksForTopHolders();
res.json({ ok: true, ...result });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
```
When a top holder moves funds, Sim sends a POST request to your webhook URL.
### Process Incoming Webhooks
Add the `/balances` route to your Express app.
```javascript index.js expandable theme={null}
app.post("/balances", async (req, res) => {
const balanceChanges = req.body.balance_changes || [];
// Sim sends the Chain ID in the header
const chainId = parseInt(req.headers["dune-webhook-chain-id"] || "1");
const processedTxs = new Set();
for (const change of balanceChanges) {
// Deduplicate by transaction hash within this batch
if (processedTxs.has(change.transaction_hash)) continue;
processedTxs.add(change.transaction_hash);
// Skip small transactions (e.g., less than $100,000)
if (change.value_delta_usd < 100000) continue;
// Format and send notification
const message = formatBalanceMessage(change, chainId);
await broadcastToSubscribers(message);
}
res.json({ ok: true, processed: processedTxs.size });
});
```
### Manage Subscribers
We use PostgreSQL to persist chat IDs.
```javascript index.js theme={null}
async function addSubscriber(chatId) {
const subscribedAt = new Date().toISOString();
await sql`
INSERT INTO subscribers (chat_id, subscribed_at)
VALUES (${chatId}, ${subscribedAt})
ON CONFLICT (chat_id) DO NOTHING
`;
}
async function getAllSubscribers() {
const rows = await sql`SELECT chat_id FROM subscribers`;
return rows.map(r => r.chat_id);
}
```
### Send Messages
```javascript index.js expandable theme={null}
async function sendTelegramMessage(text, chatId) {
const url = `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId,
text: text,
parse_mode: "Markdown",
disable_web_page_preview: true,
}),
});
return response.ok;
}
async function broadcastToSubscribers(text) {
const subscribers = await getAllSubscribers();
for (const chatId of subscribers) {
await sendTelegramMessage(text, chatId);
}
}
```
### Format Alert Messages
```javascript index.js expandable theme={null}
function formatNumber(value) {
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(value);
}
function getExplorerLink(txHash, chainId) {
const explorers = {
1: "https://etherscan.io/tx/",
10: "https://optimistic.etherscan.io/tx/",
56: "https://bscscan.com/tx/",
137: "https://polygonscan.com/tx/",
8453: "https://basescan.org/tx/",
42161: "https://arbiscan.io/tx/",
43114: "https://snowtrace.io/tx/",
};
return `${explorers[chainId] || explorers[1]}${txHash}`;
}
function formatBalanceMessage(change, chainId) {
const usdValue = change.value_delta_usd || 0;
const symbol = change.asset?.symbol || "???";
const decimals = change.asset?.decimals || 18;
const direction = change.direction;
// Calculate token amount
const rawAmount = parseFloat(change.amount_delta);
const amount = rawAmount / Math.pow(10, decimals);
// Determine emoji count based on value
let emojiCount = 1;
if (usdValue >= 10_000_000) emojiCount = 5;
else if (usdValue >= 1_000_000) emojiCount = 4;
else if (usdValue >= 500_000) emojiCount = 3;
else if (usdValue >= 100_000) emojiCount = 2;
const emoji = "🚨 ".repeat(emojiCount).trim();
const directionEmoji = direction === "in" ? "📥" : "📤";
const directionText = direction === "in" ? "received" : "sent";
const holder = change.subscribed_address;
const holderShort = `${holder.slice(0, 6)}...${holder.slice(-4)}`;
const txLink = getExplorerLink(change.transaction_hash, chainId);
return `${emoji} ${directionEmoji} *${formatNumber(amount)} ${symbol}* ($${formatNumber(usdValue)}) ${directionText}
Holder: \`${holderShort}\`
[View Transaction](${txLink}) · Powered by [Sim APIs](https://sim.dune.com)`;
}
```
### Handle Telegram Commands
Add the endpoint for Telegram updates.
```javascript index.js expandable theme={null}
app.post("/telegram/webhook", async (req, res) => {
const body = req.body;
const message = body.message;
if (message?.text) {
const chatId = message.chat.id.toString();
const text = message.text;
if (text.startsWith("/start")) {
await addSubscriber(chatId);
await sendTelegramMessage(
"📊 *Welcome to Top Holders Tracker!*\n\n" +
"You're now subscribed to top holder alerts.\n\n" +
"Commands:\n/start - Subscribe\n/status - Check subscription",
chatId
);
} else if (text.startsWith("/status")) {
const subscribers = await getAllSubscribers();
const isSubscribed = subscribers.includes(chatId);
await sendTelegramMessage(
isSubscribed
? "✅ You're subscribed to top holder alerts!"
: "❌ Not subscribed. Send /start to subscribe.",
chatId
);
}
}
res.json({ ok: true });
});
```
The Subscriptions API provides endpoints to list and update your webhooks.
### Pause and Resume
```javascript index.js expandable theme={null}
async function listWebhooks() {
const url = "https://api.sim.dune.com/beta/evm/subscriptions/webhooks";
const response = await fetch(url, {
headers: { "X-Sim-Api-Key": SIM_API_KEY },
});
return response.json();
}
async function updateWebhookStatus(webhookId, active) {
const url = `https://api.sim.dune.com/beta/evm/subscriptions/webhooks/${webhookId}`;
const response = await fetch(url, {
method: "PATCH",
headers: {
"X-Sim-Api-Key": SIM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ active }),
});
return response.ok;
}
// Management Endpoints
app.get("/setup/view-webhooks", async (req, res) => {
try {
const data = await listWebhooks();
res.json({ ok: true, ...data });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post("/setup/pause-webhooks", async (req, res) => {
const data = await listWebhooks();
let paused = 0;
for (const webhook of data.webhooks || []) {
if (webhook.active) {
await updateWebhookStatus(webhook.id, false);
paused++;
await setTimeout(250);
}
}
res.json({ ok: true, paused });
});
app.post("/setup/resume-webhooks", async (req, res) => {
const data = await listWebhooks();
let resumed = 0;
for (const webhook of data.webhooks || []) {
if (!webhook.active) {
await updateWebhookStatus(webhook.id, true);
resumed++;
await setTimeout(250);
}
}
res.json({ ok: true, resumed });
});
```
## Deploy and Configure
Run your Express app:
```bash theme={null}
npm start
```
If developing locally, you can expose a port to the public internet so that Sim Subscriptions can call it. [ngrok](https://ngrok.com) is a utility that helps with that. You need an account on [dashboard.ngrok.com](https://dashboard.ngrok.com) to use it.
```bash theme={null}
ngrok http 3001
```
Copy the provided URL (e.g., `https://abcd-123.ngrok-free.app`) and update `WEBHOOK_BASE_URL` in your `.env`.
Tell Telegram to send updates to your server:
```bash theme={null}
curl -X POST "https://api.telegram.org/bot/setWebhook" \
-H "Content-Type: application/json" \
-d '{"url": "/telegram/webhook"}'
```
Since our server is running, we can trigger the setup process using CURL or Postman:
```bash theme={null}
# 1. Fetch top holder addresses from your tokens.csv
curl -X POST https://your-url.com/setup/fetch-holders
# 2. Create webhooks for monitoring
curl -X POST https://your-url.com/setup/create-webhooks
```
Open your Telegram bot and send `/start` to begin receiving top holder alerts.
## Conclusion
You've built a top token holders tracker bot that monitors large token holders in real-time using Sim's Subscriptions API. The key components we covered:
* **Node.js & Express** - A lightweight server for handling webhooks and Telegram commands.
* **CSV Integration** - Parsing Dune Analytics data to drive your bot's logic.
* **Supabase PostgreSQL** - Cloud-hosted database for storing top holders and managing subscribers.
* **Sim APIs** - Using Token Holders and Subscriptions endpoints to power the logic.
For production use, deploy your server to a platform like Vercel, Railway, or a VPS. Configuration for serverless deployment is beyond the scope of this guide, but you can reference the [complete source code](https://github.com/dericksozo/sim-top-token-holders-tracker-telegram-bot) for a Vercel-ready setup.
For more information, visit the [Sim API Documentation](https://docs.sim.dune.com) or explore other [Subscriptions API features](https://docs.sim.dune.com/evm/subscriptions).
# Collectibles
Source: https://docs.sim.dune.com/evm/collectibles
/openapi.json GET /v1/evm/collectibles/{address}
Retrieve EVM compatiable NFTs (ERC721 and ERC1155) that include token identifiers, token standard, chain information, balance, and basic token attributes.
The Collectibles API provides information about NFTs (ERC721 and ERC1155 tokens) owned by a specific address on supported EVM blockchains, with built-in spam filtering.
## Compute Unit Cost
The Collectibles endpoint’s CU cost equals the number of chains you include via the `chain_ids` query parameter. If you omit `chain_ids`, we use the endpoint’s chains tagged `default` (currently chains, subject to change). See the [Supported Chains](/evm/supported-chains#tags) page to learn about chain tags.
For example, `?chain_ids=1,8453,137` processes three chains and currently consumes three CUs. Omitting `chain_ids` uses the default set at request time and consumes CUs (equal to the size of that set).
See the [Compute Units](/compute-units) page for detailed information.
## Spam Filtering
By default, spam filtering for collectibles is enabled. Use `filter_spam=false` to include spam collectibles in the response. You can omit the parameter or set `filter_spam=true` to keep spam filtering enabled.
We provide spam scores to explain how a collectible is classified as spam or not. The `is_spam` field is always included in each entry. Spam scores are *off* by default and require `show_spam_scores=true` to include `spam_score` and `explanations`.
Setting `show_spam_scores=true` does not disable filtering; use `filter_spam=false` if you want spam entries (and their scores) returned.
Scores are computed as a weighted average across signals.
These include whether the collection has been flagged by external providers, trade volume since launch, and the number of unique buyers and sellers.
See the table below for the full list.
| Feature | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `trade_volume` | This is the total number of trades since collection launch. Very low activity can indicate spam. Collections launched in the last 7 days are excluded from this signal. |
| `unique_buyers` | This is the count of distinct buyers. Low buyer diversity can indicate inorganic activity. |
| `unique_sellers` | This is the count of distinct sellers. Low seller diversity can indicate inorganic activity. |
| `externally_flagged` | Whether the collection has been flagged as spam by third-party providers (for example, Alchemy). `true` increases the spam likelihood. |
| `uri_non_ipfs` | Whether metadata URIs are non-IPFS. Certain patterns can correlate with spam when combined with other signals. |
| `suspicious_words` | Whether names or symbols include suspicious terms commonly associated with scams. |
| `url_shortener` | Whether metadata URIs use link shorteners (for example, Bitly). |
| `manual_classification` | Manual override for classification (`spam` or `not spam`). |
These spam score features are included in the response's `explanations` field and may be omitted if signals are unavailable.
For ERC20 token filtering based on liquidity data, see our [Token & Spam Filtering](/token-filtering) guide.
# DeFi Positions
Source: https://docs.sim.dune.com/evm/defi-positions
/openapi.json GET /v1/evm/defi/positions/{address}
Access a wallet's DeFi positions along with USD values and metadata across supported EVM chains.
The DeFi Positions API returns a wallet's active positions across liquidity pools, lending protocols, yield strategies, and tokenized DeFi assets. The endpoint is available at the beta path (`/v1/evm/defi/positions`). Each position includes token holdings, USD valuations, underlying asset metadata, and protocol-specific details such as tick ranges, collateral status, or reward accruals.
## Response Structure
Every response contains a `positions` array and an `aggregations` object:
```json theme={null}
{
"positions": [...],
"aggregations": {
"total_value_usd": 4201.34,
"total_by_chain": {
"1": 3800.00,
"8453": 401.34
}
}
}
```
Each entry in `positions` has a `type` discriminator field that determines its shape. All positions also include `chain` (string name, e.g. `"ethereum"`) and `chain_id` (numeric). Token metadata is returned as **nested objects** — not flat fields.
## Supported Chains
## Supported Protocols
Protocol coverage is organized by position type. Each protocol specifies its supported chains and the API response type under which it appears.
## Compute Units
Each request consumes **10 Compute Units per processed chain ID**. Filtering to fewer chains lowers usage. Aggregations are calculated server-side and included in the same response.
To learn more specifics, please visit the [Compute Units](/compute-units) page.
# DeFi Supported Protocols
Source: https://docs.sim.dune.com/evm/defi/supported-protocols
/openapi.json GET /v1/evm/defi/supported-protocols
Returns the list of DeFi protocol families supported by the DeFi Positions API, with per-chain availability and stability status.
The Supported Protocols endpoint lists every DeFi protocol family available through the [DeFi Positions API](/evm/defi-positions), along with the chains each protocol is active on and whether it is in stable or preview status.
Use this endpoint to build dynamic UI filters, check coverage before querying positions, or discover which sub-protocols (e.g. specific AMM forks) are grouped under each family.
## Response Structure
Each entry in `protocol_families` represents one protocol group:
* **`family`** — Snake-case protocol identifier (e.g. `uniswap_v2`, `aave_v3`, `lido`). The four Lido protocol kinds are merged into a single `lido` family.
* **`chains`** — Array of chains the protocol is deployed on. Each entry includes `chain_id`, `chain_name`, and `status` (`stable` or `preview`).
* **`sub_protocols`** — Named forks or variants under this family (e.g. `["SushiSwapV2", "PancakeSwapV2"]` under `uniswap_v2`). Empty array if no named sub-protocols exist.
## Compute Units
This endpoint costs **1 Compute Unit** per request.
# EVM Overview
Source: https://docs.sim.dune.com/evm/overview
All available API endpoints for Ethereum and EVM-compatible chains.
Access real-time data across 60+ Ethereum Virtual Machine (EVM) compatible chains — including Ethereum, Base, Arbitrum, Polygon, Optimism, and more — with a single API key.
Access realtime token balances. Get comprehensive details about native and ERC20 tokens, including token metadata and USD valuations.
View chronologically ordered transactions including native transfers, ERC20 movements, NFT transfers, and decoded contract interactions.
All NFT (ERC721 and ERC1155) balances, including IDs and metadata
Retrieve granular transaction details including block information, gas data, transaction types, and raw transaction values.
Get detailed metadata and realtime price information for any native asset or ERC20 token including symbol, name, decimals, supply information, USD pricing, and logo URLs.
Discover token distribution across ERC20 token holders, ranked by balance descending.
## Real-Time Webhooks
Instead of polling, get data pushed to your app instantly via webhooks.
Subscribe to balance changes, activity events, and transactions for any wallet — receive webhook notifications the moment they happen.
# Search Tokens
Source: https://docs.sim.dune.com/evm/search-tokens
/openapi.json GET /v1/evm/search/tokens
Search ERC20 tokens across supported EVM chains by symbol or name, with optional chain filters. Results are enriched with logo and decimals.
The Search Tokens API lets you find ERC20 tokens by name or symbol across one or more supported EVM chains. Results are ranked by an internal score (where available) and returned with their canonical chain, address, symbol, name, `logo`, and `decimals` so they can be rendered immediately.
Use it to power token-pickers, autocomplete inputs, and any UI flow where the user knows part of a token's name or symbol but not its contract address.
## Example Request
```bash theme={null}
curl -X GET "https://api.sim.dune.com/v1/evm/search/tokens?query=USDC&chain_ids=1,8453&limit=5" \
-H "X-Sim-Api-Key: YOUR_API_KEY"
```
```json Response (JSON) [expandable] theme={null}
[
{
"chain_id": 1,
"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"token_type": "ERC20",
"symbol": "USDC",
"name": "USD Coin",
"logo": "https://api.sim.dune.com/beta/token/logo/1/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"decimals": 6
},
{
"chain_id": 8453,
"address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"token_type": "ERC20",
"symbol": "USDC",
"name": "USD Coin",
"logo": "https://api.sim.dune.com/beta/token/logo/8453/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"decimals": 6
}
]
```
## Query Parameters
| Parameter | Type | Required | Description |
| ----------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `query` | string | yes | Search term matched against the token's `symbol` and `name`. Case-insensitive. **Minimum 3 characters** after trimming whitespace; shorter queries return an empty result set. |
| `chain_ids` | string | no | Comma-separated chain IDs (`1,8453`) or a tag (`mainnet`, `default`). When omitted, all supported chains are searched. See the [Supported Chains](/evm/supported-chains#tags) page. |
| `limit` | integer | no | Maximum number of results to return. Default `10`, maximum `50` — values above the maximum are clamped silently. Values below `1` are clamped to `1`. |
## Response Fields
The endpoint returns a JSON array of token objects. Each object has:
| Field | Type | Description |
| ------------ | --------------- | ----------------------------------------------------------------- |
| `chain_id` | integer | EVM chain ID where the token is deployed |
| `address` | string | The token's contract address (lowercase, `0x`-prefixed) |
| `token_type` | string | `"ERC20"` |
| `symbol` | string | Token symbol (e.g. `USDC`, `WETH`) |
| `name` | string | Token name (e.g. `USD Coin`, `Wrapped Ether`) |
| `logo` | string \| null | Logo URL when available; `null` for tokens without a known logo. |
| `decimals` | integer \| null | Token decimals; `null` for tokens without a known decimals value. |
## How Search Works
Search is powered by an in-memory **trigram index** built over each token's `symbol` and `name`. Properties:
* **Case-insensitive.** `usdc` and `USDC` match the same set of tokens.
* **Substring-friendly.** `Teth` matches `Tether`, `TetherUSD`, and similar.
* **Three-character minimum.** Queries of fewer than 3 characters (after trimming) return an empty array — trigrams need at least 3 characters of input.
* **Ranked.** Tokens with an internal ranking score (driven by activity / liquidity signals) appear first. Ties fall back to a deterministic `(chain_id ASC, address ASC)` ordering, so repeat queries return stable results.
## Filtering Examples
**Restrict to a single chain**
```bash theme={null}
curl "https://api.sim.dune.com/v1/evm/search/tokens?query=USDC&chain_ids=8453" \
-H "X-Sim-Api-Key: YOUR_API_KEY"
```
**Search across the default chain set**
```bash theme={null}
curl "https://api.sim.dune.com/v1/evm/search/tokens?query=Tether&chain_ids=default" \
-H "X-Sim-Api-Key: YOUR_API_KEY"
```
## Error Responses
| Status | Cause |
| ------------------ | ------------------------------------------------------------------------------------------- |
| `400 Bad Request` | `query` parameter is missing or empty after trimming, or a query parameter cannot be parsed |
| `401 Unauthorized` | Missing or invalid `X-Sim-Api-Key` header |
## Compute Unit Cost
The Search Tokens endpoint has a fixed CU cost of **2** per request. The `chain_ids` query parameter does not change the CU cost. See the [Compute Units](/compute-units) page for detailed information.
# Show NFT Collectibles in Your Wallet
Source: https://docs.sim.dune.com/evm/show-nfts-collectibles
Complete your realtime crypto wallet by adding a visual display of a user's NFT collection.
Your wallet now displays token balances, calculates total portfolio value, and tracks detailed account activity.
To give users a holistic view of their onchain assets, the final piece is to **showcase their NFT collections**.
In this third and final guide of our wallet series, we will focus on implementing the *Collectibles* tab.
Access the complete source code for this wallet on GitHub
Interact with the finished wallet app
This guide assumes you have completed the previous guides:
1. [Build a Realtime Wallet](/evm/build-a-realtime-wallet)
2. [Add Account Activity](/evm/add-account-activity)
## Explore the NFT Collection
See the collectibles feature in action with the live demo below. Click on the "Collectibles" tab to browse the sample wallet's NFT collection:
## Fetch NFT Collectibles
Let's add a new asynchronous `getWalletCollectibles` function to `server.js` to fetch a user's NFT collection using the [Collectibles API](/evm/collectibles).
```javascript server.js (getWalletCollectibles function) {4} theme={null}
async function getWalletCollectibles(walletAddress, limit = 50) {
if (!walletAddress) return [];
const url = `https://api.sim.dune.com/v1/evm/collectibles/${walletAddress}?limit=${limit}`;
try {
const response = await fetch(url, {
headers: {
'X-Sim-Api-Key': SIM_API_KEY,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorBody = await response.text();
console.error(`Collectibles API request failed with status ${response.status}: ${response.statusText}`, errorBody);
throw new Error(`Collectibles API request failed: ${response.statusText}`);
}
const data = await response.json();
const collectibles = data.entries || [];
// Use Sim APIs data directly
return collectibles.map(collectible => {
return {
...collectible,
// Use collection_name field, fallback to name if not available
collection_name: collectible.name || `Token #${collectible.token_id}`
};
}).filter(collectible => collectible.image_url); // Only show collectibles with images
} catch (error) {
console.error("Error fetching wallet collectibles:", error.message);
return [];
}
}
```
The NFT data is extracted from the `entries` array within this response, providing comprehensive information including contract addresses, token IDs, chain data, and rich metadata.
The [Collectibles API](/evm/collectibles) supports pagination using `limit` and `offset` query parameters.
For wallets with many NFTs, you can implement logic to fetch subsequent pages using the `next_offset` value returned by the API to provide a complete view of the user's collection.
## Rich NFT Data and Images
The Sim APIs [Collectibles API](/evm/collectibles) provides comprehensive NFT data including images, metadata, and collection information directly in the response.
The Collectibles API includes:
Direct access to NFT artwork via `image_url` field
Rich metadata including attributes, descriptions, and collection details
Names, symbols, and collection-specific data
When the NFT was last acquired and sale price information
Contract addresses, token IDs, token standards (ERC-721, ERC-1155)
NFTs from Ethereum, Polygon, Optimism, Base, and other supported chains
## Add Collectibles into the Server Route
Next, we update our main `app.get('/')` route handler in `server.js` to call this new function:
```javascript server.js (app.get('/') updated for collectibles) {16, 19, 41} theme={null}
app.get('/', async (req, res) => {
const {
walletAddress = '',
tab = 'tokens'
} = req.query;
let tokens = [];
let activities = [];
let collectibles = []; // Initialize collectibles array
let totalWalletUSDValue = 0;
let errorMessage = null;
if (walletAddress) {
try {
// Fetch balances, activities, and collectibles concurrently for better performance
[tokens, activities, collectibles] = await Promise.all([
getWalletBalances(walletAddress),
getWalletActivity(walletAddress, 25), // Fetching 25 recent activities
getWalletCollectibles(walletAddress, 50) // Fetching up to 50 collectibles
]);
// Calculate total portfolio value from token balances (Guide 1)
if (tokens && tokens.length > 0) {
totalWalletUSDValue = tokens.reduce((sum, token) => {
const value = parseFloat(token.value_usd);
return sum + (isNaN(value) ? 0 : value);
}, 0);
}
} catch (error) {
console.error("Error in route handler fetching all data:", error);
errorMessage = "Failed to fetch wallet data. Please try again.";
}
}
res.render('wallet', {
walletAddress: walletAddress,
currentTab: tab,
totalWalletUSDValue: `$${totalWalletUSDValue.toFixed(2)}`,
tokens: tokens,
activities: activities,
collectibles: collectibles, // Pass collectibles to the template
errorMessage: errorMessage
});
});
```
The route handler now fetches balances, activities, and collectibles data concurrently for optimal performance.
The `collectibles` array, containing comprehensive blockchain data and image URLs directly from Sim APIs, is passed to the `wallet.ejs` template.
## Display Collectibles in the Frontend
The final step is to modify `views/wallet.ejs` to render the fetched collectibles within the "Collectibles" tab.
We will use a grid layout to display NFT images with their collection names and token IDs.
In `views/wallet.ejs`, find the section for the "Collectibles" tab (you can search for `id="collectibles"`).
It currently contains a placeholder paragraph.
Replace that entire `div` with the following EJS:
```ejs views/wallet.ejs (Collectibles tab content) [expandable] theme={null}
<% if (collectibles && collectibles.length > 0) { %>
```
The EJS template iterates through the `collectibles` array and displays each NFT with its comprehensive metadata directly from Sim APIs.
Each collectible shows the `image_url`, the `collection_name` or fallback name, and a truncated `token_id` for identification.
***
Restart your server using `node server.js` and navigate to your wallet app in the browser.
When you click on the "Collectibles" tab, and if the wallet has NFTs, you should see the NFT collection displayed with rich visual metadata.
## Conclusion
That concludes this three-part series!
With just three API requests - [Balances](/evm/balances), [Activity](/evm/activity), and [Collectibles](/evm/collectibles) - you've built a fully functional, multichain wallet that displays token balances, calculates portfolio value, tracks detailed transaction activity, and showcases NFT collections with rich visual displays.
**This project serves as a solid foundation for a wallet**.
You can now expand upon it by exploring other Sim API features.
Whether you want to add more sophisticated analytics, deeper NFT insights, or advanced transaction tracking, Sim APIs provides the blockchain data you need to build the next generation of onchain apps.
# Stablecoins
Source: https://docs.sim.dune.com/evm/stablecoins
/openapi.json GET /v1/evm/balances/{address}/stablecoins
Get realtime stablecoin balances across all supported EVM chains. Includes USD-pegged, yield-bearing, and euro-pegged stablecoins.
The Stablecoin Balances API provides a dedicated endpoint for retrieving only stablecoin holdings for a wallet across supported EVM chains. This is a specialized version of the [Balances](/evm/balances) endpoint that filters results to include only configured stablecoins.
## Supported Stablecoins
The endpoint includes stablecoins from three categories:
| Category | Description | Examples |
| ----------------- | ----------------------------- | --------------------- |
| **Standard** | USD-pegged stablecoins | USDC, USDT, DAI, BUSD |
| **Yield-bearing** | Stablecoins that accrue yield | sDAI, sUSDe |
| **Euro-pegged** | EUR-pegged stablecoins | EURC, EURe |
The stablecoin category is used for filtering only and is not included in the response. The response format is identical to the standard Balances endpoint.
On Gnosis chain (chain\_id: 100), the native token xDAI is a stablecoin and will be included in results with `address: "native"`.
## Alternative Access
You can also access stablecoin balances through the main [Balances](/evm/balances) endpoint using the `asset_class` query parameter:
```bash theme={null}
curl -s -X GET 'https://api.sim.dune.com/v1/evm/balances/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?asset_class=stablecoin&chain_ids=1,8453' \
-H 'X-Sim-Api-Key: YOUR_API_KEY'
```
Both methods return identical results.
## Compute Unit Cost
The Stablecoin Balances endpoint's CU cost equals the number of chains you include via the `chain_ids` query parameter, the same as the standard Balances endpoint.
If you omit `chain_ids`, the endpoint uses its `default` chain set, which equals chains at request time. See the tags section of the Supported Chains page.
## Pagination
This endpoint uses cursor-based pagination, identical to the Balances endpoint. Use the `limit` query parameter to define the maximum page size. The `next_offset` value from the response can be used to fetch subsequent pages.
```bash theme={null}
# First page
curl -s -X GET 'https://api.sim.dune.com/v1/evm/balances/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/stablecoins?limit=10' \
-H 'X-Sim-Api-Key: YOUR_API_KEY'
# Next page (using next_offset from previous response)
curl -s -X GET 'https://api.sim.dune.com/v1/evm/balances/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045/stablecoins?limit=10&offset=NEXT_OFFSET_VALUE' \
-H 'X-Sim-Api-Key: YOUR_API_KEY'
```
## Example Response
```json theme={null}
{
"request_time": "2026-02-05T10:31:08Z",
"response_time": "2026-02-05T10:31:08Z",
"wallet_address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
"balances": [
{
"chain": "ethereum",
"chain_id": 1,
"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"amount": "1000000000",
"symbol": "USDC",
"name": "USD Coin",
"decimals": 6,
"price_usd": 1.0,
"value_usd": 1000.0
},
{
"chain": "gnosis",
"chain_id": 100,
"address": "native",
"amount": "100000000000000000000",
"symbol": "XDAI",
"decimals": 18,
"price_usd": 1.0,
"value_usd": 100.0
}
]
}
```
# Subscriptions API
Source: https://docs.sim.dune.com/evm/subscriptions
Get real-time onchain event notifications pushed directly to your app via webhooks. Stream balance changes, activity, and transactions as they happen.
The Subscriptions API currently supports EVM chains only.
The Subscriptions API allows you to receive realtime onchain data through webhooks. Instead of repeatedly polling endpoints to check for new data, the Subscriptions API pushes data directly to your app as soon as events occur. This makes it a great way to build reactive apps that respond instantly to onchain activity.
Essentially, the Subscriptions API provides a webhook-based version of our existing [Balances](/evm/balances), [Activity](/evm/activity), and [Transactions](/evm/transactions) endpoints. Supported chains vary by subscription type.
Get notified when a wallet's ERC20 token balance changes.
Receive updates when a wallet has new activity, such as token transfers or contract interactions.
Be alerted when a wallet sends or receives a transaction.
## Setting Up Webhooks
You can set up and manage your subscriptions in two ways:
Use our set of endpoints to [create](/evm/subscriptions/create-webhook), [update](/evm/subscriptions/update-webhook), [list](/evm/subscriptions/list-webhooks), and [delete](/evm/subscriptions/delete-webhook) your webhook subscriptions. Ideal for apps that need to manage subscriptions dynamically.
Log in to your [Sim account](https://sim.dune.com) and navigate to **Subscriptions** in the sidebar. Click **Create Webhook** to set up a new webhook subscription.
## Compute Unit Cost
The Subscriptions API costs **2 CUs per event** sent to your webhook. Note that a single webhook call may include multiple events. For example, multiple balance changes or transactions.
If you omit `chain_ids` when creating a webhook your subscription watches all supported EVM chains for matching events. You are only charged per event received regardless of how many chains are monitored.
Creating and managing webhook subscriptions through the API does not consume compute units. However, your account must have an active plan to use the Subscriptions API.
See the [Compute Units](/compute-units) page for detailed information.
## Webhook Payloads
When a subscribed event occurs, Sim APIs will send a `POST` request to your specified webhook URL. The request body will contain a JSON payload with the event data. Each subscription type returns different data structures and is available on different sets of supported chains.
All webhook deliveries include a set of `dune-webhook-*` headers to provide metadata about the event and delivery attempt.
| Header | Description |
| --------------------------------- | ----------------------------------------------------------------------- |
| `dune-webhook-id` | The unique ID of the webhook subscription that triggered this event. |
| `dune-webhook-type` | The type of subscription (`transactions`, `activities`, or `balances`). |
| `dune-webhook-chain-id` | The ID of the chain where the event occurred. |
| `dune-webhook-dispatch-timestamp` | The timestamp (ISO 8601) when the webhook was dispatched. |
| `dune-webhook-retry-index` | The retry attempt number for this delivery (0 for the first attempt). |
**Security**: To secure your webhook endpoint, validate that incoming requests are from Sim. We include a signature in the `dune-webhook-signature` header that you can use to verify the authenticity of webhook deliveries. Always validate webhook signatures in production.
**Retry Logic**: If your webhook endpoint returns a non-2xx status code or is unreachable, Sim will automatically retry the delivery using an exponential backoff strategy with up to 5 retry attempts over a 24-hour period. Track the retry attempt number using the `dune-webhook-retry-index` header (0 for the first attempt).
Below are examples of the webhook payloads for each subscription type, along with the supported chains.
## Balances
Get notified when a wallet's ERC20 token balance changes. The payload contains details about the asset, the balance change, and the transaction that caused it. This data is similar to the response from our [Balances API](/evm/balances).
```json [expandable] Balance Change Payload theme={null}
{
"balance_changes": [
{
"amount_after": "1279045098892",
"amount_before": "1279044706766",
"amount_delta": "392126",
"asset": {
"decimals": 6,
"low_liquidity": false,
"name": "USD Coin",
"pool_size_usd": 25220031.549589876,
"symbol": "USDC",
"token_address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"token_metadata": {
"logo": "https://api.sim.dune.com/beta/token/logo/8453/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
}
},
"counterparty_address": "0xf5042e6ffac5a625d4e7848e0b01373d8eb9e222",
"direction": "in",
"price_usd": 0.9991475029469292,
"subscribed_address": "0xf70da97812cb96acdf810712aa562db8dfa3dbef",
"transaction_hash": "0x51a97de72ba1fb37f74046706147eb9469e7e90f2ab3671c6cca99a8111e74f0",
"value_after_usd": 1277954.71671445,
"value_before_usd": 1277954.324922736,
"value_delta_usd": 0.3917917137405675
}
]
}
```
## Activities
Receive a notification when a new activity is associated with a subscribed address. The payload contains an array of activity objects, which is similar to the response from our [Activity API](/evm/activity).
```json [expandable] Activity Payload theme={null}
{
"activities": [
{
"chain_id": 84532,
"block_number": 33620321,
"block_time": "2025-11-13T04:42:10+00:00",
"tx_hash": "0x32c0fbe4af264b5298108cf923c2c6205765e49385fde23f07fe45e3f1fb6309",
"tx_from": "0x014bee8bd1d5438caa3a40abb424ff09a81645ff",
"tx_to": "0x3f39c9b36b0129a561aae2820a47520891cc87a1",
"tx_value": "0",
"type": "send",
"asset_type": "erc20",
"token_address": "0x3f39c9b36b0129a561aae2820a47520891cc87a1",
"to": "0xb28eb9fb315f8156e3aaaa6ac8a24b9c76d76cdb",
"value": "1000000"
},
{
"chain_id": 84532,
"block_number": 33620321,
"block_time": "2025-11-13T04:42:10+00:00",
"tx_hash": "0x32c0fbe4af264b5298108cf923c2c6205765e49385fde23f07fe45e3f1fb6309",
"tx_from": "0x014bee8bd1d5438caa3a40abb424ff09a81645ff",
"tx_to": "0x3f39c9b36b0129a561aae2820a47520891cc87a1",
"tx_value": "0",
"type": "call",
"call_type": "incoming",
"from": "0x014bee8bd1d5438caa3a40abb424ff09a81645ff",
"value": "0",
"data": "0xa9059cbb000000000000000000000000b28eb9fb315f8156e3aaaa6ac8a24b9c76d76cdb00000000000000000000000000000000000000000000000000000000000f4240"
}
]
}
```
## Transactions
Get notified when a subscribed address is the sender or receiver of a transaction. The payload contains an array of transaction objects, which is similar to the response from our [Transactions API](/evm/transactions).
```json [expandable] Transaction Payload theme={null}
{
"transactions": [
{
"chain": "base_sepolia",
"chain_id": 84532,
"address": "0x014bee8bd1d5438caa3a40abb424ff09a81645ff",
"block_time": "2025-11-13T04:42:10+00:00",
"block_number": 33620321,
"index": 28,
"hash": "0x32c0fbe4af264b5298108cf923c2c6205765e49385fde23f07fe45e3f1fb6309",
"block_hash": "0x6a172fbf201d3a3eb6473e80b42795c36184ff48b5aad135feebdd330eb8b843",
"value": "0x0",
"transaction_type": "Sender",
"from": "0x014bee8bd1d5438caa3a40abb424ff09a81645ff",
"to": "0x3f39c9b36b0129a561aae2820a47520891cc87a1",
"nonce": "0x16",
"gas_price": "0xecd58",
"gas_used": "0xe84c",
"effective_gas_price": "0xecd58",
"success": true,
"data": "0xa9059cbb000000000000000000000000b28eb9fb315f8156e3aaaa6ac8a24b9c76d76cdb00000000000000000000000000000000000000000000000000000000000f4240",
"decoded": {
"name": "transfer",
"inputs": [
{
"name": "dst",
"type": "address",
"value": "0xb28eb9fb315f8156e3aaaa6ac8a24b9c76d76cdb"
},
{
"name": "wad",
"type": "uint256",
"value": "1000000"
}
]
},
"logs": [
{
"address": "0x3f39c9b36b0129a561aae2820a47520891cc87a1",
"data": "0x00000000000000000000000000000000000000000000000000000000000f4240",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x000000000000000000000000014bee8bd1d5438caa3a40abb424ff09a81645ff",
"0x000000000000000000000000b28eb9fb315f8156e3aaaa6ac8a24b9c76d76cdb"
],
"decoded": {
"name": "Transfer",
"inputs": [
{
"name": "_from",
"type": "address",
"value": "0x014bee8bd1d5438caa3a40abb424ff09a81645ff"
},
{
"name": "_to",
"type": "address",
"value": "0xb28eb9fb315f8156e3aaaa6ac8a24b9c76d76cdb"
},
{
"name": "_tokenId",
"type": "uint256",
"value": "1000000"
}
]
}
}
]
}
]
}
```
# Create Webhook
Source: https://docs.sim.dune.com/evm/subscriptions/create-webhook
/openapi.json POST /beta/evm/subscriptions/webhooks
Create a new webhook subscription to receive realtime onchain data.
Creates a new webhook subscription. When creating a webhook, you must specify:
* **name**: A descriptive name for your webhook
* **url**: The endpoint where webhook payloads will be sent
* **type**: The type of events to subscribe to (`transactions`, `activities`, or `balances`)
* **addresses**: An array of wallet addresses to monitor
## Optional Filters
You can narrow down the events you receive by adding optional filters:
* **chain\_ids**: Monitor only specific chains (e.g., `[1, 8453]` for Ethereum and Base)
* **transaction\_type**: For transaction webhooks, filter by `sender` or `receiver`
* **counterparty**: Filter transactions by counterparty address
* **activity\_type**: For activity webhooks, filter by specific types (`send`, `receive`, `swap`, etc.)
* **asset\_type**: Filter by asset type (`native`, `erc20`, `erc721`, `erc1155`)
* **token\_address**: Monitor only a specific token contract
Start with broad filters and narrow them down based on your app's needs. This helps reduce unnecessary webhook deliveries.
## Example Use Cases
### Monitor USDC Balance Changes
```json theme={null}
{
"name": "USDC Balance Monitor",
"url": "https://example.com/webhooks/usdc",
"type": "balances",
"addresses": ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
"chain_ids": [1, 8453],
"asset_type": "erc20",
"token_address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
}
```
### Track All Incoming Transactions
```json theme={null}
{
"name": "Incoming Transactions",
"url": "https://example.com/webhooks/txns",
"type": "transactions",
"addresses": ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
"transaction_type": "receiver"
}
```
### Monitor Swap Activities
```json theme={null}
{
"name": "Swap Monitor",
"url": "https://example.com/webhooks/swaps",
"type": "activities",
"addresses": ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
"activity_type": "swap"
}
```
# Delete Webhook
Source: https://docs.sim.dune.com/evm/subscriptions/delete-webhook
/openapi.json DELETE /beta/evm/subscriptions/webhooks/{webhookId}
Permanently delete a webhook subscription.
Permanently deletes a webhook subscription. This action cannot be undone.
Deleting a webhook is permanent. If you only need to temporarily stop receiving events, consider using the [Update Webhook](/evm/subscriptions/update-webhook) endpoint to set `active: false` instead.
After deletion:
* No new webhook deliveries will be sent
* The webhook ID becomes invalid
* All subscribed addresses are removed
* Historical delivery logs may still be visible in your dev portal
# Get Webhook
Source: https://docs.sim.dune.com/evm/subscriptions/get-webhook
/openapi.json GET /beta/evm/subscriptions/webhooks/{webhookId}
Retrieve detailed information about a specific webhook subscription.
Retrieves the complete configuration and metadata for a specific webhook subscription. Use this endpoint to:
* Check the current status of a webhook
* View the webhook's configuration and filters
* Verify when a webhook was created or last updated
The response includes all webhook properties, including optional filters that may have been set during creation.
# Get Webhook Addresses
Source: https://docs.sim.dune.com/evm/subscriptions/get-webhook-addresses
/openapi.json GET /beta/evm/subscriptions/webhooks/{webhookId}/addresses
Retrieve the list of addresses currently subscribed to a webhook.
Returns a paginated list of all wallet addresses that are currently subscribed to the specified webhook. This is useful for:
* Auditing which addresses are being monitored
* Verifying address subscriptions
* Exporting your subscription list
## Pagination
This endpoint uses cursor-based pagination. Use the `limit` parameter to control page size, and pass the `next_offset` value from the response as the `offset` parameter to retrieve the next page.
If you're managing a large number of addresses (hundreds or thousands), use pagination to efficiently retrieve the complete list.
# List Webhooks
Source: https://docs.sim.dune.com/evm/subscriptions/list-webhooks
/openapi.json GET /beta/evm/subscriptions/webhooks
Retrieve a paginated list of all webhook subscriptions for your team.
Returns a list of all webhook subscriptions associated with your authenticated team. Use this endpoint to view all your active and inactive webhooks, along with their configurations.
## Pagination
This endpoint uses cursor-based pagination. Use the `limit` parameter to control page size, and pass the `next_offset` value from the response as the `offset` parameter to retrieve the next page.
# Replace Webhook Addresses
Source: https://docs.sim.dune.com/evm/subscriptions/replace-webhook-addresses
/openapi.json PUT /beta/evm/subscriptions/webhooks/{webhookId}/addresses
Replace the entire address list for a webhook subscription.
Replaces the entire list of subscribed addresses for a webhook. The new list completely overwrites the existing addresses.
Use this endpoint when you want to:
* Replace the entire address list with a new set
* Reset the subscription to monitor a different set of addresses
* Perform a bulk update of all addresses at once
This endpoint replaces ALL existing addresses.
Any addresses not included in the new list will be removed from the subscription.
If you only need to add or remove specific addresses without replacing the entire list, use the [Update Addresses](/evm/subscriptions/update-webhook-addresses) endpoint instead.
# Update Webhook
Source: https://docs.sim.dune.com/evm/subscriptions/update-webhook
/openapi.json PATCH /beta/evm/subscriptions/webhooks/{webhookId}
Modify an existing webhook subscription's configuration.
Partially updates a webhook subscription's configuration. You can modify any of the webhook's properties without having to resend the entire configuration.
Only include the fields you want to update. All other properties will remain unchanged.
To manage subscribed addresses (add/remove), use the [Update Addresses](/evm/subscriptions/update-webhook-addresses) endpoint instead.
## Example Use Cases
### Pause a Webhook
To temporarily stop receiving webhook deliveries without deleting the webhook:
```json theme={null}
{
"active": false
}
```
### Update Destination URL
```json theme={null}
{
"url": "https://new-endpoint.example.com/webhooks"
}
```
### Change Chain Filter
```json theme={null}
{
"chain_ids": [1, 10, 8453]
}
```
### Update Multiple Properties
You can update multiple properties in a single request:
```json theme={null}
{
"name": "Updated USDC Monitor",
"active": true,
"chain_ids": [1, 8453]
}
```
# Update Addresses
Source: https://docs.sim.dune.com/evm/subscriptions/update-webhook-addresses
/openapi.json PATCH /beta/evm/subscriptions/webhooks/{webhookId}/addresses
Add or remove addresses from a webhook subscription.
Add or remove individual addresses from a webhook subscription without replacing the entire list. This is useful when you want to incrementally manage your subscribed addresses.
If you need to replace the entire address list at once, use the [Replace Webhook Addresses](/evm/subscriptions/replace-webhook-addresses) endpoint instead.
## Example Use Cases
### Add New Addresses
Add one or more addresses to your existing subscription:
```json theme={null}
{
"add_addresses": [
"0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
"0x1234567890123456789012345678901234567890"
]
}
```
### Remove Addresses
Remove addresses that you no longer want to monitor:
```json theme={null}
{
"remove_addresses": [
"0x3f60008Dfd0EfC03F476D9B489D6C5B13B3eBF2C"
]
}
```
### Add and Remove in One Request
You can add and remove addresses in the same request. The removal happens first, followed by the additions.
```json theme={null}
{
"add_addresses": [
"0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"
],
"remove_addresses": [
"0x3f60008Dfd0EfC03F476D9B489D6C5B13B3eBF2C"
]
}
```
This endpoint only manages addresses. To update other webhook properties like name, URL, or status, use the [Update Webhook](/evm/subscriptions/update-webhook) endpoint.
# Supported Chains
Source: https://docs.sim.dune.com/evm/supported-chains
/openapi.json GET /v1/evm/supported-chains
Explore chains supported by Sim's EVM API endpoints.
The Supported Chains endpoint provides realtime information about which blockchains are supported by Sim's EVM API endpoints.
Chain support varies by API endpoint. Use the dropdown below to check which chains are available for each API:
```bash cURL theme={null}
curl --request GET \
--url https://api.sim.dune.com/v1/evm/supported-chains \
--header 'X-Sim-Api-Key: YOUR_API_KEY'
```
```javascript JavaScript theme={null}
const response = await fetch('https://api.sim.dune.com/v1/evm/supported-chains', {
method: 'GET',
headers: {
'X-Sim-Api-Key': 'YOUR_API_KEY'
}
});
const data = await response.json();
console.log(data);
```
```python Python theme={null}
import requests
url = "https://api.sim.dune.com/v1/evm/supported-chains"
headers = {"X-Sim-Api-Key": "YOUR_API_KEY"}
response = requests.get(url, headers=headers)
data = response.json()
print(data)
```
```json 200 theme={null}
{
"chains": [
{
"name": "ethereum",
"chain_id": 1,
"tags": ["default", "mainnet"],
"balances": {"supported": true},
"transactions": {"supported": true},
"activity": {"supported": true},
"token_info": {"supported": true},
"token_holders": {"supported": true},
"collectibles": {"supported": true}
},
{
"name": "polygon",
"chain_id": 137,
"tags": ["default", "mainnet"],
"balances": {"supported": true},
"transactions": {"supported": true},
"activity": {"supported": true},
"token_info": {"supported": true},
"token_holders": {"supported": true},
"collectibles": {"supported": true}
}
]
}
```
```json 400 theme={null}
{
"error": "Bad Request"
}
```
```json 401 theme={null}
{
"error": "Unauthorized"
}
```
```json 404 theme={null}
{
"error": "Not Found"
}
```
```json 429 theme={null}
{
"error": "Too many requests"
}
```
```json 500 theme={null}
{
"error": "Internal Server Error"
}
```
## Tags
The `tags` property groups chains by category, such as `mainnet`, `testnet`, or `default`.
You can use these tags to filter or select chains in API requests.
Any endpoint that supports the `chain_ids` query parameter accepts a tag in place of explicit IDs, letting you fetch data for an entire group of chains in a single request.
When using `chain_ids`, you can request chains in several ways:
* **By tags**: `?chain_ids=mainnet` returns all chains tagged with `mainnet`. Using `?chain_ids=mainnet,testnet` returns all chains that are tagged with `mainnet` *or* `testnet`.
* **Specific chain IDs**: `?chain_ids=1,137,42161` (Ethereum, Polygon, Arbitrum).
* **Mix tags and numeric IDs**: `?chain_ids=1,8543,testnet`
* **Default behavior**: Omitting `chain_ids` returns only chains tagged `default`.
Some supported chains have **no tag assigned**.
A chain may be untagged due to higher latency, restrictive rate limits, RPC cost, or a temporary incident.
Untagged chains stay out of default requests to keep them fast, but you can still query them with `chain_ids` by passing their numerical chain id (e.g. `chain_ids=21000000` for corn).
Open the accordion above and scan the table to see which chains carry tags and which are untagged.
## Compute Units
Chain selection directly determines CU cost for chain-dependent endpoints (like Balances and Collectibles). CU equals the number of distinct chains included after expanding any tags you pass in `chain_ids`.
If you omit `chain_ids`, the endpoint uses its `default` chain set. That is currently chains for Balances and chains for Collectibles (these values can change over time).
See [Compute Units](/compute-units) for the full rate card and guidance.
## Using the API Endpoint
You can programmatically retrieve the list of supported chains to adapt to newly supported networks.
The response includes an array of supported chains.
Each item in the array includes the chain's `name`, `chain_id`, an array of `tags`, and support for each endpoint.
Each endpoint (balances, transactions, activity, etc.) has a `supported` boolean value
## Examples
Here are two practical examples of how you might use the API endpoint:
### 1. Building a Dynamic Chain Selector
This example shows how to fetch supported chains and create a user-friendly dropdown menu that filters chains based on their capabilities.
It can be useful for wallet UIs or dApp chain selection.
```javascript [expandable] theme={null}
// Fetch supported chains and build a dropdown for users
async function buildChainSelector() {
const response = await fetch('https://api.sim.dune.com/v1/evm/supported-chains', {
headers: { 'X-Sim-Api-Key': 'YOUR_API_KEY' }
});
const data = await response.json();
// Filter chains that support balances
const supportedChains = data.chains.filter(chain => chain.balances.supported);
// Build dropdown options
const chainOptions = supportedChains.map(chain => ({
value: chain.chain_id,
label: `${chain.name} (${chain.chain_id})`,
isMainnet: chain.tags.includes('mainnet')
}));
return chainOptions;
}
```
### 2. Validating Chain Support
This example demonstrates how to validate whether a specific chain supports a particular endpoint before making API calls.
This helps prevent errors and improves user experience by showing appropriate messages.
```javascript [expandable] theme={null}
async function validateChainSupport(chainId, endpointName) {
// Check if a chain supports a specific endpoint before making requests
try {
const response = await fetch('https://api.sim.dune.com/v1/evm/supported-chains', {
headers: { 'X-Sim-Api-Key': 'YOUR_API_KEY' }
});
const data = await response.json();
// Find the chain
const chain = data.chains.find(c => c.chain_id === chainId);
if (!chain) {
return { supported: false, message: `Chain ${chainId} not found` };
}
// Check if the endpoint is supported
if (!chain[endpointName] || !chain[endpointName].supported) {
return {
supported: false,
message: `Endpoint '${endpointName}' not supported on ${chain.name}`
};
}
return {
supported: true,
message: `Chain ${chain.name} supports ${endpointName}`
};
} catch (error) {
return { supported: false, message: `Error validating chain: ${error.message}` };
}
}
// Usage
const result = await validateChainSupport(1, 'balances');
console.log(result.message); // "Chain ethereum supports balances"
```
# Token Holders
Source: https://docs.sim.dune.com/evm/token-holders
/openapi.json GET /v1/evm/token-holders/{chain_id}/{address}
Discover token distribution across ERC20 token holders, ranked by balance descending.
The Token Holders API returns accounts holding a specific ERC20 token, ranked by balance descending. Use it for whale tracking, governance analysis, airdrop eligibility checks, or understanding token distribution.
## Example Request
```bash theme={null}
curl -X GET "https://api.sim.dune.com/v1/evm/token-holders/1/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48?limit=5" \
-H "X-Sim-Api-Key: YOUR_API_KEY"
```
The response returns holders sorted by balance, with each holder's share of total supply:
```json Response (JSON) [expandable] theme={null}
{
"holders": [
{
"address": "0x37305b1cd40574e4c5ce33f8e8306be057fd7341",
"balance": "2500000000000",
"share": 0.025
},
{
"address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
"balance": "1000000000000",
"share": 0.01
}
],
"next_offset": "eyJsYXN0X2JhbGFuY2UiOiIxMDAwMDAwMDAwMDAwIn0"
}
```
| Field | Description |
| --------- | -------------------------------------------------------------------------- |
| `address` | Holder's wallet address |
| `balance` | Raw token balance (divide by token's `decimals` for human-readable amount) |
| `share` | Proportion of total supply held (0.025 = 2.5%) |
## Pagination
This endpoint uses cursor-based pagination.
You can use the `limit` query parameter to define the maximum page size (up to 500).
Results might at times be less than the maximum page size.
The `next_offset` value is included in the initial response and can be used to fetch the next page of results by passing it as the `offset` query parameter in the next request.
You can only use the value from `next_offset` to set the `offset` query parameter of the next page of results.
## Compute Unit Cost
The Token Holders endpoint has a fixed CU cost of **2** per request. See the [Compute Units](/compute-units) page for detailed information.
# Token Info
Source: https://docs.sim.dune.com/evm/token-info
/openapi.json GET /v1/evm/token-info/{address}
Get detailed metadata and realtime price information for any native asset or ERC20 token including symbol, name, decimals, supply information, USD pricing, and logo URLs.
The Tokens API provides metadata and realtime pricing information for native and ERC20 tokens on supported EVM blockchains. The API returns:
* Token metadata (symbol, name, decimals)
* Current USD pricing information
* Supply information
* Logo URLs when available
The `?chain_ids` query parameter is mandatory.
To learn more about this query parameter, see the [Supported Chains](/evm/supported-chains#tags) page.
## Native vs. ERC20 tokens
The `address` path parameter can be set to `native` for chain-native assets or to an ERC20 contract address. Include the required `?chain_ids` query parameter to select the chain. The response shape differs slightly for native assets vs ERC20 tokens.
**Native asset**
Use `native` as the `address` path parameter.
```http Native Request Example theme={null}
GET /v1/evm/token-info/native?chain_ids=1
```
```json Native Response Example [expandable] theme={null}
{
"contract_address": "native",
"tokens": [
{
"chain_id": 1,
"chain": "ethereum",
"price_usd": 3900.777068,
"symbol": "ETH",
"name": "Ethereum",
"decimals": 18,
"logo": "https://api.sim.dune.com/beta/token/logo/1"
}
]
}
```
**ERC20 token**
Use the token's contract address as the `address` path parameter. Additional fields like `pool_size`, `total_supply`, and `fully_diluted_value` may be present.
```http ERC20 Request Example theme={null}
GET /v1/evm/token-info/0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca?chain_ids=8453
```
```json ERC20 Response Example [expandable] theme={null}
{
"contract_address": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca",
"tokens": [
{
"chain_id": 8453,
"chain": "base",
"price_usd": 0.998997309877106,
"pool_size": 456370.679451466,
"total_supply": "8980896607582",
"fully_diluted_value": 8971891.55125885,
"symbol": "USDbC",
"name": "USD Base Coin",
"decimals": 6,
"logo": "https://api.sim.dune.com/beta/token/logo/8453/0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca"
}
]
}
```
## Token Prices
Sim looks up prices onchain. We use the most liquid onchain pair to determine a usd price. We return the available liquidity in `pool_size` as part of the response.
## Historical prices
You can request historical point-in-time prices by adding the optional `historical_prices` query parameter. Use whole numbers to specify the number of hours in the past. You can request up to three offsets. For example, `historical_prices=8760` returns the price 8760 hours (approximately 1 year) ago. `historical_prices=720,168,24` returns prices 720 hours (1 month) ago, 168 hours (1 week) ago, and 24 hours ago.
The `historical_prices` query parameter is currently supported only on the EVM Token Info and EVM Balances endpoints.
When set, each token object includes a `historical_prices` array with one entry per offset:
```json theme={null}
{
"tokens": [
{
"chain": "base",
"symbol": "ETH",
"price_usd": 3897.492219,
"historical_prices": [
{ "offset_hours": 8760, "price_usd": 2816.557286 },
{ "offset_hours": 720, "price_usd": 3714.205613 },
{ "offset_hours": 168, "price_usd": 3798.926195 }
]
}
]
}
```
Percent changes are not returned. You can compute your own percentage differences using the current `price_usd` and the values in `historical_prices[].price_usd`.
## Pagination
This endpoint uses cursor-based pagination. You can use the `limit` parameter to define the maximum page size.
Results might at times be less than the maximum page size.
The `next_offset` value is included in the initial response and can be utilized to fetch the next page of results by passing it as the `offset` query parameter in the next request.
You can only use the value from `next_offset` to set the `offset` parameter of the next page of results. Using your own `offset` value will not have any effect.
## Compute Unit Cost
The Token Info endpoint has a fixed CU cost of **2** per request. The `chain_ids` query parameter is required but does not change the CU cost. See the [Compute Units](/compute-units) page for detailed information.
# Transactions
Source: https://docs.sim.dune.com/evm/transactions
/openapi.json GET /v1/evm/transactions/{address}
Retrieve granular transaction details including block information, gas data, transaction types, and raw transaction values.
The Transactions API allows for quick and accurate lookup of transactions associated with an address.
Transactions are ordered by descending block time, so the most recent transactions appear first.
## Decoded transactions
Enable decoded transaction data and logs by adding the `?decode=true` query parameter to your request.
When decoding is enabled, two types of data may be decoded:
1. **Transaction call data**: The `data` field of each transaction may include an additional `decoded` object at the root level of the transaction, representing the parsed function call.
2. **Event logs**: When a transaction contains EVM logs, each log may include an additional `decoded` object representing the parsed event.
For more details, see the [response information](#response-logs-decoded) below.
Decoding is only available for contracts that exist on [Dune.com](https://dune.com). Search for your contract on Dune to check if it's available for decoding. Transactions without logs, logs without known signatures, or contracts not indexed on Dune **will not** include decoded data.
## Warnings
When requesting transactions for specific chains using the `chain_ids` parameter, the API may return warnings if some requested chain IDs are not supported. Unlike errors, warnings indicate non-fatal issues where the request can still be partially fulfilled.
When unsupported chain IDs are included in your request, the API will:
* Return transactions for all supported chains you requested
* Include a `warnings` array in the response with details about the unsupported chains
### Example: Request with Unsupported Chain IDs
If you request `?chain_ids=1,9999,10`, the API returns transactions for chains 1 and 10 (supported), and includes a warning about chain 9999 (unsupported):
```json theme={null}
{
"wallet_address": "0x37305b1cd40574e4c5ce33f8e8306be057fd7341",
"transactions": [
{
"address": "0x37305b1cd40574e4c5ce33f8e8306be057fd7341",
"block_hash": "0x745bdf699cee1fef27b9304be43a2435c744500ef455cc6b7dfb4c34417601a2",
"block_number": "30819446",
"block_time": "2025-05-28T10:30:39Z",
"chain": "ethereum",
"from": "0x37305b1cd40574e4c5ce33f8e8306be057fd7341",
"to": "0x6ff5693b99212da76ad316178a184ab56d299b43",
"hash": "0x1081ebf623669338e9de6865d08d7ff3a3d1b0ef6f6486b350c3caf5b2e9257d",
"value": "0x0"
}
],
"warnings": [
{
"code": "UNSUPPORTED_CHAIN_IDS",
"message": "Some requested chain_ids are not supported. Transactions are returned only for supported chains.",
"chain_ids": [9999],
"docs_url": "https://docs.sim.dune.com/evm/supported-chains"
}
]
}
```
Check the [Supported Chains](/evm/supported-chains) page to see which chains are currently supported for the Transactions endpoint.
## Pagination
This endpoint is using cursor based pagination.
You can use the `limit` parameter to define the maximum page size.
Results might at times be less than the maximum page size.
The `next_offset` value is included in the initial response and can be utilized to fetch the next page of results by passing it as the `offset` query parameter in the next request.
You can only use the value from `next_offset` to set the `offset` parameter of the next page of results. Using your own `offset` value will not have any effect.
## Compute Unit Cost
The Transactions endpoint has a fixed CU cost of **1** per request. See the [Compute Units](/compute-units) page for detailed information.
## Real-Time Updates
**Skip the polling.** Use the [Subscriptions API](/evm/subscriptions) to receive webhook notifications when a wallet sends or receives a transaction. [Set up a webhook in minutes](/evm/subscriptions/create-webhook).
# Developer Quickstart
Source: https://docs.sim.dune.com/index
Take your first steps with the Sim APIs
Sim is a real-time blockchain data API by [Dune](https://dune.com). It gives developers instant access to wallet balances, token metadata, transaction history, and onchain activity across 60+ EVM chains and Solana — all through a single API key, with no indexer setup required. With [webhook subscriptions](/evm/subscriptions), you can also push data directly to your app in real time.
This guide will help you make your first API call to retrieve multichain token balances for an address.
## Authentication
Sim APIs use API keys to authenticate requests.
You can create and manage your API keys in your [Sim Dashboard](https://sim.dune.com/).
To authenticate, include your API key in the `X-Sim-Api-Key` header for every request.
```bash theme={null}
curl --request GET \
--header "X-Sim-Api-Key: YOUR_API_KEY"
```
All API requests must be made over HTTPS.
Calls made over plain HTTP will fail.
API requests without authentication will also fail.
Your API keys carry many privileges, so be sure to keep them secure.
Do not share your secret API keys in public places like GitHub, client-side code, and so on.
## Your First Request
Let's make your first request. We'll retrieve token balances for `0xd8da6bf26964af9d7eed9e03e53415d37aa96045` (Vitalik's wallet) across multiple EVM chains using the [Balances API](/evm/balances).
Here's how to make the API call:
```bash cURL theme={null}
curl -X GET "https://api.sim.dune.com/v1/evm/balances/0xd8da6bf26964af9d7eed9e03e53415d37aa96045" \
-H "X-Sim-Api-Key: YOUR_API_KEY"
```
```javascript JavaScript theme={null}
const options = {method: 'GET', headers: {'X-Sim-Api-Key': 'YOUR_API_KEY'}};
fetch('https://api.sim.dune.com/v1/evm/balances/0xd8da6bf26964af9d7eed9e03e53415d37aa96045', options)
.then(response => response.json())
.then(response => console.log(response))
.catch(err => console.error(err));
```
```python Python theme={null}
import requests
url = "https://api.sim.dune.com/v1/evm/balances/0xd8da6bf26964af9d7eed9e03e53415d37aa96045"
headers = {"X-Sim-Api-Key": "YOUR_API_KEY"}
response = requests.request("GET", url, headers=headers)
print(response.text)
```
Replace `YOUR_API_KEY` with your actual API key from the Sim Dashboard.
The API will return a JSON response containing an array of `balances`.
Each object in the array represents a token balance for the specified address on one of the chains, including various details.
```json Response (JSON) [expandable] theme={null}
{
"balances": [
{
"address": "native",
"amount": "605371497350928252303",
"chain": "ethereum",
"decimals": 18,
"price_usd": 3042.816964922323,
"symbol": "ETH",
"value_usd": 1842034.6622198338
}
],
"next_offset": "dKMBWDLqM7vlyn5OMEXsLWp0nI4AAAABA5JLazNO7x4poVGqUwsgxgqvvIg",
"request_time": "2023-11-07T05:31:56Z",
"response_time": "2023-11-07T05:31:56Z",
"wallet_address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"
}
```
With a single API request you get normalized, realtime data with enriched metadata and pricing.
## Next Steps
After making your first API call to Sim APIs, you'll either see the JSON response shown above (success!) or you might encounter an error. If you received an error, check out our [Error Handling Guide](/error-handling) for troubleshooting tips and best practices.
If your call was successful, you've seen how easily you can retrieve comprehensive, multichain data. But this is just the beginning of what's possible.
Are you ready to learn more?
Here are a few paths you can explore:
Set up webhook subscriptions to receive instant notifications for balance changes, activity, and transactions — no polling required.
Access balances, activity, transactions, NFTs, and more across 60+ Ethereum and EVM-compatible chains.
Get real-time balances and transactions on Solana and Eclipse.
Follow our practical guides to build fully-functional features like token portfolio displays, real-time activity feeds, and more for your onchain apps.
Speed up your development using Sim APIs with our LLM-friendly resources.
# Cloudflare Proxy
Source: https://docs.sim.dune.com/proxy
Learn how to set up a Cloudflare Worker to securely proxy your Sim API requests, protecting your API key from client-side exposure.
Protect your Sim API key when making requests from client-side apps by using a Cloudflare Worker as a proxy.
This worker receives requests from your app, securely adds your `SIM_API_KEY` on the server, and then forwards the requests to the Sim API endpoints.
**When to use a proxy:**
* Your app makes Sim API calls from the browser (React, Next.js client components, etc.)
* You need to avoid exposing your API key in client-side JavaScript
* You need to handle CORS for browser-based requests
**When you don't need a proxy:**
* Server-to-server calls (Node.js backends, Python scripts, etc.) can call the Sim API directly
We provide a one-click-deploy Cloudflare Worker to simplify this setup.
Find detailed instructions in our GitHub repo:
One-click deployment and comprehensive setup instructions for the Cloudflare Worker proxy.
# Support
Source: https://docs.sim.dune.com/support
Get help with Sim APIs through our support channels
Email our team directly for technical support and questions
Join our developer community for discussions and peer support
## Email Support
For technical support, bug reports, or questions that aren't answered in the documentation, email our team directly.
Send an email to [simsupport@dune.com](mailto:simsupport@dune.com) for technical help
## Developer Community
### Telegram
Join our active developer community for quick answers and code examples.
Connect with other Sim developers
## API Status
Check real-time status and uptime history for all Sim API endpoints.
Monitor Sim APIs operational status
## Rate Limits & Scaling
Your plan includes a monthly **Compute Unit (CU)** allowance. If you exceed your quota, requests may return errors unless your plan allows overage.
Track usage and manage your plan in the dashboard, and see our CU explainer for details:
View usage, quotas, and upgrade options
Learn how CUs are calculated and billed
# Solana Balances
Source: https://docs.sim.dune.com/svm/balances
/openapi.json GET /beta/svm/balances/{address}
Get token balances for a given SVM address
The Solana Balances API provides accurate and fast real-time balances of native SOL, SPL, and SPL-2022 tokens on supported Solana Virtual Machine (SVM) blockchains.
We currently support **Solana** and **Eclipse**.
* **SPL tokens** are the standard Solana token format (similar to ERC-20 on Ethereum).
* **SPL-2022** is the newer token standard with extended features like transfer fees and confidential transfers.
This endpoint uses the `chains` parameter (not `chain_ids` like EVM endpoints). Accepted values: `solana`, `eclipse`, or `all`.
## Example Request
```bash cURL theme={null}
curl -X GET "https://api.sim.dune.com/beta/svm/balances/vitalik.sol" \
-H "X-Sim-Api-Key: YOUR_API_KEY"
```
```javascript JavaScript theme={null}
const options = {method: ‘GET’, headers: {‘X-Sim-Api-Key’: ‘YOUR_API_KEY’}};
fetch(‘https://api.sim.dune.com/beta/svm/balances/vitalik.sol’, options)
.then(response => response.json())
.then(response => console.log(response))
.catch(err => console.error(err));
```
```python Python theme={null}
import requests
url = "https://api.sim.dune.com/beta/svm/balances/vitalik.sol"
headers = {"X-Sim-Api-Key": "YOUR_API_KEY"}
response = requests.get(url, headers=headers)
print(response.json())
```
## Pagination
This endpoint uses cursor-based pagination. You can use the `limit` parameter to define the maximum page size.
Results might at times be less than the maximum page size.
The `next_offset` value is passed back by the initial response and can be used to fetch the next page of results, by passing it as the `offset` query parameter in the next request.
You can only use the value from `next_offset` to set the `offset` parameter of the next page of results. Using your own `offset` value will not have any effect.
## Compute Unit Cost
The Solana Balances endpoint’s CU cost equals the number of chains you include via the `chains` query parameter. If you omit `chains`, the endpoint uses its default chain set, which is currently Solana only (**1 CU**). We currently support two SVM chains (Solana and Eclipse). See the [Compute Units](/compute-units) page for detailed information.
# Solana Overview
Source: https://docs.sim.dune.com/svm/overview
Access real-time balances and transactions on Solana and Eclipse via the SVM endpoints.
Sim's Solana endpoints (also called SVM endpoints) give you real-time access to wallet balances and transactions on Solana and Eclipse.
**Key differences from EVM endpoints:** Solana endpoints use the `chains` parameter (not `chain_ids`) with string values like `solana`, `eclipse`, or `all`. These endpoints are currently in beta (`/beta/svm/...`).
Get accurate and fast realtime balances of native, SPL and SPL-2022 tokens on Solana and Eclipse blockchains, with token metadata and USD valuations.
Quick and accurate lookup of transactions associated with a Solana address, ordered by descending block time, with complete raw transaction data.
# Solana Transactions
Source: https://docs.sim.dune.com/svm/transactions
/openapi.json GET /beta/svm/transactions/{address}
Get transactions for a given SVM address
The Solana Transactions API allows for quick and accurate lookup of transactions associated with an address.
**We currently only support Solana** (Eclipse support coming soon).
# Response Structure
The API returns a JSON object with the following top-level fields:
| Field | Description | Type |
| ------------ | --------------------------------------------- | ----------- |
| next\_offset | Pagination token for the next page of results | string/null |
| transactions | Array of transaction objects | array |
# Transaction Object Fields
Each item in the `transactions` array contains the following fields:
| Field | Description | Type |
| ---------------- | --------------------------------------------------------------- | ------ |
| address | Wallet address | string |
| block\_slot | Block's sequential index | number |
| block\_time | Timestamp of block creation (in microseconds) | number |
| chain | Name of the blockchain | string |
| raw\_transaction | Raw transaction data from the RPC node at the time of ingestion | object |
See [getTransaction RPC Method](https://solana.com/docs/rpc/http/gettransaction) for more details about `raw_transaction`.
# Ordering
The data is ordered by descending block time, so that new transactions will always be delivered first.
# Pagination
This endpoint is using cursor based pagination. You can use the `limit` parameter to define the maximum page size.
Results might at times be less than the maximum page size.
The `next_offset` value is included in the response and can be utilized to fetch the next page of results by passing it as the `offset` query parameter in the next request.
You can only use the value from `next_offset` to set the `offset` parameter of the next page of results. Using your own `offset` value will not have any effect.
## Compute Unit Cost
The Solana Transactions endpoint has a fixed CU cost of **1** per request. See the [Compute Units](/compute-units) page for detailed information.
# Token & Spam Filtering
Source: https://docs.sim.dune.com/token-filtering
Filter out spam, low-liquidity, and unwanted tokens using Sim's liquidity data and the exclude_spam_tokens parameter.
When working with blockchain data, you'll encounter numerous tokens with varying levels of liquidity and utility.
Sim APIs provide comprehensive token metadata and liquidity data to help you implement custom filtering logic that fits your specific requirements.
## Our Approach to Token Data
We don't offer direct spam filtering because definitions of spam are varying and subjective.
Instead, we provide the best objective measure that we're aware of—liquidity data—to allow developers to filter downstream based on their specific requirements.
By grounding filtering decisions in measurable, onchain liquidity, rather than subjective labels, our method offers several advantages:
* **Objective**: We provide liquidity metrics rather than subjective spam classifications
* **Realtime**: Liquidity is checked at query time, not based on outdated lists
* **Flexible**: All filtering data is provided, allowing you to implement custom logic that fits your use case
* **Transparent**: You have full visibility into the data used for filtering decisions
* **Adaptable**: Your filtering criteria can evolve with your application's needs
**We do not detect or flag honeypots, scam tokens, or other malicious contracts**.
Our APIs will return price and liquidity data for any token that has trading activity.
The presence of price data does not indicate that a token is safe to trade or that transactions will be successful.
## Supported APIs
Get wallet token balances with comprehensive filtering metadata
Track wallet activity with detailed token information
Filtering tokens does not change chain selection and therefore does not change Compute Units. To reduce CU on chain-dependent endpoints, limit `chain_ids` (EVM) or `chains` (SVM) to only the networks you need. See [Supported Chains](/evm/supported-chains) and [Compute Units](/compute-units).
For each token in our responses, we include comprehensive metadata that gives you the information needed to make informed filtering decisions:
1. **Token basics**: `symbol`, `name`, and `decimals` properties
2. **Price data**: Current USD pricing information using `price_usd`
3. **Liquidity information**: Real-time liquidity pool data with `pool_size`
4. **Pool size**: The total value locked in the token's highest liquidity pool using `low_liquidity`
## How Sim Calculates Liquidity Data
Sim's approach to assessing liquidity is sophisticated and real-time:
* For each token, we dynamically track the highest liquidity route to USDC
* We calculate the USD value of the liquidity along that route for each token upon each query
* This provides you with current, accurate liquidity information rather than static or outdated data
When `pool_size` is very small and `low_liquidity` is true, you should disregard `price_usd` and `value_usd` fields. While the price is technically correct based on the best liquidity pool we can find, it becomes effectively meaningless when there's insufficient liquidity. You can't actually exchange the token for anything else of value at that price.
## Using Token Data for Custom Filtering
Let's explore practical implementations for different filtering scenarios.
The following examples demonstrate how to use this data to create robust filtering logic that meets your app's needs.
### Exclude tokens with less than 100 USD liquidity
Use the optional `exclude_spam_tokens` query parameter on the EVM Balances API to automatically filter out tokens with less than 100 USD of liquidity. Include `exclude_spam_tokens=true` to have those tokens excluded from the response entirely.
By default, balances for tokens without available pricing data are included. Set `exclude_unpriced=true` if you want those unpriced token balances filtered out.
```bash theme={null}
curl -s -X GET 'https://api.sim.dune.com/v1/evm/balances/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?exclude_spam_tokens=true&exclude_unpriced=true' \
-H 'X-Sim-Api-Key: YOUR_API_KEY'
```
This is distinct from the `low_liquidity` field in responses, which is `true` when liquidity is below 10,000.
### Liquidity Threshold Filtering
Filter tokens based on minimum liquidity requirements using the `pool_size` field. This is one of the most effective ways to filter out low-quality tokens.
```javascript theme={null}
// Filter tokens with at least $10,000 in liquidity
const filterByLiquidity = (tokens, minLiquidity = 10000) => {
return tokens.filter(token => {
return token.pool_size && token.pool_size >= minLiquidity;
});
};
// Usage
const filteredTokens = filterByLiquidity(tokenData, 10000);
```
This approach ensures you only show tokens that have sufficient trading volume and market depth, reducing the likelihood of displaying illiquid or potentially problematic tokens.
### Allowlisting Specific Tokens
Include certain tokens regardless of their liquidity metrics by maintaining a list of approved token addresses and chain IDs.
A secure method for allowlisting is to use a combination of the token's unique contract `address` and its `chain_id`.
This guarantees you are identifying the exact token on the correct network.
```javascript theme={null}
// Allowlist of trusted tokens. Each entry is an object containing
// the specific chainId and the token's contract address.
const ALLOWLIST = [
// USDC on Ethereum
{ chainId: 1, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' },
// Wrapped BTC on Ethereum
{ chainId: 1, address: '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599' },
// DEGEN on Base
{ chainId: 8453, address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed' },
// Native ETH on Ethereum Mainnet
{ chainId: 1, address: 'native' },
// Native ETH on Base
{ chainId: 8453, address: 'native' },
];
const filterWithAllowlist = (tokens, minLiquidity = 10000) => {
return tokens.filter(token => {
// Check if the current token matches any in our allowlist.
const isAllowlisted = ALLOWLIST.some(allowlistItem => {
// Note: The Balances API uses `address`, while the Activity API uses `token_address`.
// We handle both possibilities here. We also convert to lowercase for a reliable match.
const tokenAddress = (token.address || token.token_address || '').toLowerCase();
return token.chain_id === allowlistItem.chainId && tokenAddress === allowlistItem.address;
});
// 1. If the token is on the allowlist, always include it.
if (isAllowlisted) {
return true;
}
// 2. For all other tokens, apply a standard liquidity filter.
return token.pool_size && token.pool_size >= minLiquidity;
});
};
```
### Denylisting Specific Tokens
Exclude certain tokens even if they meet your liquidity criteria by maintaining a blocklist of problematic symbols, or any other criteria you choose.
```javascript theme={null}
// Denylist of problematic token symbols
const DENYLISTED_SYMBOLS = [
'SCAM',
'RUG',
];
const filterWithDenylist = (tokens) => {
return tokens.filter(token => {
// Exclude denylisted tokens
if (token.symbol && DENYLISTED_SYMBOLS.includes(token.symbol.toUpperCase())) {
return false;
}
// Apply other filtering criteria
return token.pool_size && token.pool_size >= 1000;
});
};
```
Denylisting helps you maintain control over which tokens appear in your application, even if they have sufficient liquidity.
### Custom Criteria Combinations
Create sophisticated filtering logic by combining multiple criteria such as liquidity, completeness, and custom business rules.
```javascript theme={null}
const advancedTokenFilter = (tokens, options = {}) => {
const {
minLiquidity = 1000,
requireCompleteName = true,
minPriceUsd = 0.000001,
allowLowLiquidity = false
} = options;
return tokens.filter(token => {
// Check if token has complete metadata
if (requireCompleteName && (!token.name || !token.symbol)) {
return false;
}
// Check minimum price threshold
if (token.price_usd && token.price_usd < minPriceUsd) {
return false;
}
// Check liquidity requirements
if (!allowLowLiquidity && token.low_liquidity) {
return false;
}
if (token.pool_size && token.pool_size < minLiquidity) {
return false;
}
return true;
});
};
```
This approach allows you to create nuanced filtering that considers multiple factors simultaneously.
### Token Completeness Filtering
Filter based on whether tokens have complete metadata, ensuring users only see tokens with proper names and symbols.
```javascript theme={null}
const filterCompleteTokens = (tokens) => {
return tokens.filter(token => {
// Require all basic metadata to be present
const hasBasicInfo = token.name &&
token.symbol &&
token.decimals !== undefined;
// Optionally require price data
const hasPriceData = token.price_usd !== undefined;
return hasBasicInfo && hasPriceData;
});
};
// Or create a more flexible version
const filterTokensByCompleteness = (tokens, strict = false) => {
return tokens.filter(token => {
if (strict) {
// Strict mode: require all fields
return token.name && token.symbol && token.decimals &&
token.price_usd && token.pool_size;
} else {
// Lenient mode: require only basic fields
return token.symbol && token.decimals !== undefined;
}
});
};
```
Completeness filtering ensures your users have meaningful information about the tokens they're viewing.