I kept seeing BONKbot numbers on Dune dashboards. Millions in daily volume. Thousands of users buying and selling memecoins through a Telegram chat. No website, no app, just a bot. I had to figure out how this thing works under the hood.

So I built one. It took me a weekend to get the core working, and honestly, the code isn’t that complicated. The architecture is straightforward: Telegram bot receives a command, hits Jupiter for a swap quote, signs the transaction, sends it to Solana, and replies with the result. That’s it.

Here’s how I put the whole thing together.


Setting Up the Telegram Bot

First, you need a bot token from BotFather on Telegram. Message @BotFather, run /newbot, pick a name. You’ll get a token.

Then set up the project:

import TelegramBot from "node-telegram-bot-api";
import {
  Connection,
  Keypair,
  VersionedTransaction,
} from "@solana/web3.js";
import bs58 from "bs58";
import crypto from "crypto";

const bot = new TelegramBot(process.env.TELEGRAM_TOKEN, { polling: true });
const connection = new Connection(process.env.SOLANA_RPC_URL);

const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; // 32-byte hex string
const FEE_BPS = 50; // 0.5% fee on each trade
const FEE_WALLET = process.env.FEE_WALLET;

I use node-telegram-bot-api because it’s dead simple and handles polling out of the box. You could use webhooks for production, but polling is fine for development.


Generating Wallets for Users

Every user needs a Solana wallet. When someone hits /start, I generate a new keypair and store it encrypted. I made the mistake early on of storing keys in plain text in a JSON file. Don’t do that. Ever.

Here’s what I do for encryption:

function encrypt(text: string): string {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv(
    "aes-256-cbc",
    Buffer.from(ENCRYPTION_KEY, "hex"),
    iv
  );
  let encrypted = cipher.update(text, "utf8", "hex");
  encrypted += cipher.final("hex");
  return iv.toString("hex") + ":" + encrypted;
}

function decrypt(data: string): string {
  const [ivHex, encrypted] = data.split(":");
  const decipher = crypto.createDecipheriv(
    "aes-256-cbc",
    Buffer.from(ENCRYPTION_KEY, "hex"),
    Buffer.from(ivHex, "hex")
  );
  let decrypted = decipher.update(encrypted, "hex", "utf8");
  decrypted += decipher.final("utf8");
  return decrypted;
}

And the /start handler:

// In-memory store — use a real database in production
const users = new Map<number, { encryptedKey: string; slippage: number }>();

bot.onText(/\/start/, async (msg) => {
  const chatId = msg.chat.id;

  if (users.has(chatId)) {
    bot.sendMessage(chatId, "You already have a wallet set up.");
    return;
  }

  const keypair = Keypair.generate();
  const secretKey = bs58.encode(keypair.secretKey);
  const encryptedKey = encrypt(secretKey);

  users.set(chatId, { encryptedKey, slippage: 100 }); // 1% default slippage

  bot.sendMessage(
    chatId,
    `Wallet created!\n\nAddress: \`${keypair.publicKey.toBase58()}\`\n\nSend SOL to this address to start trading.`,
    { parse_mode: "Markdown" }
  );
});

Each user gets their own wallet with a default slippage of 1%. That’s high, but for memecoins it needs to be. I’ve had users complain about failed transactions when slippage was set to 0.5%.


The Buy Command

This is where the magic happens. User sends /buy <token_address> <amount_in_sol>, and the bot executes a swap through Jupiter.

async function getJupiterQuote(
  inputMint: string,
  outputMint: string,
  amount: number,
  slippageBps: number
) {
  const params = new URLSearchParams({
    inputMint,
    outputMint,
    amount: amount.toString(),
    slippageBps: slippageBps.toString(),
  });

  const res = await fetch(`https://quote-api.jup.ag/v6/quote?${params}`);
  return res.json();
}

async function executeSwap(quoteResponse: any, userKeypair: Keypair) {
  const swapRes = await fetch("https://quote-api.jup.ag/v6/swap", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      quoteResponse,
      userPublicKey: userKeypair.publicKey.toBase58(),
      wrapAndUnwrapSol: true,
    }),
  });

  const { swapTransaction } = await swapRes.json();
  const txBuf = Buffer.from(swapTransaction, "base64");
  const tx = VersionedTransaction.deserialize(txBuf);

  tx.sign([userKeypair]);
  const signature = await connection.sendRawTransaction(tx.serialize(), {
    skipPreflight: true,
    maxRetries: 3,
  });

  await connection.confirmTransaction(signature, "confirmed");
  return signature;
}

