In October I audited the first version of the buy bot I was selling.

I found 5 bugs. One of them doubled every buy amount. Another made the bot post zero alerts on the exact setup most buyers use: a Linux VPS.

I stopped selling v1 the same week and rewrote it from scratch. This is everything that went wrong, how I fixed it, and the code you need if you're building anything that reads Solana swaps.

What v1 was

A Python bot that watches a Solana token and posts buys to Telegram or Discord. It was aimed at traders. It worked on my machine, which, as you'll see, was the problem.

The 5 bugs

1. Every amount was doubled

v1 added up the token transfers inside a transaction. A swap moves tokens out of the pool and into the buyer's account, so the same tokens were counted on both legs.

The fix is to stop reading transfers and read balances: what each wallet held before the transaction, and after. More on that below.

2. Zero alerts on Linux

The variable names in .env didn't match the names the code read. On Windows, environment variable names are case-insensitive, so it worked. On Linux, they're case-sensitive, so the bot started, connected, and silently never posted anything.

Most people run a bot like this on a Linux VPS. I tested on Windows.

3. Dotfiles shipped without the dot

.env.example and .gitignore were packaged as env.example and gitignore. The first one confused people. The second one is worse: a .gitignore without its dot does nothing, so anyone who pushed their bot to GitHub could have pushed their bot token and RPC key with it.

4. Alerts lost without a trace

When Telegram or Discord rate-limited the bot, or the RPC timed out, the alert was dropped. No retry, no log line. From the outside, it looked like nobody was buying.

5. No price memory

The bot kept no record of prices between alerts, so it had nothing to fall back on when a price lookup failed, and nothing to compare against.

The 4 promises I couldn't keep

The sales page said "sub-second alerts", "no third-party API", "tracks exchange wallets" and "customizable layouts". None of these held up when I checked. I removed all four.

If you sell developer tools: every claim on your page is a test case. I just hadn't written the tests.

Bonus: the free version flooded Discord

The free Lite edition had no rate control. On an active token, it buried Discord channels in alerts. Not a great first impression for a free product meant to earn trust.

The rewrite: compute the swap from balances, not transfers

Every transaction you get from getTransaction includes preTokenBalances and postTokenBalances in its meta. Each entry has the token mint, the owner wallet and the amount. The net change per owner is the truth:

from collections import defaultdict from decimal import Decimal def net_token_changes(meta: dict, mint: str) -> dict[str, Decimal]: """Net change of one token, per owner wallet, for one transaction.""" changes: dict[str, Decimal] = defaultdict(Decimal) for side, sign in (("preTokenBalances", -1), ("postTokenBalances", 1)): for bal in meta.get(side) or []: if bal.get("mint") != mint or not bal.get("owner"): continue amount = Decimal(bal["uiTokenAmount"]["uiAmountString"] or "0") changes[bal["owner"]] += sign * amount return {owner: delta for owner, delta in changes.items() if delta != 0} 

From there:

  • The buyer is the wallet with a positive change that is not a pool. Pools are never counted as buyers, even though their balances change too.
  • What they paid comes from the same idea applied to SOL (preBalances / postBalances) or USDC, with the network fee and the rent for new token accounts excluded. Otherwise every first-time buyer looks like they paid a little more.
  • A split route (one buy spread across several pools) becomes a single alert with the full amount, because you're reading the wallet, not the pools.

The rewrite: never lose an alert

  • One WebSocket connection, with automatic reconnection.
  • Catch-up after a disconnection: the bot remembers the last signature it processed and fetches what it missed.
  • No duplicates: every processed signature goes into SQLite, so a catch-up never posts the same buy twice.
  • Rate limits respected for Telegram and Discord, with retries instead of silent drops.
  • Clear configuration errors: a missing or misspelled variable stops the bot with a message that says which one.

Testing on mainnet: 3 more bugs in 72 hours

From October 28 to 30, I ran the new version on POPCAT, a busy token, and compared every alert with Solscan. Buys, sells and transfers matched. It still found three bugs:

  1. A new transaction format ("version 1") that the parser didn't handle yet. Fixed in v2.0.1.
  2. Silence looked like a crash. On a quiet hour, you couldn't tell if the bot was alive. It now prints an "Alive" line every 15 minutes. v2.0.2.
  3. "Position +594,210%". A wallet that held a few leftover tokens of dust and then bought for real showed an absurd position increase. Anyone holding less than a meaningful amount before the buy is now a "New holder!". v2.0.3.

That last one would have looked ridiculous in a client's Telegram group. Only real mainnet traffic surfaces bugs like that.

What it still doesn't do

Watching a specific Raydium pool doesn't work yet: Raydium manages its pool accounts in a way my pool watcher doesn't handle. Token buys through Raydium are detected fine. It's documented, and on the list.

I'd rather tell you than have you find out.

What I'd tell anyone shipping a bot

  • Test on the OS your users run, not the one you develop on.
  • Check your packaged files, dotfiles included. One ls -a would have caught bug #3.
  • Compare against a block explorer for every transaction type: buy, sell, transfer, split route.
  • Read balances, not instructions, whenever you need "how much did this wallet get".
  • Never promise a number you haven't measured.

Try it

Full disclosure: I also sell a paid edition with sell alerts, your own branding and Guardian security alerts for token teams, at ship.opsnode.link. The Lite is free for good.

If you've hit any of these bugs in your own Solana project, I'd like to hear how you solved them.