@@ -10,7 +10,7 @@ description: Create a Telegram bot that monitors and alerts you when top token h
1010
1111In 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
259310async 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
341389async 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
458510async 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
685739For 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