I use skipPreflight: true because preflight checks add latency. When you’re trading memecoins, speed matters more than safety checks. The transaction will either land or it won’t.

Now the actual bot command:

const SOL_MINT = "So11111111111111111111111111111111111111112";

bot.onText(/\/buy (.+) (.+)/, async (msg, match) => {
  const chatId = msg.chat.id;
  const tokenMint = match[1];
  const solAmount = parseFloat(match[2]);

  const user = users.get(chatId);
  if (!user) {
    bot.sendMessage(chatId, "Run /start first to create a wallet.");
    return;
  }

  const lamports = Math.floor(solAmount * 1e9);

  bot.sendMessage(chatId, "Fetching quote...");

  try {
    const quote = await getJupiterQuote(
      SOL_MINT, tokenMint, lamports, user.slippage
    );

    const secretKey = decrypt(user.encryptedKey);
    const keypair = Keypair.fromSecretKey(bs58.decode(secretKey));
    const signature = await executeSwap(quote, keypair);

    bot.sendMessage(
      chatId,
      `Bought! [View on Solscan](https://solscan.io/tx/${signature})`,
      { parse_mode: "Markdown" }
    );
  } catch (err) {
    bot.sendMessage(chatId, `Trade failed: ${err.message}`);
  }
});

The Sell Command

Selling is the same flow in reverse. Swap the token back to SOL.

bot.onText(/\/sell (.+) (.+)/, async (msg, match) => {
  const chatId = msg.chat.id;
  const tokenMint = match[1];
  const tokenAmount = parseInt(match[2]);

  const user = users.get(chatId);
  if (!user) {
    bot.sendMessage(chatId, "Run /start first.");
    return;
  }

  bot.sendMessage(chatId, "Fetching sell quote...");

  try {
    const quote = await getJupiterQuote(
      tokenMint, SOL_MINT, tokenAmount, user.slippage
    );

    const secretKey = decrypt(user.encryptedKey);
    const keypair = Keypair.fromSecretKey(bs58.decode(secretKey));
    const signature = await executeSwap(quote, keypair);

    bot.sendMessage(
      chatId,
      `Sold! [View on Solscan](https://solscan.io/tx/${signature})`,
      { parse_mode: "Markdown" }
    );
  } catch (err) {
    bot.sendMessage(chatId, `Sell failed: ${err.message}`);
  }
});

One thing I learned: you need to pass the raw token amount (with decimals) for sells. If a token has 6 decimals and you want to sell 100 tokens, the amount is 100000000. I got this wrong the first time and kept getting “insufficient balance” errors.


Checking Balances

Users want to see what they’re holding. The /balance command pulls SOL balance and all token accounts.

import { PublicKey } from "@solana/web3.js";

