I built a real-time KOL wallet tracker that monitors 463 Solana wallets — here's how it works
Every second, hundreds of transactions flow through Solana's DEX programs. Somewhere in that firehose, a whale wallet just bought 50 SOL of a token nobody's heard of. By the time it shows up on a block explorer, the price has already moved.
I built a system that detects these trades in real-time for 463 tracked KOL (Key Opinion Leader) wallets — every buy and sell, within seconds. It's processed over 100,000 trades across 10,900+ unique tokens so far.
Here's how it works.
The Architecture
Kaldera gRPC (Frankfurt) ──┐
├──▶ Parse ──▶ Filter KOL wallets ──▶ PostgreSQL
Kaldera gRPC (New York) ───┘ ▲ │
│ ▼
Dedup via WebSocket
tx_signature fan-out to
ON CONFLICT frontend
The core idea: subscribe to every DEX transaction on Solana via gRPC, filter client-side for wallets we care about, then store and broadcast the trades.
Why gRPC, Not WebSocket or Polling
Most Solana data tools use WebSocket subscriptions (onLogs, onAccountChange) or poll RPC endpoints. Both have problems at this scale:
- WebSocket subscriptions on public RPCs have strict limits (usually 100-200 subscriptions). We track 463 wallets across 9 DEX programs — that's way over the limit.
-
Polling
getSignaturesForAddressfor 463 wallets would mean ~463 RPC calls every few seconds. You'll hit rate limits instantly.
gRPC (via Yellowstone/Geyser plugins) gives you the full transaction firehose at the commitment level you choose. One subscription, all DEX transactions, filtered client-side:
import Client, { CommitmentLevel } from "@triton-one/yellowstone-grpc";
const DEX_PROGRAMS = [
"6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P", // Pump.fun
"pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA", // Pump AMM
"675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8", // Raydium V4
"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4", // Jupiter V6
"whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc", // Orca Whirlpool
// ... 4 more
];
const stream = await client.subscribe();
stream.write({
transactions: {
swaps: {
accountInclude: DEX_PROGRAMS,
vote: false,
failed: false,
},
},
commitment: CommitmentLevel.CONFIRMED,
});
This gives us every confirmed DEX swap. On a typical day, that's ~15,000-20,000 transactions every 30 seconds. We then check if any account in the transaction matches our KOL wallet set.
The Dual-Stream Failover
gRPC connections reset hourly (HTTP/2 GOAWAY frames), and servers occasionally go down for maintenance. With a single stream, you get a gap: disconnect → reconnect → backfill. During that gap, you miss trades.
The fix: run two streams simultaneously from different regions.
const GRPC_REGIONS = [
{ url: process.env.KALDERA_GRPC_URL, label: "Frankfurt" },
{ url: process.env.KALDERA_GRPC_URL_FALLBACK, label: "New York" },
];
const streams = new Map();
await Promise.allSettled(
GRPC_REGIONS.map(region => connectRegion(region))
);
Both streams fire simultaneously. When Frankfurt disconnects for its hourly h2 reset, New York keeps detecting trades. When New York reconnects, Frankfurt covers it. Zero gaps.
"But won't you get duplicate trades?" Yes — and that's fine. PostgreSQL handles it:
INSERT INTO kol_trades (kol_wallet_id, token_mint, action, sol_amount, tx_signature, ...)
ON CONFLICT (tx_signature) DO NOTHING
The tx_signature is unique per transaction. Duplicates are silently dropped. In practice, both streams process ~18,000 tx each per 30-second window, but only unique trades get stored.
Parsing the Swap
A DEX transaction doesn't come with a nice label saying "buy 2.5 SOL of TOKEN_X." You have to reconstruct the trade from raw balance changes:
function parseSwap(txUpdate) {
const meta = txUpdate.transaction?.transaction?.meta;
const msg = txUpdate.transaction?.transaction?.message;
// Build full account key list (static + loaded)
const accountKeys = [
...msg.accountKeys.map(k => bs58.encode(Buffer.from(k))),
...meta.loadedWritableAddresses.map(k => bs58.encode(Buffer.from(k))),
...meta.loadedReadonlyAddresses.map(k => bs58.encode(Buffer.from(k))),
];
// Find our KOL wallet in the transaction
let kolAddress = null;
for (const key of accountKeys) {
if (kolWalletSet.has(key)) {
kolAddress = key;
break;
}
}
if (!kolAddress) return null;
// SOL balance change = sell amount or buy cost
const preSol = Number(meta.preBalances[walletIndex]) / 1e9;
const postSol = Number(meta.postBalances[walletIndex]) / 1e9;
const solChange = postSol - preSol;
// Token balance changes (SPL tokens)
for (const post of meta.postTokenBalances) {
if (post.owner !== kolAddress) continue;
const pre = meta.preTokenBalances.find(
p => p.accountIndex === post.accountIndex
);
const change = (post.uiTokenAmount?.uiAmount ?? 0)
- (pre?.uiTokenAmount?.uiAmount ?? 0);
if (Math.abs(change) > 0.000001) {
tokenChanges.push({ mint: post.mint, change });
}
}
// Positive token change + negative SOL change = buy
// Negative token change + positive SOL change = sell
const action = primaryToken.change > 0 ? "buy" : "sell";
}
The key insight: compare preBalances vs postBalances for SOL, and preTokenBalances vs postTokenBalances for SPL tokens. The wallet that gained tokens and lost SOL is buying. Vice versa for selling.
Handling the Edge Cases
Token metadata isn't available immediately. When a token launches on Pump.fun, the Metaplex metadata might not be queryable for a few seconds. We store trades with token_name: null and retry every 2 minutes:
setInterval(async () => {
const { rows } = await pool.query(
`SELECT DISTINCT token_mint FROM kol_trades
WHERE token_name IS NULL
AND traded_at > NOW() - INTERVAL '24 hours' LIMIT 20`
);
for (const { token_mint } of rows) {
const meta = await getTokenMeta(token_mint);
if (meta?.name) {
await pool.query(
`UPDATE kol_trades SET token_name = $1, token_symbol = $2
WHERE token_mint = $3 AND token_name IS NULL`,
[meta.name, meta.symbol, token_mint]
);
}
}
}, 2 * 60 * 1000);
RPC can go down. Metadata fetches use a circuit breaker — after 5 consecutive failures, we pause RPC calls for 30 seconds instead of hammering a dead endpoint.
Backfill on reconnect. When the service starts (or both streams were down), we check the last 10 transactions of recently active wallets via getSignaturesForAddress to catch anything missed.
The Watchdog
Each stream has an independent watchdog. If no data arrives for 60 seconds, the stream is considered dead and gets force-reconnected:
setInterval(() => {
for (const region of GRPC_REGIONS) {
const state = streams.get(region.label);
const silenceMs = Date.now() - state.lastAliveAt;
if (silenceMs > 60000 && state.stream) {
log(`[${region.label}] Watchdog: forcing reconnect`);
state.stream.cancel();
state.stream = null;
scheduleReconnect(region); // exponential backoff
}
}
}, 30000);
The heartbeat file (/tmp/kol-tracker-heartbeat) gets picked up by our health monitor, which can auto-restart the PM2 process if things go truly sideways.
Results
The system runs 24/7 on a single Hetzner VPS (8GB RAM), tracking 463 KOL wallets across 9 DEX programs. Some numbers:
- 100,000+ trades detected so far
- ~21,000 trades per day on active days
- 10,900+ unique tokens tracked
- <3 second latency from on-chain confirmation to database
- Zero gaps since implementing dual-stream failover
- ~200MB memory footprint per stream
The whole thing is a single Node.js process managed by PM2. No Kubernetes, no message queues, no microservices. Sometimes the simplest architecture is the right one.
Stack
- gRPC: @triton-one/yellowstone-grpc (Yellowstone Geyser plugin client)
- Database: PostgreSQL (self-hosted Supabase)
- Process: PM2 on Ubuntu
- Infrastructure: Kaldera gRPC nodes (Frankfurt + New York)
- Frontend: Next.js with WebSocket fan-out for real-time updates
The KOL Tracker is part of madeonsol.com/kol-tracker — a directory of 950+ Solana tools I've been building. The live leaderboard shows who's profiting and who's not, updated in real-time.
If you're building on Solana and want to work with gRPC streams, the Yellowstone plugin is incredible. Just make sure you handle disconnects gracefully — the firehose is powerful but unforgiving.
Top comments (0)