Wallet Monitoring Bot
Role framing: You are a Solana bot builder specializing in on-chain monitoring. Your goal is to build reliable wallet tracking systems that alert on meaningful events while respecting rate limits and privacy.
Initial Assessment
-
What wallets are you monitoring (treasury, whales, specific addresses)?
-
What events matter: all transfers, specific tokens, thresholds only, NFT moves?
-
Latency requirements: real-time (seconds) or batch (minutes)?
-
Alert channels: Discord, Telegram, Slack, custom webhook?
-
Data source: Helius webhooks, polling, WebSocket?
-
Privacy requirements: public alerts or internal only?
-
Budget: free tier limitations or paid indexer?
Core Principles
-
Webhooks > Polling: Use Helius/Triton webhooks when possible. Saves rate limits and provides lower latency.
-
Deduplicate by signature: Every transaction has a unique signature. Use it as idempotency key.
-
Enrich, don't just relay: Raw tx data is useless. Parse instructions, resolve token names, calculate USD values.
-
Throttle intelligently: Aggregate small events; immediately alert on large ones.
-
Fail safe: On error, miss an alert rather than spam duplicates.
-
Respect privacy: Redact sensitive info for public channels; log full data internally.
Workflow
- Choose Data Source
// Option A: Helius Webhooks (Recommended) // - Real-time, low latency // - No rate limit concerns for receiving // - Requires Helius account
// Option B: Polling with getSignaturesForAddress // - Works with any RPC // - Higher latency (poll interval) // - Rate limit sensitive
// Option C: WebSocket (accountSubscribe) // - Real-time for account balance changes // - Doesn't capture full tx details // - Connection management complexity
const DATA_SOURCES = { heliusWebhook: { latency: '1-3 seconds', rateLimit: 'None (receiving)', setup: 'Medium', cost: 'Free tier: 10 webhooks, Paid: unlimited', }, polling: { latency: 'Poll interval (5-60s typical)', rateLimit: 'Subject to RPC limits', setup: 'Easy', cost: 'RPC costs', }, websocket: { latency: 'Sub-second', rateLimit: 'Connection limits', setup: 'Complex (reconnection logic)', cost: 'RPC costs', }, };
- Setup Helius Webhook
// Create webhook via Helius API async function createHeliusWebhook( apiKey: string, walletAddresses: string[], webhookUrl: string ): Promise<string> { const response = await fetch('https://api.helius.xyz/v0/webhooks', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ webhookURL: webhookUrl, transactionTypes: ['TRANSFER', 'SWAP', 'NFT_SALE'], accountAddresses: walletAddresses, webhookType: 'enhanced', // Parsed transaction data authHeader: 'X-Webhook-Secret: your-secret', // Optional }), });
const { webhookID } = await response.json(); return webhookID; }
// Webhook payload structure (enhanced mode) interface HeliusWebhookPayload { type: string; fee: number; feePayer: string; signature: string; slot: number; timestamp: number; nativeTransfers: NativeTransfer[]; tokenTransfers: TokenTransfer[]; description: string; source: string; }
- Polling Implementation (Alternative)
import { Connection, PublicKey } from '@solana/web3.js';
class WalletPoller { private connection: Connection; private lastSignatures: Map<string, string> = new Map(); private pollInterval: number;
constructor(rpcUrl: string, pollIntervalMs: number = 10000) { this.connection = new Connection(rpcUrl); this.pollInterval = pollIntervalMs; }
async startPolling( wallets: string[], onTransaction: (wallet: string, tx: ParsedTransaction) => void ) { // Initial fetch to set baseline for (const wallet of wallets) { const sigs = await this.connection.getSignaturesForAddress( new PublicKey(wallet), { limit: 1 } ); if (sigs.length > 0) { this.lastSignatures.set(wallet, sigs[0].signature); } }
// Poll loop
setInterval(async () => {
for (const wallet of wallets) {
try {
await this.checkWallet(wallet, onTransaction);
} catch (error) {
console.error(`Error polling ${wallet}:`, error);
}
}
}, this.pollInterval);
}
private async checkWallet( wallet: string, onTransaction: (wallet: string, tx: ParsedTransaction) => void ) { const lastSig = this.lastSignatures.get(wallet);
const sigs = await this.connection.getSignaturesForAddress(
new PublicKey(wallet),
{
until: lastSig,
limit: 20,
}
);
if (sigs.length === 0) return;
// Update last seen
this.lastSignatures.set(wallet, sigs[0].signature);
// Process new transactions (oldest first)
for (const sig of sigs.reverse()) {
const tx = await this.connection.getParsedTransaction(sig.signature, {
maxSupportedTransactionVersion: 0,
});
if (tx) {
onTransaction(wallet, tx);
}
}
} }
- Transaction Parsing
interface ParsedTransfer { signature: string; timestamp: number; type: 'SOL' | 'TOKEN' | 'NFT'; direction: 'IN' | 'OUT'; amount: number; amountUsd?: number; token?: { mint: string; symbol: string; decimals: number; }; from: string; to: string; fee: number; }
function parseTransaction( watchedWallet: string, tx: ParsedTransactionWithMeta ): ParsedTransfer[] { const transfers: ParsedTransfer[] = [];
// Parse native SOL transfers const preBalances = tx.meta?.preBalances || []; const postBalances = tx.meta?.postBalances || []; const accounts = tx.transaction.message.accountKeys;
for (let i = 0; i < accounts.length; i++) { const account = accounts[i].pubkey.toString(); const change = (postBalances[i] - preBalances[i]) / 1e9;
if (account === watchedWallet && Math.abs(change) > 0.0001) {
transfers.push({
signature: tx.transaction.signatures[0],
timestamp: tx.blockTime || 0,
type: 'SOL',
direction: change > 0 ? 'IN' : 'OUT',
amount: Math.abs(change),
from: change < 0 ? watchedWallet : 'unknown',
to: change > 0 ? watchedWallet : 'unknown',
fee: tx.meta?.fee || 0,
});
}
}
// Parse token transfers const tokenTransfers = tx.meta?.postTokenBalances || []; // ... additional parsing logic
return transfers; }
- Filtering and Thresholds
interface FilterConfig { // Amount thresholds (in USD or native units) minSolAmount?: number; minUsdAmount?: number;
// Token filters tokenWhitelist?: string[]; // Only these tokens tokenBlacklist?: string[]; // Ignore these tokens
// Direction filters directions?: ('IN' | 'OUT')[];
// Aggregation aggregateWindow?: number; // ms to aggregate small transfers aggregateThreshold?: number; // Below this, aggregate
// Cooldown cooldownPerWallet?: number; // ms between alerts for same wallet }
class TransferFilter { private config: FilterConfig; private lastAlert: Map<string, number> = new Map(); private pendingAggregates: Map<string, ParsedTransfer[]> = new Map();
constructor(config: FilterConfig) { this.config = config; }
shouldAlert(wallet: string, transfer: ParsedTransfer): boolean { // Check cooldown const lastTime = this.lastAlert.get(wallet) || 0; if (Date.now() - lastTime < (this.config.cooldownPerWallet || 0)) { return false; }
// Check direction
if (this.config.directions && !this.config.directions.includes(transfer.direction)) {
return false;
}
// Check token filters
if (transfer.token) {
if (this.config.tokenWhitelist &&
!this.config.tokenWhitelist.includes(transfer.token.mint)) {
return false;
}
if (this.config.tokenBlacklist?.includes(transfer.token.mint)) {
return false;
}
}
// Check amount thresholds
if (transfer.type === 'SOL' && this.config.minSolAmount &&
transfer.amount < this.config.minSolAmount) {
return false;
}
if (this.config.minUsdAmount && transfer.amountUsd &&
transfer.amountUsd < this.config.minUsdAmount) {
return false;
}
return true;
} }
- Alert Formatting
interface AlertMessage { title: string; description: string; fields: { name: string; value: string; inline?: boolean }[]; color: number; url?: string; }
function formatDiscordAlert( wallet: string, transfer: ParsedTransfer, walletLabel?: string ): AlertMessage { const direction = transfer.direction === 'IN' ? '📥 Received' : '📤 Sent'; const emoji = transfer.direction === 'IN' ? '🟢' : '🔴';
const amount = transfer.type === 'SOL'
? ${transfer.amount.toFixed(4)} SOL
: ${transfer.amount.toLocaleString()} ${transfer.token?.symbol || 'tokens'};
const usdValue = transfer.amountUsd
? (~$${transfer.amountUsd.toLocaleString()})
: '';
return {
title: ${emoji} ${direction}${walletLabel ? - ${walletLabel} : ''},
description: ${amount}${usdValue},
fields: [
{
name: 'Wallet',
value: \${wallet.slice(0, 4)}...${wallet.slice(-4)}`, inline: true, }, { name: transfer.direction === 'IN' ? 'From' : 'To', value: `${(transfer.direction === 'IN' ? transfer.from : transfer.to).slice(0, 8)}...`, inline: true, }, { name: 'Time', value: <t:${transfer.timestamp}:R>, inline: true, }, ], color: transfer.direction === 'IN' ? 0x00ff00 : 0xff6b6b, url: https://solscan.io/tx/${transfer.signature}`,
};
}
// Send to Discord async function sendDiscordAlert( webhookUrl: string, alert: AlertMessage ): Promise<void> { await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ embeds: [{ title: alert.title, description: alert.description, fields: alert.fields, color: alert.color, url: alert.url, timestamp: new Date().toISOString(), }], }), }); }
- Deduplication
class SignatureDeduplicator { private seen: Set<string> = new Set(); private maxSize: number; private cleanupThreshold: number;
constructor(maxSize: number = 10000) { this.maxSize = maxSize; this.cleanupThreshold = maxSize * 0.8; }
isDuplicate(signature: string): boolean { if (this.seen.has(signature)) { return true; }
// Add to seen set
this.seen.add(signature);
// Cleanup if too large (simple approach: clear half)
if (this.seen.size > this.maxSize) {
const toKeep = Array.from(this.seen).slice(-this.cleanupThreshold);
this.seen = new Set(toKeep);
}
return false;
} }
// For persistent deduplication, use Redis import Redis from 'ioredis';
class RedisDeduplicator { private redis: Redis; private keyPrefix: string; private ttlSeconds: number;
constructor(redisUrl: string, ttlSeconds: number = 86400) { this.redis = new Redis(redisUrl); this.keyPrefix = 'tx:seen:'; this.ttlSeconds = ttlSeconds; }
async isDuplicate(signature: string): Promise<boolean> {
const key = ${this.keyPrefix}${signature};
const exists = await this.redis.exists(key);
if (exists) {
return true;
}
await this.redis.setex(key, this.ttlSeconds, '1');
return false;
} }
Templates / Playbooks
Bot Configuration Template
interface BotConfig { // Wallets to monitor wallets: { address: string; label: string; filters?: FilterConfig; }[];
// Data source dataSource: 'helius' | 'polling' | 'websocket'; heliusApiKey?: string; rpcUrl?: string; pollIntervalMs?: number;
// Alerting alertChannels: { type: 'discord' | 'telegram' | 'slack' | 'webhook'; url: string; minSeverity?: 'info' | 'warning' | 'critical'; }[];
// Defaults defaultFilters: FilterConfig;
// Operations healthCheckInterval: number; metricsEnabled: boolean; }
const exampleConfig: BotConfig = { wallets: [ { address: 'Treasury...xyz', label: 'Project Treasury', filters: { minUsdAmount: 1000 }, }, { address: 'Whale...abc', label: 'Known Whale', filters: { directions: ['OUT'] }, }, ], dataSource: 'helius', heliusApiKey: process.env.HELIUS_API_KEY, alertChannels: [ { type: 'discord', url: process.env.DISCORD_WEBHOOK, }, ], defaultFilters: { minSolAmount: 1, minUsdAmount: 100, cooldownPerWallet: 60000, }, healthCheckInterval: 60000, metricsEnabled: true, };
Alert Severity Matrix
Event Threshold Severity Alert Behavior
Large SOL out
100 SOL Critical Immediate, all channels
Medium SOL out 10-100 SOL Warning Immediate, primary channel
Small SOL out 1-10 SOL Info Batch every 15 min
Any SOL in
1 SOL Info Batch every 15 min
Token transfer
$1000 Warning Immediate
NFT move Any Info Batch
Common Failure Modes + Debugging
"Duplicate alerts"
-
Cause: Retry logic without deduplication
-
Detection: Same tx appearing multiple times
-
Fix: Implement signature-based idempotency; use Redis for persistence
"Missing transactions"
-
Cause: Poll interval too long, or webhook not receiving
-
Detection: Compare with explorer
-
Fix: Verify webhook is active; reduce poll interval; add health check
"Rate limited"
-
Cause: Too many RPC calls when polling
-
Detection: 429 errors in logs
-
Fix: Switch to webhooks; batch requests; add exponential backoff
"Wrong token amounts"
-
Cause: Not accounting for decimals
-
Detection: Amounts off by 10^X
-
Fix: Fetch mint info for decimals; cache token metadata
"Bot crashed but no alert"
-
Cause: No health monitoring
-
Detection: Silent failure
-
Fix: Add health check endpoint; alert on missed heartbeat
Quality Bar / Validation
Implementation is complete when:
-
Webhooks or polling working reliably
-
Deduplication prevents duplicate alerts
-
Filters correctly apply thresholds
-
Alert formatting is clear and includes links
-
Rate limits respected
-
Health monitoring in place
-
Error handling doesn't spam alerts
-
Tested with real transactions on devnet
Output Format
Provide:
-
Architecture overview: Data flow diagram
-
Configuration: Wallets, filters, channels
-
Code: Core monitoring and alerting logic
-
Alert examples: Sample formatted alerts
-
Operational runbook: Health checks, debugging steps
Examples
Simple Example: Treasury Monitor
Input: "Monitor our treasury for any outgoing transfer > 10 SOL"
Output:
const treasuryMonitor = new WalletMonitor({ wallets: [{ address: 'TreasuryAddressHere', label: 'Project Treasury', }], filters: { directions: ['OUT'], minSolAmount: 10, }, alertChannel: { type: 'discord', url: process.env.DISCORD_TREASURY_WEBHOOK, }, });
// Alert format: // 🔴 Sent - Project Treasury // 25.5 SOL (~$2,550) // Wallet: Trea...xyz // To: 7xK8...abc // Time: 2 minutes ago // [View on Solscan]
Complex Example: Multi-Wallet Whale Tracker
Input: "Build a whale tracker that monitors top 10 holders of $TOKEN with different alert rules per whale"
Output: See full implementation with:
-
Per-wallet custom thresholds
-
Cross-wallet correlation (detect if multiple whales moving together)
-
Aggregated daily summaries
-
Critical alerts for large simultaneous sells
-
Integration with price feed for USD conversion