bot.onText(/\/balance/, async (msg) => {
  const chatId = msg.chat.id;
  const user = users.get(chatId);
  if (!user) {
    bot.sendMessage(chatId, "Run /start first.");
    return;
  }

  const secretKey = decrypt(user.encryptedKey);
  const keypair = Keypair.fromSecretKey(bs58.decode(secretKey));
  const pubkey = keypair.publicKey;

  const solBalance = await connection.getBalance(pubkey);
  const tokenAccounts = await connection.getParsedTokenAccountsByOwner(
    pubkey,
    { programId: new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") }
  );

  let message = `*SOL Balance:* ${(solBalance / 1e9).toFixed(4)} SOL\n\n`;

  for (const account of tokenAccounts.value) {
    const info = account.account.data.parsed.info;
    const amount = info.tokenAmount.uiAmountString;
    if (parseFloat(amount) > 0) {
      message += `Token: \`${info.mint}\`\nAmount: ${amount}\n\n`;
    }
  }

  bot.sendMessage(chatId, message, { parse_mode: "Markdown" });
});

Quick Action Buttons

Nobody wants to type out a full token address every time. Inline keyboards make this way better. Here’s how I add quick buy buttons when a user pastes a token address:

bot.onText(/\/trade (.+)/, async (msg, match) => {
  const chatId = msg.chat.id;
  const tokenMint = match[1];

  bot.sendMessage(chatId, `Buy ${tokenMint.slice(0, 8)}... with SOL:`, {
    reply_markup: {
      inline_keyboard: [
        [
          { text: "0.1 SOL", callback_data: `buy:${tokenMint}:0.1` },
          { text: "0.5 SOL", callback_data: `buy:${tokenMint}:0.5` },
          { text: "1 SOL", callback_data: `buy:${tokenMint}:1` },
        ],
        [
          { text: "Sell 25%", callback_data: `sell:${tokenMint}:25` },
          { text: "Sell 50%", callback_data: `sell:${tokenMint}:50` },
          { text: "Sell 100%", callback_data: `sell:${tokenMint}:100` },
        ],
      ],
    },
  });
});

bot.on("callback_query", async (query) => {
  const chatId = query.message.chat.id;
  const [action, tokenMint, value] = query.data.split(":");

  if (action === "buy") {
    const lamports = Math.floor(parseFloat(value) * 1e9);
    const user = users.get(chatId);
    if (!user) return;

    try {
      const quote = await getJupiterQuote(
        SOL_MINT, tokenMint, lamports, user.slippage
      );
      const secretKey = decrypt(user.encryptedKey);
      const keypair = Keypair.fromSecretKey(bs58.decode(secretKey));
      const sig = await executeSwap(quote, keypair);

      bot.sendMessage(
        chatId,
        `Bought with ${value} SOL! [Tx](https://solscan.io/tx/${sig})`,
        { parse_mode: "Markdown" }
      );
    } catch (err) {
      bot.sendMessage(chatId, `Failed: ${err.message}`);
    }
  }

  bot.answerCallbackQuery(query.id);
});

These buttons are what make the bot feel fast. Tap, confirm, done. That’s the whole UX.


Slippage Settings

Different tokens need different slippage. Bluechips like SOL/USDC work fine at 0.5%, but a fresh memecoin might need 5-10%. I let users configure this:

bot.onText(/\/slippage (.+)/, async (msg, match) => {
  const chatId = msg.chat.id;
  const bps = parseInt(match[1]);

  if (isNaN(bps) || bps < 1 || bps > 5000) {
    bot.sendMessage(chatId, "Slippage must be between 1 and 5000 bps.");
    return;
  }

  const user = users.get(chatId);
  if (!user) return;

  user.slippage = bps;
  bot.sendMessage(chatId, `Slippage set to ${bps / 100}%`);
});

The Business Model

Here’s how these bots make money: take a fee on every swap. BONKbot charges 1% on each transaction. That adds up fast when you’re processing millions in volume.

I charge 0.5%. You can add the fee as a separate SOL transfer in the same transaction, or you can adjust the swap amount so the bot skims a portion before executing. The simpler approach is a post-trade transfer from the user’s wallet to your fee wallet. Users can see it, it’s transparent, and it builds trust.

At scale, even 0.5% on $1M daily volume is $5,000 a day. That’s why everyone’s building these bots.


Security Is Everything

I can’t stress this enough: if you’re building a bot that holds user funds, security is your number one concern. Not features, not speed. Security.

Here’s my checklist:

  • Encrypt all private keys at rest with AES-256. Never store raw keys.
  • Use environment variables for all secrets. No hardcoded keys.
  • Rate limit commands to prevent abuse. One trade per 5 seconds per user.
  • Set transaction limits. Cap max trade size until users verify.
  • Log everything. If something goes wrong, you need an audit trail.
  • Run the bot on an isolated server. Don’t put it on the same machine as your other projects.

I used to think “it’s just a side project, security doesn’t matter.” Then I read about a Telegram bot that got drained because the developer left an API key in a public repo. Don’t be that person.


Why These Bots Took Off

It’s not about the tech. The tech is simple. It’s about convenience.

Opening Phantom, connecting to Jupiter, pasting a token address, setting slippage, approving the transaction — that’s 30 seconds minimum. On a Telegram bot, you paste an address, tap a button, and it’s done in 5 seconds. On your phone, in a group chat, while you’re at lunch.

Speed kills in memecoin trading. The difference between buying 10 seconds after a token launches and 60 seconds after can be a 10x difference in entry price. Telegram bots win because they cut out every unnecessary step.

The other thing: Telegram groups. When someone posts a token address in a group of 10,000 people, and there’s a bot right there that lets you buy with one tap — that’s a distribution channel money can’t buy.


What I’d Do Differently

If I were starting over, I’d skip the in-memory store and go straight to Redis for user data. I’d also add a proper queue for transaction processing instead of firing them off inline. When ten users hit buy at the same time, you want those transactions queued and processed reliably, not all racing against each other.

I’d also add priority fees from day one. Solana’s fee market means your transactions get dropped if you’re not paying enough during high congestion. Jupiter’s API supports priority fee parameters — use them.

The bot itself is simple. A weekend of work gets you something functional. The hard part isn’t the code. It’s getting users to trust a Telegram bot with their money. That’s the real challenge, and no amount of clean architecture solves it. You need transparency, a track record, and a community that vouches for you. Without that, you’re just another bot in a sea of bots.