Skip to content

Commit 91932ab

Browse files
committed
Improving the Top Token Holders Telegram Bot guide based on Christoph's feedback.
1 parent 619288a commit 91932ab

File tree

1 file changed

+121
-67
lines changed

1 file changed

+121
-67
lines changed

evm/build-a-top-holders-tracker-bot.mdx

Lines changed: 121 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ description: Create a Telegram bot that monitors and alerts you when top token h
1010

1111
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.
1212

13-
We'll use the [Token Holders API](/evm/token-holders) to identify the top holders for for each token, then set up the [Subscriptions API](/evm/subscriptions) to receive instant webhook notifications when those wallets move funds.
13+
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.
1414

1515
<CardGroup cols={2}>
1616
<Card title="View Source Code" icon="github" href="https://github.com/duneanalytics/telegram-whale-tracker">
@@ -28,6 +28,7 @@ Before you begin, ensure you have:
2828
- **Node.js** - v22 or later
2929
- **Sim API Key** - [Get your API key](https://sim.dune.com)
3030
- **Telegram Bot Token** - Create one via [@BotFather](https://t.me/botfather)
31+
- **Supabase Account** - [Create a free account](https://supabase.com)
3132

3233
## Features
3334

@@ -52,24 +53,36 @@ Let's initialize the project.
5253
</Step>
5354

5455
<Step title="Install Dependencies">
55-
We need Express, SQLite, and a CSV parser to handle the Dune export.
56+
We need Express, the Postgres client, and a CSV parser to handle the Dune export.
5657

5758
```bash
58-
npm install express better-sqlite3 csv-parse
59+
npm install express postgres csv-parse
5960
```
6061
</Step>
6162

63+
<Step title="Set Up Supabase">
64+
1. Go to [supabase.com/dashboard](https://supabase.com/dashboard) and create a new project
65+
2. Once created, go to **Project Settings****Database**
66+
3. Scroll to **Connection string** and select the **URI** tab
67+
4. Copy the **Transaction pooler** connection string (uses port `6543`)
68+
69+
<Note>
70+
The Transaction pooler connection is recommended for serverless deployments like Vercel.
71+
</Note>
72+
</Step>
73+
6274
<Step title="Set Up Environment Variables">
6375
Create a `.env` file in your project root:
6476

6577
```env .env
6678
SIM_API_KEY=your_sim_api_key_here
6779
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
6880
WEBHOOK_BASE_URL=https://your-deployed-url.com
81+
DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
6982
```
7083

7184
<Warning>
72-
Never commit your `.env` file to version control. Add it to your `.gitignore`.
85+
If your database password contains special characters (`!`, `@`, `#`, etc.), URL-encode them. For example, `@` becomes `%40`.
7386
</Warning>
7487
</Step>
7588

@@ -88,11 +101,11 @@ Let's initialize the project.
88101
</Step>
89102

90103
<Step title="Initialize Server and Database">
91-
Create the entry point `main.js`. We will initialize SQLite tables immediately on startup.
104+
Create the entry point `main.js`. We initialize the database tables on startup.
92105

93106
```javascript main.js
94107
import express from "express";
95-
import Database from "better-sqlite3";
108+
import postgres from "postgres";
96109
import { setTimeout } from "node:timers/promises";
97110
import fs from "node:fs";
98111
import { parse } from "csv-parse/sync";
@@ -102,39 +115,46 @@ Let's initialize the project.
102115
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || "";
103116
const WEBHOOK_BASE_URL = process.env.WEBHOOK_BASE_URL || "";
104117
const PORT = process.env.PORT || 3000;
118+
const DATABASE_URL = process.env.DATABASE_URL || "";
105119

106-
if (!SIM_API_KEY || !TELEGRAM_BOT_TOKEN) {
120+
if (!SIM_API_KEY || !TELEGRAM_BOT_TOKEN || !DATABASE_URL) {
107121
console.error("Missing required environment variables");
108122
process.exit(1);
109123
}
110124

111-
// 2. Database Setup (SQLite)
112-
const db = new Database("top-holders-tracker.db");
125+
// 2. Database Setup (PostgreSQL via Supabase)
126+
const sql = postgres(DATABASE_URL);
113127

114128
// Initialize Tables
115-
db.exec(`
116-
CREATE TABLE IF NOT EXISTS top_holders (
117-
id INTEGER PRIMARY KEY AUTOINCREMENT,
118-
token_address TEXT,
119-
chain_id INTEGER,
120-
symbol TEXT,
121-
blockchain TEXT,
122-
holders_json TEXT, -- Storing holder list as JSON
123-
UNIQUE(token_address, chain_id)
124-
);
125-
126-
CREATE TABLE IF NOT EXISTS subscribers (
127-
chat_id TEXT PRIMARY KEY,
128-
subscribed_at TEXT
129-
);
130-
131-
CREATE TABLE IF NOT EXISTS webhooks (
132-
id TEXT PRIMARY KEY,
133-
token_address TEXT,
134-
chain_id INTEGER,
135-
active INTEGER DEFAULT 1
136-
);
137-
`);
129+
async function initDatabase() {
130+
await sql`
131+
CREATE TABLE IF NOT EXISTS top_holders (
132+
id SERIAL PRIMARY KEY,
133+
token_address TEXT,
134+
chain_id INTEGER,
135+
symbol TEXT,
136+
blockchain TEXT,
137+
holders_json TEXT,
138+
UNIQUE(token_address, chain_id)
139+
)
140+
`;
141+
142+
await sql`
143+
CREATE TABLE IF NOT EXISTS subscribers (
144+
chat_id TEXT PRIMARY KEY,
145+
subscribed_at TEXT
146+
)
147+
`;
148+
149+
await sql`
150+
CREATE TABLE IF NOT EXISTS webhooks (
151+
id TEXT PRIMARY KEY,
152+
token_address TEXT,
153+
chain_id INTEGER,
154+
active INTEGER DEFAULT 1
155+
)
156+
`;
157+
}
138158

139159
// 3. Express Setup
140160
const app = express();
@@ -144,11 +164,42 @@ Let's initialize the project.
144164
res.json({ ok: true });
145165
});
146166

147-
app.listen(PORT, () => {
148-
console.log(`Server running on port ${PORT}`);
167+
// Start server after database is ready
168+
initDatabase().then(() => {
169+
app.listen(PORT, () => {
170+
console.log(`Server running on port ${PORT}`);
171+
});
172+
}).catch(err => {
173+
console.error("Failed to initialize database:", err);
174+
process.exit(1);
149175
});
150176
```
151177
</Step>
178+
179+
<Step title="Enable Row Level Security (RLS)">
180+
In your Supabase dashboard, go to **SQL Editor** and run:
181+
182+
```sql
183+
-- Enable RLS on all tables
184+
ALTER TABLE top_holders ENABLE ROW LEVEL SECURITY;
185+
ALTER TABLE subscribers ENABLE ROW LEVEL SECURITY;
186+
ALTER TABLE webhooks ENABLE ROW LEVEL SECURITY;
187+
188+
-- Create policies that allow all operations (server-side access)
189+
CREATE POLICY "Allow all operations on top_holders" ON top_holders
190+
FOR ALL USING (true) WITH CHECK (true);
191+
192+
CREATE POLICY "Allow all operations on subscribers" ON subscribers
193+
FOR ALL USING (true) WITH CHECK (true);
194+
195+
CREATE POLICY "Allow all operations on webhooks" ON webhooks
196+
FOR ALL USING (true) WITH CHECK (true);
197+
```
198+
199+
<Note>
200+
This enables RLS (a Supabase security requirement) with permissive policies since only your server accesses these tables.
201+
</Note>
202+
</Step>
152203
</Steps>
153204

154205
## Get Top ERC20 Tokens
@@ -229,7 +280,7 @@ function loadTokensFromCSV() {
229280

230281
## Get Top Token Holders
231282

232-
Now we'll identify the top holder addresses for each token using Sim's Token Holders API and store them in our SQLite database.
283+
Now we'll identify the top holder addresses for each token using Sim's Token Holders API and store them in our database.
233284

234285
### Fetch Holders for a Token
235286

@@ -253,7 +304,7 @@ async function fetchTokenHolders(tokenAddress, chainId, limit = 3) {
253304

254305
### Store Top Holder Addresses
255306

256-
This function iterates through the CSV records, fetches top holders, and saves them to the DB.
307+
This function iterates through the CSV records, fetches top holders, and saves them to the database.
257308

258309
```javascript main.js
259310
async function fetchAllTopHolders() {
@@ -262,11 +313,6 @@ async function fetchAllTopHolders() {
262313

263314
console.log(`Processing ${tokens.length} tokens from CSV...`);
264315

265-
const insertStmt = db.prepare(`
266-
INSERT OR REPLACE INTO top_holders (token_address, chain_id, symbol, blockchain, holders_json)
267-
VALUES (?, ?, ?, ?, ?)
268-
`);
269-
270316
for (const token of tokens) {
271317
const chainId = getChainId(token.blockchain);
272318

@@ -276,13 +322,15 @@ async function fetchAllTopHolders() {
276322
const holders = await fetchTokenHolders(token.contract_address, chainId);
277323

278324
if (holders.length > 0) {
279-
insertStmt.run(
280-
token.contract_address.toLowerCase(),
281-
chainId,
282-
token.symbol,
283-
token.blockchain,
284-
JSON.stringify(holders)
285-
);
325+
const tokenAddress = token.contract_address.toLowerCase();
326+
const holdersJson = JSON.stringify(holders);
327+
328+
await sql`
329+
INSERT INTO top_holders (token_address, chain_id, symbol, blockchain, holders_json)
330+
VALUES (${tokenAddress}, ${chainId}, ${token.symbol}, ${token.blockchain}, ${holdersJson})
331+
ON CONFLICT (token_address, chain_id)
332+
DO UPDATE SET symbol = ${token.symbol}, blockchain = ${token.blockchain}, holders_json = ${holdersJson}
333+
`;
286334
totalHolders += holders.length;
287335
console.log(`Found ${holders.length} top holders for ${token.symbol}`);
288336
}
@@ -335,16 +383,12 @@ async function createWebhook(config) {
335383

336384
### Create Webhooks for All Top Holders
337385

338-
Iterate through our local `top_holders` table and create a subscription for each.
386+
Iterate through our `top_holders` table and create a subscription for each.
339387

340388
```javascript main.js
341389
async function createWebhooksForTopHolders() {
342390
const webhookIds = [];
343-
const rows = db.prepare("SELECT * FROM top_holders").all();
344-
345-
const insertWebhookStmt = db.prepare(`
346-
INSERT OR REPLACE INTO webhooks (id, token_address, chain_id) VALUES (?, ?, ?)
347-
`);
391+
const rows = await sql`SELECT * FROM top_holders`;
348392

349393
for (const row of rows) {
350394
const holders = JSON.parse(row.holders_json);
@@ -362,7 +406,11 @@ async function createWebhooksForTopHolders() {
362406
});
363407

364408
if (webhook?.id) {
365-
insertWebhookStmt.run(webhook.id, row.token_address, row.chain_id);
409+
await sql`
410+
INSERT INTO webhooks (id, token_address, chain_id)
411+
VALUES (${webhook.id}, ${row.token_address}, ${row.chain_id})
412+
ON CONFLICT (id) DO UPDATE SET token_address = ${row.token_address}, chain_id = ${row.chain_id}
413+
`;
366414
webhookIds.push(webhook.id);
367415
console.log(`Created webhook for ${row.symbol}`);
368416
}
@@ -421,16 +469,20 @@ app.post("/balances", async (req, res) => {
421469
422470
### Manage Subscribers
423471
424-
We use SQLite to persist chat IDs.
472+
We use PostgreSQL to persist chat IDs.
425473
426474
```javascript main.js
427-
function addSubscriber(chatId) {
428-
db.prepare("INSERT OR IGNORE INTO subscribers (chat_id, subscribed_at) VALUES (?, ?)")
429-
.run(chatId, new Date().toISOString());
475+
async function addSubscriber(chatId) {
476+
const subscribedAt = new Date().toISOString();
477+
await sql`
478+
INSERT INTO subscribers (chat_id, subscribed_at)
479+
VALUES (${chatId}, ${subscribedAt})
480+
ON CONFLICT (chat_id) DO NOTHING
481+
`;
430482
}
431483

432-
function getAllSubscribers() {
433-
const rows = db.prepare("SELECT chat_id FROM subscribers").all();
484+
async function getAllSubscribers() {
485+
const rows = await sql`SELECT chat_id FROM subscribers`;
434486
return rows.map(r => r.chat_id);
435487
}
436488
```
@@ -456,7 +508,7 @@ async function sendTelegramMessage(text, chatId) {
456508
}
457509

458510
async function broadcastToSubscribers(text) {
459-
const subscribers = getAllSubscribers();
511+
const subscribers = await getAllSubscribers();
460512
for (const chatId of subscribers) {
461513
await sendTelegramMessage(text, chatId);
462514
}
@@ -533,15 +585,15 @@ app.post("/telegram/webhook", async (req, res) => {
533585
const text = message.text;
534586

535587
if (text.startsWith("/start")) {
536-
addSubscriber(chatId);
588+
await addSubscriber(chatId);
537589
await sendTelegramMessage(
538590
"📊 *Welcome to Top Holders Tracker!*\n\n" +
539591
"You're now subscribed to top holder alerts.\n\n" +
540592
"Commands:\n/start - Subscribe\n/status - Check subscription",
541593
chatId
542594
);
543595
} else if (text.startsWith("/status")) {
544-
const subscribers = getAllSubscribers();
596+
const subscribers = await getAllSubscribers();
545597
const isSubscribed = subscribers.includes(chatId);
546598
await sendTelegramMessage(
547599
isSubscribed
@@ -646,7 +698,9 @@ app.post("/setup/resume-webhooks", async (req, res) => {
646698
Tell Telegram to send updates to your server:
647699

648700
```bash
649-
curl "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook?url=<YOUR_URL>/telegram/webhook"
701+
curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \
702+
-H "Content-Type: application/json" \
703+
-d '{"url": "<YOUR_URL>/telegram/webhook"}'
650704
```
651705
</Step>
652706

@@ -673,13 +727,13 @@ You've built a top token holders tracker bot that monitors large token holders i
673727

674728
- **Node.js & Express** - A modern server setup using native environment variable loading.
675729
- **CSV Integration** - Parsing Dune Analytics data to drive your bot's logic.
676-
- **SQLite** - Efficient local storage for caching top holders and managing subscribers.
730+
- **Supabase PostgreSQL** - Cloud-hosted database for storing top holders and managing subscribers.
677731
- **Sim APIs** - Using Token Holders and Subscriptions endpoints to power the logic.
678732
679733
### Next Steps
680734
681735
- **Add More Chains** - Extend the token list to cover additional blockchains
682736
- **Filter by Value** - Let users set minimum USD thresholds for alerts
683-
- **Persistent Storage** - If scaling beyond a single VPS, consider migrating SQLite to PostgreSQL.
737+
- **Deploy to Vercel** - Use the Supabase-Vercel integration for seamless deployment
684738
685739
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).

0 commit comments

Comments
 (0)