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


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


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

For full API reference, see the 0watch API docs.