On-chain NFT markets move fast. A whale selling $2M in CryptoPunks doesn't announce it. A known collector suddenly listing an entire collection is signal. A suspicious wallet accumulating blue chips before a project partnership drops is insider trading in slow motion.
This tutorial walks through building a bot that monitors a list of high-value NFT wallets with 0watch and forwards alerts to a Discord server. The same pattern works for Telegram bots, Slack channels, or any webhook destination.
What you'll build
- A wallet registry for known NFT whales
- A 0watch monitoring setup with per-wallet thresholds
- A webhook handler that receives transfer alerts
- A Discord notification bot with transaction context
- A lightweight dashboard query for daily summaries
Architecture
Target NFT Wallets (on-chain)
│
▼
0watch Indexer ←── indexes ETH + Base + Arbitrum
│
▼ (webhook POST)
Whale Tracker Bot
│
├── Large transfer → Discord #whale-moves
├── High velocity → Discord #whale-moves (escalated)
└── Failed large tx → Discord #whale-alerts
The bot maintains a list of tracked wallets, sets up webhooks for each, and processes the alert stream. Discord is just the output — swap in Telegram or Slack without changing anything else.
Prerequisites
- Node.js 18+
- 0watch API key
- Discord bot token and webhook URL (or swap for your preferred notification channel)
Install dependencies
npm install @0agent/0watch-sdk
For Discord notifications, you'll POST to a Discord webhook URL directly — no additional packages needed.
Step 1: Define your whale list
In practice you'd load this from a database or on-chain query. For this tutorial, we'll use a static config:
// whales.ts
export interface WhaleMeta {
address: `0x${string}`;
label: string;
tier: 'blue-chip' | 'mid-cap' | 'unknown';
thresholdEth: number; // alert on transfers above this
}
export const TRACKED_WHALES: WhaleMeta[] = [
{
address: '0x29469395eaf6f95920e59f858042f0e28d98a20b',
label: 'Punk 9998 Holder',
tier: 'blue-chip',
thresholdEth: 100,
},
{
address: '0xb8c2c29ee19d8307cb7255e1cd9cbde883a267d5',
label: 'Known BAYC Accumulator',
tier: 'blue-chip',
thresholdEth: 50,
},
{
address: '0x7a58c0be72be218b41c608b7fe7c5bb630736c71',
label: 'Active Blur Trader',
tier: 'mid-cap',
thresholdEth: 10,
},
];
Step 2: Register wallets and set up webhooks
import { ZeroWatchClient } from '@0agent/0watch-sdk';
import { TRACKED_WHALES } from './whales.js';
const client = new ZeroWatchClient({
apiKey: process.env.ZEROWATCH_API_KEY,
});
async function setupTracking() {
const webhookBase = process.env.WEBHOOK_URL!; // your bot's public endpoint
for (const whale of TRACKED_WHALES) {
// Register the wallet (idempotent — safe to re-run)
await client.createWallet({
address: whale.address,
label: whale.label,
chain: 'ethereum',
}).catch((err) => {
// Wallet already registered — not an error
if (err.status === 409) return;
throw err;
});
// Set up the alert webhook for this wallet
const webhook = await client.createWebhook({
url: `${webhookBase}/hooks/whale`,
wallet_address: whale.address,
threshold_eth: whale.thresholdEth,
});
console.log(`Tracking ${whale.label} (${whale.address}) — webhook ${webhook.id}`);
}
}
setupTracking().catch(console.error);
If a whale has multiple wallets, register each separately. Label them clearly — "Whale A — Holding Wallet" and "Whale A — Trading Wallet" are much easier to read in a Discord notification at 2am.
Step 3: Build the webhook handler
import express from 'express';
import crypto from 'node:crypto';
import { TRACKED_WHALES } from './whales.js';
const app = express();
// Raw body needed for HMAC verification
app.post('/hooks/whale', express.raw({ type: '*/*' }), async (req, res) => {
const rawBody = req.body.toString('utf-8');
const signature = req.headers['x-0watch-signature'] as string;
if (!verifySignature(rawBody, process.env.WEBHOOK_SIGNING_SECRET!, signature)) {
return res.status(401).json({ error: 'unauthorized' });
}
// Respond immediately, process async
res.json({ ok: true });
const alert = JSON.parse(rawBody) as AlertPayload;
processWhaleAlert(alert).catch(console.error);
});
function verifySignature(payload: string, secret: string, sig: string): boolean {
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'));
}
app.listen(3001, () => console.log('Whale tracker running on :3001'));
Step 4: Process alerts and build Discord messages
interface AlertPayload {
event: 'high_value_transaction';
walletAddress: string;
thresholdEth: number;
valueEth: number;
transaction: {
hash: string;
blockNumber: number;
timestamp: number;
fromAddress: string;
toAddress: string | null;
valueWei: string;
txType: 'eth_transfer' | 'erc20_transfer' | 'erc20_approval' | 'uniswap_swap' | 'unknown';
status: 0 | 1;
chainId: number;
};
}
function getWhaleMeta(address: string) {
return TRACKED_WHALES.find(
(w) => w.address.toLowerCase() === address.toLowerCase()
);
}
async function processWhaleAlert(alert: AlertPayload) {
const { walletAddress, valueEth, transaction } = alert;
const whale = getWhaleMeta(walletAddress);
const label = whale?.label ?? walletAddress.slice(0, 10) + '...';
// Determine direction
const isOutbound = transaction.fromAddress.toLowerCase() === walletAddress.toLowerCase();
const direction = isOutbound ? '🔴 SELL / SEND' : '🟢 BUY / RECEIVE';
// Build Etherscan link
const chainPath = transaction.chainId === 1 ? '' : `${chainName(transaction.chainId)}.`;
const etherscanLink = `https://${chainPath}etherscan.io/tx/${transaction.hash}`;
// Build Discord embed
const embed = {
title: `${direction} — ${valueEth.toFixed(4)} ETH`,
description: `**${label}** moved funds`,
color: isOutbound ? 0xff4444 : 0x44ff88,
fields: [
{ name: 'Wallet', value: `\`${walletAddress}\``, inline: false },
{ name: 'From', value: `\`${transaction.fromAddress}\``, inline: true },
{ name: 'To', value: `\`${transaction.toAddress ?? 'contract'}\``, inline: true },
{ name: 'Type', value: transaction.txType, inline: true },
{ name: 'Value', value: `${valueEth.toFixed(6)} ETH`, inline: true },
{ name: 'Block', value: transaction.blockNumber.toString(), inline: true },
{ name: 'Status', value: transaction.status === 1 ? '✅ Success' : '❌ Failed', inline: true },
{ name: 'Explorer', value: `[View on Etherscan](${etherscanLink})`, inline: false },
],
timestamp: new Date(transaction.timestamp).toISOString(),
footer: { text: '0watch • NFT Whale Tracker' },
};
await postDiscordEmbed(embed);
}
function chainName(chainId: number): string {
const names: Record<number, string> = { 1: '', 8453: 'basescan.org', 42161: 'arbiscan.io' };
return names[chainId] ?? '';
}
Step 5: Post to Discord
async function postDiscordEmbed(embed: object) {
const webhookUrl = process.env.DISCORD_WEBHOOK_URL!;
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: '0watch Whale Tracker',
avatar_url: 'https://0agent.ai/icon.png',
embeds: [embed],
}),
});
if (!response.ok) {
const error = await response.text();
console.error('Discord delivery failed:', error);
}
}
Discord webhooks are simple and reliable. If you need mentions or slash commands, switch to a proper Discord bot. For read-only alerts, a webhook is sufficient.
Step 6: Add a daily summary
Pull yesterday's activity across all tracked wallets and post a summary each morning:
import { ZeroWatchClient } from '@0agent/0watch-sdk';
import { TRACKED_WHALES } from './whales.js';
const client = new ZeroWatchClient({ apiKey: process.env.ZEROWATCH_API_KEY });
async function postDailySummary() {
const lines: string[] = ['**NFT Whale Activity — Last 24h**\n'];
for (const whale of TRACKED_WHALES) {
const { alerts } = await client.getWalletAlerts(whale.address, { limit: 20 });
const { transactions } = await client.getWalletTransactions(whale.address, { limit: 50 });
const summary = await client.getWalletSummary(whale.address);
const dayAgo = Date.now() - 86400_000;
const recentAlerts = alerts.filter(a => a.detectedAt > dayAgo);
const recentTxs = transactions.filter(t => t.timestamp > dayAgo);
if (recentTxs.length === 0) continue;
lines.push(
`**${whale.label}**`,
`• Transactions: ${recentTxs.length}`,
`• Alerts: ${recentAlerts.length}`,
`• Total alerts all-time: ${summary.alertsTriggered}`,
''
);
}
if (lines.length === 1) {
lines.push('No whale activity in the last 24 hours.');
}
await fetch(process.env.DISCORD_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: '0watch Daily',
content: lines.join('\n'),
}),
});
}
// Run at 9am UTC
const now = new Date();
const nextRun = new Date();
nextRun.setUTCHours(9, 0, 0, 0);
if (nextRun <= now) nextRun.setUTCDate(nextRun.getUTCDate() + 1);
setTimeout(() => {
postDailySummary();
setInterval(postDailySummary, 86400_000);
}, nextRun.getTime() - now.getTime());
Expected alert payload
When a tracked whale makes a move above threshold:
{
"event": "high_value_transaction",
"walletAddress": "0x29469395eaf6f95920e59f858042f0e28d98a20b",
"thresholdEth": 100,
"valueEth": 347.5,
"transaction": {
"hash": "0xdef456...",
"blockNumber": 19881234,
"timestamp": 1710518400000,
"fromAddress": "0x29469395eaf6f95920e59f858042f0e28d98a20b",
"toAddress": "0xBlurMarketplace",
"valueWei": "347500000000000000000",
"txType": "erc20_transfer",
"status": 1,
"chainId": 1
}
}
txType: "erc20_transfer" on an NFT-related wallet likely indicates an NFT sale settled in WETH or a token transfer. "uniswap_swap" indicates a DEX trade. "erc20_approval" can signal a wallet approving a marketplace contract before listing.
Adding more tracked wallets
You don't need to redeploy to add wallets. The setup script is idempotent — add a wallet to your list and rerun it:
ZEROWATCH_API_KEY=... node setup-tracking.js
New wallets start monitoring immediately. Existing webhooks are left untouched.
Running in production
A few considerations for running this at scale:
Deduplication — 0watch retries failed deliveries. Store processed transaction.hash values in Redis or a simple SQLite table to prevent duplicate Discord messages.
Rate limiting — Discord webhooks have rate limits. If you're tracking many wallets, queue messages and post with a small delay between them.
Multiple chains — Register the same whale's address on multiple chains to track cross-chain activity:
for (const chain of ['ethereum', 'base', 'arbitrum'] as const) {
await client.createWallet({
address: whale.address,
label: `${whale.label} (${chain})`,
chain,
});
}
Next steps
- Add lookback logic using
getWalletTransactionsto seed historical context when a new whale is added - Cross-reference alert wallets against known mixer addresses for risk scoring
- Build a leaderboard endpoint that ranks tracked wallets by weekly transfer volume
For full API reference, see the 0watch API docs.