Build a Solana Telegram Trading Bot
In about ten minutes, you'll have your own Telegram bot that swaps any Solana token, with a button menu, real prices, and a wallet that's just for the bot. No laptop, no coding experience required — just a phone, Telegram, and a Solana wallet you can send a few dollars from.
This guide is the phone-first companion to Build a Trading Bot, which covers the broader infrastructure (latency, persistence, dedicated submission). If you want a working bot you can ship today and trade with, you're in the right place. If you want a production backend with a database and 24/7 monitoring, start there instead.
What you'll build
A Telegram bot you control by tapping buttons. The main menu looks like this:
[ 💰 Buy ] [ 💸 Sell ]
[ 👛 Wallet ] [ 📊 Balance ] [ 📜 History ]
[ 💵 Price ] [ 📋 Quote ]
[ ⚙️ Settings ] [ ❓ Help ]Tap a button, the bot asks one question, you answer. Behind the scenes it uses the Venum.dev SDK to find the best price across every Solana DEX, builds an unsigned transaction via /v1/swap/build, signs it locally with the bot's keypair, and submits it through /v1/swap. Token symbols and decimals come from /v1/search and /v1/token/:mint. Your wallet's holdings come from /v1/balances. Solana RPC reads, when you need them, hit rpc.venum.dev — same API key.
Before you start
You need:
- A phone with Telegram installed and a normal browser (Chrome or Safari).
- A Solana wallet you already use (Phantom, Backpack, an exchange withdrawal — anything that can send SOL).
- A small amount of SOL for the demo. Start with whatever you can afford to lose.
- About 10 minutes.
You do not need: a laptop, a credit card, a developer account, or any prior coding knowledge.
If you'd rather run the whole thing through Replit Agent than copy code yourself, jump to Use Replit Agent — paste one prompt, get the working files.
Step 1 · Create your bot on Telegram
Telegram lets anyone create a bot for free in under two minutes. You do it by chatting with another bot called BotFather.
Open Telegram.
Tap the search icon at the top and type
BotFather.Tap the result with a blue checkmark next to the name. That's the official one.
Tap Start to open the conversation.
Send
/newbot.BotFather asks for a name — what users see. Type something like
My Trading Bot.BotFather asks for a username — must end in
bot. Trymytradingbot_<initials>_bot. Iterate until one is free.BotFather replies with a long block of text. The important line:
Use this token to access the HTTP API: 1234567890:AAEhBPxxxxxxxxxxxxxxxxxxxxxxxxxxxxxThat long string is your bot token. Treat it like a password.
Tap and hold on the token, then tap Copy.
Save it somewhere you can paste from
On a phone, the easiest way to keep it for the next few minutes is to send it to yourself in Telegram's "Saved Messages" chat. You'll paste it into Replit shortly.
You can close BotFather now. The bot exists, but no code is running for it yet.
Step 2 · Get a Venum.dev API key
Venum.dev is the engine that does the actual trading. One free key powers the SDK, the HTTP API, and the Solana RPC — three things in one.
- Open your browser and go to app.venum.dev.
- Tap Sign in with Google and pick your account. No credit card required.
- From the dashboard, find the API Keys section.
- Tap Create key if there isn't one. Name it something like
telegram-bot. - Tap the key to reveal it, then Copy.
Send it to your Saved Messages in Telegram so you can paste it shortly.
For a deeper read on auth (rate limits, key rotation, server-side use), see Authentication.
Step 3 · Create a Replit
Replit runs your bot in the cloud. The free tier is enough for personal use, the mobile app works fine, and Secrets are stored safely.
- Open replit.com and sign up with Google (or log in).
- Tap Create Repl (or the + button).
- Pick the Node.js template.
- Name it
telegram-trading-botand tap Create Repl.
You land in the editor with two files: index.js and package.json. You'll replace both and add a third in the next step.
Step 4 · Add the bot code
Your bot is one file. In Replit's file panel, tap + → New file, name it src/index.ts (with the src/ prefix — Replit creates the folder), and paste the entire file below.
Replit auto-installs the imports (grammy, @venumdev/sdk, @solana/web3.js, bs58, tsx) the first time you click Run, so you don't need a package.json or tsconfig.json.
Tap and hold to select all
On a phone, tap inside the code block, hold, then drag the selection handles to cover the whole thing. Tap Copy, then tap inside the new src/index.ts file in Replit and paste.
// ─── Imports ───────────────────────────────────────────────────────────────
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Bot, InlineKeyboard, type Context } from "grammy";
import { Keypair, VersionedTransaction } from "@solana/web3.js";
import { VenumClient, VenumApiError } from "@venumdev/sdk";
import type {
TokenSearchResult,
SwapBuildResponse,
QuoteRoute,
BalanceEntry,
} from "@venumdev/sdk";
import bs58 from "bs58";
// ─── Constants ─────────────────────────────────────────────────────────────
const SOL_MINT = "So11111111111111111111111111111111111111112";
const WALLET_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "wallet.json");
const DEFAULT_SLIPPAGE_BPS = 50;
const SWAP_EXPIRY_MS = 30_000;
const TX_CONFIRM_TIMEOUT_MS = 30_000;
// ─── Logger ────────────────────────────────────────────────────────────────
type LogLevel = "INFO" | "WARN" | "ERROR";
function ts(): string {
return new Date().toISOString();
}
function log(level: LogLevel, tag: string, msg: string, extra?: Record<string, unknown>): void {
const extraStr = extra ? " " + JSON.stringify(extra) : "";
const line = `[${ts()}] ${level.padEnd(5)} [${tag}] ${msg}${extraStr}`;
if (level === "ERROR") {
process.stderr.write(line + "\n");
} else {
process.stdout.write(line + "\n");
}
}
function logError(tag: string, err: unknown, extra?: Record<string, unknown>): void {
const base: Record<string, unknown> = { ...extra };
if (err instanceof VenumApiError) {
base["type"] = "VenumApiError";
base["status"] = err.status;
base["message"] = err.message;
} else if (err instanceof Error) {
base["type"] = err.constructor.name;
base["message"] = err.message;
base["stack"] = err.stack;
} else {
base["raw"] = String(err);
}
log("ERROR", tag, "error", base);
}
function userLabel(ctx: Context): string {
const u = ctx.from;
if (!u) return "unknown";
return u.username ? `@${u.username}` : `id:${u.id}`;
}
// ─── Env Validation ────────────────────────────────────────────────────────
function requireEnv(key: string): string {
const val = process.env[key];
if (!val) {
process.stderr.write(
`\n[${ts()}] ERROR [env] Missing required secret: ${key}\n` +
` Open Replit Secrets (the lock icon) and add ${key}, then restart.\n\n`
);
process.exit(1);
}
return val;
}
const TELEGRAM_BOT_TOKEN = requireEnv("TELEGRAM_BOT_TOKEN");
const VENUM_API_KEY = requireEnv("VENUM_API_KEY");
// ─── Wallet Setup ──────────────────────────────────────────────────────────
function loadOrCreateWallet(): Keypair {
const envKey = process.env["WALLET_SECRET_KEY"];
if (envKey) {
const kp = Keypair.fromSecretKey(bs58.decode(envKey));
log("INFO", "wallet", "loaded from env", { pubkey: kp.publicKey.toBase58() });
return kp;
}
if (fs.existsSync(WALLET_FILE)) {
const raw = JSON.parse(fs.readFileSync(WALLET_FILE, "utf8")) as { secretKey: string };
const kp = Keypair.fromSecretKey(bs58.decode(raw.secretKey));
log("INFO", "wallet", "loaded from wallet.json", { pubkey: kp.publicKey.toBase58() });
return kp;
}
const keypair = Keypair.generate();
const secretKeyB58 = bs58.encode(keypair.secretKey);
fs.writeFileSync(WALLET_FILE, JSON.stringify({ secretKey: secretKeyB58 }, null, 2));
const pubkey = keypair.publicKey.toBase58();
process.stdout.write(`
╔══════════════════════════════════════════════════════════════╗
║ NEW WALLET GENERATED — ACTION REQUIRED ║
╠══════════════════════════════════════════════════════════════╣
║ Public key: ${pubkey} ║
║ ║
║ Add the following to Replit Secrets (🔒 lock icon): ║
║ ║
║ WALLET_SECRET_KEY=${secretKeyB58}
║ ║
║ Anyone with that value controls this wallet. Keep it ║
║ secret. This message will not appear again. ║
╚══════════════════════════════════════════════════════════════╝\n\n`);
return keypair;
}
const wallet = loadOrCreateWallet();
// ─── Venum Setup ───────────────────────────────────────────────────────────
const venum = new VenumClient({ apiKey: VENUM_API_KEY });
// ─── Types ─────────────────────────────────────────────────────────────────
interface ResolvedToken {
mint: string;
symbol: string;
decimals: number;
}
interface PendingSwap {
transaction: string;
quoteId: string;
inputSymbol: string;
outputSymbol: string;
inputDisplay: string;
estimatedOutput: string;
minOutput: string;
route: string;
expiresAt: number;
outputDecimals: number;
}
interface SwapRecord {
id: number;
at: number;
pair: string;
inputDisplay: string;
estimatedOutput: string;
status: "confirmed" | "failed" | "timeout";
sig?: string;
err?: string;
}
type FlowStep =
| { kind: "buy_token"; inputToken: ResolvedToken }
| { kind: "buy_amount"; inputToken: ResolvedToken; outputToken: ResolvedToken; inputBalance: string }
| { kind: "sell_token" }
| { kind: "sell_amount"; token: ResolvedToken; inputBalance: string }
| { kind: "price_token" }
| { kind: "quote_input" }
| { kind: "quote_output"; inputToken: ResolvedToken }
| { kind: "quote_amount"; inputToken: ResolvedToken; outputToken: ResolvedToken }
| { kind: "settings_custom" };
// ─── In-Memory State ───────────────────────────────────────────────────────
const flowState = new Map<number, FlowStep>();
const balanceAmountCache = new Map<string, string>();
const pendingSwaps = new Map<number, PendingSwap>();
const swapHistory = new Map<number, SwapRecord[]>();
const tokenCache = new Map<string, ResolvedToken>();
let swapCounter = 0;
let slippageBps = DEFAULT_SLIPPAGE_BPS;
const HISTORY_LIMIT = 10;
function recordSwap(chatId: number, record: SwapRecord): void {
const list = swapHistory.get(chatId) ?? [];
list.unshift(record);
if (list.length > HISTORY_LIMIT) list.length = HISTORY_LIMIT;
swapHistory.set(chatId, list);
}
// ─── Number Helpers ────────────────────────────────────────────────────────
function validateAmountFormat(amount: string): void {
if (!/^\d+(\.\d+)?$/.test(amount)) {
throw new Error("Invalid amount. Use a number like 0.5 or 1.25.");
}
}
function toBaseUnitsString(amount: string, decimals: number): string {
validateAmountFormat(amount);
const [whole = "0", frac = ""] = amount.split(".");
if (frac.length > decimals) {
throw new Error(`Too many decimal places — this token supports at most ${decimals}.`);
}
const fracPadded = frac.padEnd(decimals, "0");
return (BigInt(whole) * BigInt(10 ** decimals) + BigInt(fracPadded || "0")).toString();
}
function fromBaseUnitsToDisplay(amount: string, decimals: number): string {
const s = BigInt(amount).toString().padStart(decimals + 1, "0");
if (decimals === 0) return s;
const intPart = s.slice(0, s.length - decimals) || "0";
const fracPart = s.slice(-decimals).replace(/0+$/, "");
return fracPart ? `${intPart}.${fracPart}` : intPart;
}
// ─── Token Resolution ──────────────────────────────────────────────────────
async function resolveToken(input: string): Promise<ResolvedToken> {
const upper = input.toUpperCase().trim();
if (upper === "SOL") {
const info: ResolvedToken = { mint: SOL_MINT, decimals: 9, symbol: "SOL" };
tokenCache.set("SOL", info);
return info;
}
if (tokenCache.has(upper)) return tokenCache.get(upper)!;
const isMint = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(input.trim());
let info: ResolvedToken;
if (isMint) {
const detail = await venum.token(input.trim());
info = {
mint: detail.token.mint,
symbol: detail.token.symbol ?? input.trim().slice(0, 8),
decimals: detail.token.decimals,
};
} else {
const response = await venum.search(input, { limit: 10 });
const tokenResults = response.results.filter(
(r): r is TokenSearchResult => r.type === "token"
);
const hit =
tokenResults.find((r) => r.symbol.toUpperCase() === upper) ??
tokenResults.find((r) => r.tradeable) ??
tokenResults[0];
if (!hit) throw new Error(`Could not find a token called "${input}" on Venum.dev.`);
const detail = await venum.token(hit.mint);
info = {
mint: detail.token.mint,
symbol: detail.token.symbol ?? upper,
decimals: detail.token.decimals,
};
}
tokenCache.set(upper, info);
tokenCache.set(info.mint, info);
return info;
}
// ─── UI Helpers ────────────────────────────────────────────────────────────
function backButton(): InlineKeyboard {
return new InlineKeyboard().text("⬅ Back to menu", "menu");
}
function cancelButton(): InlineKeyboard {
return new InlineKeyboard().text("✖ Cancel", "menu");
}
async function balancePickerKeyboard(prefix: string): Promise<{ keyboard: InlineKeyboard; tokens: ResolvedToken[] }> {
const result = await venum.balances(wallet.publicKey.toBase58());
const nonZero = result.balances
.filter((b) => (b.uiAmount ?? 0) > 0)
.sort((a, b) => {
if (a.mint === SOL_MINT) return -1;
if (b.mint === SOL_MINT) return 1;
return (b.uiAmount ?? 0) - (a.uiAmount ?? 0);
});
const tokens: ResolvedToken[] = [];
const kb = new InlineKeyboard();
for (const b of nonZero) {
const token: ResolvedToken = { mint: b.mint, symbol: b.symbol, decimals: b.decimals };
tokenCache.set(b.mint, token);
tokenCache.set(b.symbol.toUpperCase(), token);
balanceAmountCache.set(b.mint, b.amount ?? "0");
tokens.push(token);
const dp = Math.min(b.decimals, 4);
kb.text(`${b.symbol} ${(b.uiAmount ?? 0).toFixed(dp)}`, `${prefix}:${b.mint}`).row();
}
kb.text("✖ Cancel", "menu");
return { keyboard: kb, tokens };
}
function pctOf(baseUnits: string, pct: number, decimals: number): { base: string; display: string } {
const amount = (BigInt(baseUnits) * BigInt(pct)) / 100n;
return { base: amount.toString(), display: fromBaseUnitsToDisplay(amount.toString(), decimals) };
}
function amountKeyboard(token: ResolvedToken, balanceBase: string): InlineKeyboard {
const kb = new InlineKeyboard();
for (const pct of [25, 50, 100]) {
const { display } = pctOf(balanceBase, pct, token.decimals);
kb.text(`${pct}% ${display} ${token.symbol}`, `pct:${pct}`);
}
return kb.row().text("✖ Cancel", "menu");
}
function formatBalances(balances: BalanceEntry[]): string {
const nonZero = balances.filter((b) => (b.uiAmount ?? 0) > 0);
if (nonZero.length === 0) return "_Wallet is empty._";
return nonZero
.sort((a, b) => {
if (a.mint === SOL_MINT) return -1;
if (b.mint === SOL_MINT) return 1;
return (b.uiAmount ?? 0) - (a.uiAmount ?? 0);
})
.map((b) => {
const dp = Math.min(b.decimals, 6);
return `\`${b.symbol}\`: ${(b.uiAmount ?? 0).toFixed(dp)}`;
})
.join("\n");
}
function formatHistory(records: SwapRecord[]): string {
if (records.length === 0) return "_No swaps yet this session._";
return records
.map((r) => {
const date = new Date(r.at).toISOString().replace("T", " ").slice(0, 19) + " UTC";
const icon = r.status === "confirmed" ? "✅" : r.status === "timeout" ? "⏰" : "❌";
const link = r.sig ? ` · [tx](https://explorer.solana.com/tx/${r.sig})` : "";
const errNote = r.err ? `\n_${r.err}_` : "";
return `${icon} *${r.pair}* ${r.inputDisplay} → ~${r.estimatedOutput}\n_${date}${link}_${errNote}`;
})
.join("\n\n");
}
function mainMenu(): InlineKeyboard {
return new InlineKeyboard()
.text("💰 Buy", "buy")
.text("💸 Sell", "sell")
.row()
.text("👛 Wallet", "wallet")
.text("📊 Balance", "balance")
.text("📜 History", "history")
.row()
.text("💵 Price", "price")
.text("📋 Quote", "quote")
.row()
.text("⚙️ Settings", "settings")
.text("❓ Help", "help");
}
async function showMenu(ctx: Context, text = "Main menu — choose an action:"): Promise<void> {
flowState.delete(ctx.chat!.id);
try {
await ctx.editMessageText(text, { reply_markup: mainMenu() });
} catch {
await ctx.reply(text, { reply_markup: mainMenu() });
}
}
// ─── Bot Setup ─────────────────────────────────────────────────────────────
const bot = new Bot(TELEGRAM_BOT_TOKEN);
// ─── Slash Commands ────────────────────────────────────────────────────────
bot.command(["start", "menu"], async (ctx) => {
log("INFO", "cmd", "/start", { chat: ctx.chat.id, user: userLabel(ctx) });
flowState.delete(ctx.chat.id);
await ctx.reply("Main menu — choose an action:", { reply_markup: mainMenu() });
});
bot.command("wallet", async (ctx) => {
log("INFO", "cmd", "/wallet", { chat: ctx.chat.id, user: userLabel(ctx) });
await ctx.reply(
`Your bot wallet address:\n\`${wallet.publicKey.toBase58()}\`\n\n` +
"_Send SOL here to fund._",
{ parse_mode: "Markdown", reply_markup: backButton() }
);
});
bot.command("address", async (ctx) => {
log("INFO", "cmd", "/address", { chat: ctx.chat.id, user: userLabel(ctx) });
await ctx.reply(`\`${wallet.publicKey.toBase58()}\``, { parse_mode: "Markdown" });
});
bot.command("balance", async (ctx) => {
log("INFO", "cmd", "/balance", { chat: ctx.chat.id, user: userLabel(ctx) });
try {
const result = await venum.balances(wallet.publicKey.toBase58());
await ctx.reply(`📊 *Balance*\n\n${formatBalances(result.balances)}`, {
parse_mode: "Markdown",
reply_markup: backButton(),
});
} catch (err) {
logError("balance", err, { chat: ctx.chat.id });
await ctx.reply("Could not fetch balance. Try again later.", { reply_markup: backButton() });
}
});
bot.command("history", async (ctx) => {
log("INFO", "cmd", "/history", { chat: ctx.chat.id, user: userLabel(ctx) });
const records = swapHistory.get(ctx.chat.id) ?? [];
await ctx.reply(`📜 *Swap History*\n\n${formatHistory(records)}`, {
parse_mode: "Markdown",
link_preview_options: { is_disabled: true },
reply_markup: backButton(),
});
});
bot.command("help", async (ctx) => {
log("INFO", "cmd", "/help", { chat: ctx.chat.id, user: userLabel(ctx) });
await ctx.reply(helpText(), { parse_mode: "Markdown", reply_markup: backButton() });
});
// ─── Callback: Main Menu ───────────────────────────────────────────────────
bot.callbackQuery("menu", async (ctx) => {
await ctx.answerCallbackQuery();
await showMenu(ctx);
});
// ─── Callback: Buy ─────────────────────────────────────────────────────────
bot.callbackQuery("buy", async (ctx) => {
await ctx.answerCallbackQuery();
try {
const { keyboard, tokens } = await balancePickerKeyboard("buy_from");
if (tokens.length === 0) {
await ctx.editMessageText("⚠️ No tokens in wallet to spend.", { reply_markup: backButton() });
return;
}
await ctx.editMessageText("💰 *Buy* — spend which token?", {
parse_mode: "Markdown",
reply_markup: keyboard,
});
} catch (err) {
logError("buy", err, { chat: ctx.chat!.id });
await ctx.editMessageText("Could not load balances. Try again.", { reply_markup: backButton() });
}
});
bot.callbackQuery(/^buy_from:(.+)$/, async (ctx) => {
await ctx.answerCallbackQuery();
const mint = ctx.match![1]!;
const inputToken = tokenCache.get(mint);
if (!inputToken) {
await ctx.editMessageText("⚠️ Token not found. Try again.", { reply_markup: backButton() });
return;
}
flowState.set(ctx.chat!.id, { kind: "buy_token", inputToken });
await ctx.editMessageText(
`💰 Spending *${inputToken.symbol}* — which token do you want to buy?\n\nReply with a symbol (e.g. \`BONK\`) or a base58 mint address.`,
{ parse_mode: "Markdown", reply_markup: cancelButton() }
);
});
// ─── Callback: Sell ────────────────────────────────────────────────────────
bot.callbackQuery("sell", async (ctx) => {
await ctx.answerCallbackQuery();
try {
const { keyboard, tokens } = await balancePickerKeyboard("sell_from");
if (tokens.length === 0) {
await ctx.editMessageText("⚠️ No tokens in wallet to sell.", { reply_markup: backButton() });
return;
}
await ctx.editMessageText("💸 *Sell* — which token do you want to sell?", {
parse_mode: "Markdown",
reply_markup: keyboard,
});
} catch (err) {
logError("sell", err, { chat: ctx.chat!.id });
await ctx.editMessageText("Could not load balances. Try again.", { reply_markup: backButton() });
}
});
bot.callbackQuery(/^sell_from:(.+)$/, async (ctx) => {
await ctx.answerCallbackQuery();
const mint = ctx.match![1]!;
const token = tokenCache.get(mint);
if (!token) {
await ctx.editMessageText("⚠️ Token not found. Try again.", { reply_markup: backButton() });
return;
}
const inputBalance = balanceAmountCache.get(mint) ?? "0";
flowState.set(ctx.chat!.id, { kind: "sell_amount", token, inputBalance });
const kb = BigInt(inputBalance) > 0n ? amountKeyboard(token, inputBalance) : cancelButton();
await ctx.editMessageText(
`💸 Selling *${token.symbol}* → SOL\n\nHow much *${token.symbol}* do you want to sell?\n_Or type a custom amount._`,
{ parse_mode: "Markdown", reply_markup: kb }
);
});
// ─── Callback: Wallet ──────────────────────────────────────────────────────
bot.callbackQuery("wallet", async (ctx) => {
await ctx.answerCallbackQuery();
await ctx.editMessageText(
`👛 *Your wallet address:*\n\`${wallet.publicKey.toBase58()}\`\n\n_Send SOL here to fund._`,
{ parse_mode: "Markdown", reply_markup: backButton() }
);
});
// ─── Callback: Balance ─────────────────────────────────────────────────────
bot.callbackQuery("balance", async (ctx) => {
await ctx.answerCallbackQuery();
try {
const result = await venum.balances(wallet.publicKey.toBase58());
await ctx.editMessageText(`📊 *Balance*\n\n${formatBalances(result.balances)}`, {
parse_mode: "Markdown",
reply_markup: backButton(),
});
} catch (err) {
logError("balance", err, { chat: ctx.chat!.id });
await ctx.editMessageText("Could not fetch balance. Try again later.", {
reply_markup: backButton(),
});
}
});
// ─── Callback: History ─────────────────────────────────────────────────────
bot.callbackQuery("history", async (ctx) => {
await ctx.answerCallbackQuery();
const records = swapHistory.get(ctx.chat!.id) ?? [];
await ctx.editMessageText(`📜 *Swap History*\n\n${formatHistory(records)}`, {
parse_mode: "Markdown",
link_preview_options: { is_disabled: true },
reply_markup: backButton(),
});
});
// ─── Callback: Price ───────────────────────────────────────────────────────
bot.callbackQuery("price", async (ctx) => {
await ctx.answerCallbackQuery();
flowState.set(ctx.chat!.id, { kind: "price_token" });
await ctx.editMessageText(
"💵 *Price* — which token?\n\nReply with a symbol (e.g. `SOL`) or a mint address.",
{ parse_mode: "Markdown", reply_markup: cancelButton() }
);
});
// ─── Callback: Quote ───────────────────────────────────────────────────────
bot.callbackQuery("quote", async (ctx) => {
await ctx.answerCallbackQuery();
flowState.set(ctx.chat!.id, { kind: "quote_input" });
await ctx.editMessageText(
"📋 *Quote* — which token are you swapping *from*?\n\nReply with a symbol or mint address.",
{ parse_mode: "Markdown", reply_markup: cancelButton() }
);
});
// ─── Callback: Settings ────────────────────────────────────────────────────
bot.callbackQuery("settings", async (ctx) => {
await ctx.answerCallbackQuery();
await showSettingsMenu(ctx);
});
// ─── Callback: Help ────────────────────────────────────────────────────────
bot.callbackQuery("help", async (ctx) => {
await ctx.answerCallbackQuery();
await ctx.editMessageText(helpText(), { parse_mode: "Markdown", reply_markup: backButton() });
});
// ─── Callback: Slippage Presets ────────────────────────────────────────────
const slippagePresets: Array<[string, number]> = [
["0.5%", 50],
["1%", 100],
["3%", 300],
["5%", 500],
];
for (const [label, bps] of slippagePresets) {
bot.callbackQuery(`slippage:${bps}`, async (ctx) => {
await ctx.answerCallbackQuery();
slippageBps = bps;
await showSettingsMenu(ctx, `✅ Slippage set to ${label}`);
});
}
// ─── Callback: Percentage Amount Selector ─────────────────────────────────
bot.callbackQuery(/^pct:(\d+)$/, async (ctx) => {
await ctx.answerCallbackQuery();
const pct = parseInt(ctx.match![1]!);
const chatId = ctx.chat!.id;
const step = flowState.get(chatId);
if (step?.kind === "buy_amount") {
const { inputToken, outputToken, inputBalance } = step;
const { base, display } = pctOf(inputBalance, pct, inputToken.decimals);
await ctx.editMessageText("⏳ Building swap…");
await executeSwapFlow(ctx, inputToken, outputToken, base, display);
} else if (step?.kind === "sell_amount") {
const { token, inputBalance } = step;
const outputToken: ResolvedToken = { mint: SOL_MINT, symbol: "SOL", decimals: 9 };
const { base, display } = pctOf(inputBalance, pct, token.decimals);
await ctx.editMessageText("⏳ Building swap…");
await executeSwapFlow(ctx, token, outputToken, base, display);
} else {
await ctx.answerCallbackQuery({ text: "Session expired — start over from the menu." });
}
});
bot.callbackQuery("slippage:custom", async (ctx) => {
await ctx.answerCallbackQuery();
flowState.set(ctx.chat!.id, { kind: "settings_custom" });
await ctx.editMessageText(
"⚙️ *Custom slippage*\n\nReply with a whole number between 1 and 5000 (basis points).\n_Example: 75 = 0.75%_",
{ parse_mode: "Markdown", reply_markup: cancelButton() }
);
});
// ─── Callback: Swap Confirm ────────────────────────────────────────────────
bot.callbackQuery(/^confirm:(\d+)$/, async (ctx) => {
await ctx.answerCallbackQuery();
const id = parseInt(ctx.match![1]!);
const swap = pendingSwaps.get(id);
if (!swap) {
await ctx.editMessageText("Swap not found or already processed.", { reply_markup: backButton() });
return;
}
if (swap.expiresAt - Date.now() <= 0) {
pendingSwaps.delete(id);
await ctx.editMessageText("⏰ Quote expired. Start over from the menu.", {
reply_markup: backButton(),
});
return;
}
pendingSwaps.delete(id);
await ctx.editMessageText("🔐 Signing & submitting…");
try {
const txBytes = Buffer.from(swap.transaction, "base64");
const tx = VersionedTransaction.deserialize(txBytes);
tx.sign([wallet]);
const signedB64 = Buffer.from(tx.serialize()).toString("base64");
const submitResult = await venum.submitSwap({
quoteId: swap.quoteId,
signedTransaction: signedB64,
});
const sig = submitResult.signature;
const shortSig = `${sig.slice(0, 8)}…${sig.slice(-4)}`;
const explorerUrl = `https://explorer.solana.com/tx/${sig}`;
await ctx.editMessageText(
`📤 Submitted: [${shortSig}](${explorerUrl})\n\n_Waiting for confirmation…_`,
{ parse_mode: "Markdown", link_preview_options: { is_disabled: true } }
);
const event = await venum.waitForTx(sig, {
events: ["confirmed", "finalized"],
signal: AbortSignal.timeout(TX_CONFIRM_TIMEOUT_MS),
});
if (event.status === "confirmed" || event.status === "finalized") {
recordSwap(ctx.chat!.id, {
id,
at: Date.now(),
pair: `${swap.inputSymbol} → ${swap.outputSymbol}`,
inputDisplay: swap.inputDisplay,
estimatedOutput: swap.estimatedOutput,
status: "confirmed",
sig,
});
await ctx.reply(`✅ Confirmed: [${shortSig}](${explorerUrl})`, {
parse_mode: "Markdown",
link_preview_options: { is_disabled: true },
reply_markup: backButton(),
});
} else {
const reason = event.err ?? event.status;
const isTimeout = event.status === "timeout" || event.status === "verify-timeout";
recordSwap(ctx.chat!.id, {
id,
at: Date.now(),
pair: `${swap.inputSymbol} → ${swap.outputSymbol}`,
inputDisplay: swap.inputDisplay,
estimatedOutput: swap.estimatedOutput,
status: isTimeout ? "timeout" : "failed",
sig,
err: reason,
});
await ctx.reply(`❌ Swap failed: ${reason}\n[${shortSig}](${explorerUrl})`, {
parse_mode: "Markdown",
link_preview_options: { is_disabled: true },
reply_markup: backButton(),
});
}
} catch (err) {
logError("swap", err, { swapId: id, chat: ctx.chat!.id });
const msg = err instanceof Error ? err.message : "Unknown error";
await ctx.reply(`❌ Could not execute swap: ${msg}`, { reply_markup: backButton() });
}
});
// ─── Callback: Swap Cancel ─────────────────────────────────────────────────
bot.callbackQuery(/^cancel:(\d+)$/, async (ctx) => {
await ctx.answerCallbackQuery();
const id = parseInt(ctx.match![1]!);
pendingSwaps.delete(id);
await showMenu(ctx, "Swap cancelled.");
});
// ─── Text Message Handler (Multi-Step Flows) ───────────────────────────────
bot.on("message:text", async (ctx) => {
const chatId = ctx.chat.id;
const text = ctx.message.text.trim();
const step = flowState.get(chatId);
if (!step) return;
if (step.kind === "buy_token") {
try {
const outputToken = await resolveToken(text);
const inputBalance = balanceAmountCache.get(step.inputToken.mint) ?? "0";
flowState.set(chatId, { kind: "buy_amount", inputToken: step.inputToken, outputToken, inputBalance });
const kb = BigInt(inputBalance) > 0n ? amountKeyboard(step.inputToken, inputBalance) : cancelButton();
await ctx.reply(
`💰 Buying *${outputToken.symbol}* with *${step.inputToken.symbol}*\n\nHow much *${step.inputToken.symbol}* do you want to spend?\n_Or type a custom amount._`,
{ parse_mode: "Markdown", reply_markup: kb }
);
} catch (err) {
logError("buy_token", err, { chat: chatId, input: text });
const msg = err instanceof Error ? err.message : "Unknown error";
await ctx.reply(`⚠️ ${msg}`, { reply_markup: cancelButton() });
}
return;
}
if (step.kind === "buy_amount") {
try {
const { inputToken, outputToken } = step;
const amountStr = toBaseUnitsString(text, inputToken.decimals);
await ctx.reply("⏳ Building swap…");
await executeSwapFlow(ctx, inputToken, outputToken, amountStr, text);
} catch (err) {
logError("buy_amount", err, { chat: chatId, input: text });
const msg = err instanceof Error ? err.message : "Unknown error";
await ctx.reply(`⚠️ ${msg}`, { reply_markup: cancelButton() });
}
return;
}
if (step.kind === "sell_amount") {
try {
const outputToken: ResolvedToken = { mint: SOL_MINT, symbol: "SOL", decimals: 9 };
const inputToken = step.token;
const amountStr = toBaseUnitsString(text, inputToken.decimals);
await ctx.reply("⏳ Building swap…");
await executeSwapFlow(ctx, inputToken, outputToken, amountStr, text);
} catch (err) {
logError("sell_amount", err, { chat: chatId, input: text });
const msg = err instanceof Error ? err.message : "Unknown error";
await ctx.reply(`⚠️ ${msg}`, { reply_markup: cancelButton() });
}
return;
}
if (step.kind === "price_token") {
flowState.delete(chatId);
try {
const result = await venum.price(text);
const priceStr = `$${result.priceUsd.toFixed(6)}`;
await ctx.reply(`💵 *${result.token}*: ${priceStr}`, {
parse_mode: "Markdown",
reply_markup: backButton(),
});
} catch (err) {
logError("price", err, { chat: chatId, input: text });
const detail =
err instanceof VenumApiError
? ` (HTTP ${err.status}: ${err.message})`
: err instanceof Error
? `: ${err.message}`
: "";
await ctx.reply(`Could not get a price for *${text}*${detail}`, {
parse_mode: "Markdown",
reply_markup: backButton(),
});
}
return;
}
if (step.kind === "quote_input") {
try {
const inputToken = await resolveToken(text);
flowState.set(chatId, { kind: "quote_output", inputToken });
await ctx.reply(
`📋 Swapping *${inputToken.symbol}* → which token?\n\nReply with a symbol or mint address.`,
{ parse_mode: "Markdown", reply_markup: cancelButton() }
);
} catch (err) {
logError("quote_input", err, { chat: chatId, input: text });
const msg = err instanceof Error ? err.message : "Unknown error";
await ctx.reply(`⚠️ ${msg}`, { reply_markup: cancelButton() });
}
return;
}
if (step.kind === "quote_output") {
try {
const outputToken = await resolveToken(text);
flowState.set(chatId, {
kind: "quote_amount",
inputToken: step.inputToken,
outputToken,
});
await ctx.reply(
`📋 How much *${step.inputToken.symbol}* do you want to quote?`,
{ parse_mode: "Markdown", reply_markup: cancelButton() }
);
} catch (err) {
logError("quote_output", err, { chat: chatId, input: text });
const msg = err instanceof Error ? err.message : "Unknown error";
await ctx.reply(`⚠️ ${msg}`, { reply_markup: cancelButton() });
}
return;
}
if (step.kind === "quote_amount") {
flowState.delete(chatId);
try {
const { inputToken, outputToken } = step;
const amountStr = toBaseUnitsString(text, inputToken.decimals);
const quoteResponse = await venum.quote({
inputMint: inputToken.mint,
outputMint: outputToken.mint,
amount: amountStr,
slippageBps,
});
const topRoute: QuoteRoute | undefined = quoteResponse.topRoutes[0];
if (!topRoute) {
await ctx.reply("No route found for this pair.", { reply_markup: backButton() });
return;
}
const outDisplay = fromBaseUnitsToDisplay(topRoute.outputAmount, outputToken.decimals);
const impact = topRoute.priceImpactPct != null ? ` • Impact: ${topRoute.priceImpactPct.toFixed(3)}%` : "";
const slipStr = `${(slippageBps / 100).toFixed(2)}%`;
await ctx.reply(
`📋 *Quote*\n\n${text} ${inputToken.symbol} → ~${outDisplay} ${outputToken.symbol}\nRoute: ${topRoute.dex}${impact}\nSlippage: ${slipStr}`,
{ parse_mode: "Markdown", reply_markup: backButton() }
);
} catch (err) {
logError("quote_amount", err, { chat: chatId, input: text });
const msg = err instanceof Error ? err.message : "Unknown error";
await ctx.reply(`Quote failed: ${msg}`, { reply_markup: backButton() });
}
return;
}
if (step.kind === "settings_custom") {
flowState.delete(chatId);
const bps = parseInt(text, 10);
if (isNaN(bps) || bps < 1 || bps > 5000 || bps.toString() !== text) {
await ctx.reply("⚠️ Enter a whole number between 1 and 5000.", {
reply_markup: cancelButton(),
});
return;
}
slippageBps = bps;
const pct = (bps / 100).toFixed(2);
await ctx.reply(`✅ Slippage set to ${bps} bps (${pct}%)`, { reply_markup: backButton() });
return;
}
});
// ─── Swap Flow Helper ──────────────────────────────────────────────────────
async function executeSwapFlow(
ctx: Context,
inputToken: ResolvedToken,
outputToken: ResolvedToken,
amountStr: string,
inputDisplay: string
): Promise<void> {
flowState.delete(ctx.chat!.id);
try {
const build: SwapBuildResponse = await venum.buildSwap({
inputMint: inputToken.mint,
outputMint: outputToken.mint,
amount: amountStr,
slippageBps,
userPublicKey: wallet.publicKey.toBase58(),
createAtaIfMissing: true,
});
const id = ++swapCounter;
const estimatedDisplay = fromBaseUnitsToDisplay(build.estimatedOutput, outputToken.decimals);
const minDisplay = fromBaseUnitsToDisplay(build.minOutput, outputToken.decimals);
pendingSwaps.set(id, {
transaction: build.transaction,
quoteId: build.quoteId,
inputSymbol: inputToken.symbol,
outputSymbol: outputToken.symbol,
inputDisplay,
estimatedOutput: estimatedDisplay,
minOutput: minDisplay,
route: build.route.dex,
expiresAt: Date.now() + SWAP_EXPIRY_MS,
outputDecimals: outputToken.decimals,
});
const keyboard = new InlineKeyboard()
.text("✅ Confirm", `confirm:${id}`)
.text("✖ Cancel", `cancel:${id}`);
await ctx.reply(
`🔄 *Confirm Swap*\n\n${inputDisplay} ${inputToken.symbol} → ~${estimatedDisplay} ${outputToken.symbol}\nMin out: ${minDisplay} ${outputToken.symbol}\nRoute: ${build.route.dex} • Slippage: ${slippageBps} bps\n\n_Quote expires in ~30s._`,
{ parse_mode: "Markdown", reply_markup: keyboard }
);
} catch (err) {
logError("buildSwap", err, { chat: ctx.chat!.id });
const msg = err instanceof Error ? err.message : "Unknown error";
await ctx.reply(`Could not prepare swap: ${msg}`, { reply_markup: backButton() });
}
}
// ─── Settings Menu Helper ──────────────────────────────────────────────────
async function showSettingsMenu(ctx: Context, header?: string): Promise<void> {
const pct = (slippageBps / 100).toFixed(2);
const body = `⚙️ *Settings*\n\nSlippage: \`${slippageBps} bps (${pct}%)\`\n\nChoose a preset or enter a custom value:`;
const text = header ? `${header}\n\n${body}` : body;
const keyboard = new InlineKeyboard()
.text("0.5%", "slippage:50")
.text("1%", "slippage:100")
.text("3%", "slippage:300")
.text("5%", "slippage:500")
.row()
.text("✏️ Custom", "slippage:custom")
.row()
.text("⬅ Back", "menu");
try {
await ctx.editMessageText(text, { parse_mode: "Markdown", reply_markup: keyboard });
} catch {
await ctx.reply(text, { parse_mode: "Markdown", reply_markup: keyboard });
}
}
// ─── Help Text ─────────────────────────────────────────────────────────────
function helpText(): string {
return (
`❓ *Solana Trading Bot — Help*\n\n` +
`*Buttons*\n` +
`💰 *Buy* — swap one token for another\n` +
`💸 *Sell* — swap a token for SOL\n` +
`👛 *Wallet* — show your bot wallet address\n` +
`📊 *Balance* — show your SOL balance\n` +
`📜 *History* — last 10 swaps in this chat\n` +
`💵 *Price* — look up a token price\n` +
`📋 *Quote* — get a swap quote (no execution)\n` +
`⚙️ *Settings* — adjust slippage (default 50 bps)\n\n` +
`*Slash shortcuts*\n` +
`/start or /menu — open the main menu\n` +
`/wallet — show wallet address\n` +
`/address — show bare address for easy copy\n` +
`/balance — show balances\n` +
`/history — last 10 swaps\n` +
`/help — show this message`
);
}
// ─── Global Error Handler ──────────────────────────────────────────────────
bot.catch((err) => {
logError("bot", err.error, { update: err.ctx?.update?.update_id });
});
// ─── Startup ───────────────────────────────────────────────────────────────
log("INFO", "bot", "starting", { wallet: wallet.publicKey.toBase58() });
bot.start({
onStart: () => log("INFO", "bot", "listening for updates (long polling)"),
});
// ─── Graceful Shutdown ─────────────────────────────────────────────────────
async function shutdown(signal: string): Promise<void> {
log("INFO", "bot", `received ${signal}, stopping`);
await bot.stop();
process.exit(0);
}
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));Step 5 · Add your secrets
Secrets are how Replit stores passwords without putting them in your code.
- On the left of Replit, tap the 🔒 lock icon (Secrets).
- Tap + New Secret.
- Key:
TELEGRAM_BOT_TOKEN. Value: paste the BotFather token. Tap Add. - Tap + New Secret again. Key:
VENUM_API_KEY. Value: paste your Venum.dev key. Tap Add.
Names must match exactly — capital letters, underscores, no spaces.
Step 6 · Click Run and meet your bot
Tap the green Run button at the top.
The console runs
npm install(first time, ~30 s), then starts the bot. After 10–20 seconds you should see:[...] INFO [bot] starting {"wallet":"7FxK...abcd"} [...] INFO [bot] listening for updates (long polling)Open Telegram. Search for the username you gave your bot in Step 1.
Tap Start (or send
/start).
The bot replies with the main menu. Tap ❓ Help to see what each button does.
Step 7 · Save your bot's wallet secret
The first time you run the bot it generated a fresh Solana wallet just for it, and printed something like this in the console:
╔══════════════════════════════════════════════════════════════╗
║ NEW WALLET GENERATED — ACTION REQUIRED ║
╠══════════════════════════════════════════════════════════════╣
║ Public key: 7FxKabcd... ║
║ ║
║ Add the following to Replit Secrets (🔒 lock icon): ║
║ ║
║ WALLET_SECRET_KEY=4xK9zP... ║
║ ║
║ Anyone with that value controls this wallet. Keep it ║
║ secret. This message will not appear again. ║
╚══════════════════════════════════════════════════════════════╝Add it to Secrets so the wallet survives restarts:
- Tap and hold the long string after
WALLET_SECRET_KEY=and tap Copy. - Tap the 🔒 lock icon.
- + New Secret. Key:
WALLET_SECRET_KEY. Value: paste. Add.
You should now have three secrets: TELEGRAM_BOT_TOKEN, VENUM_API_KEY, and WALLET_SECRET_KEY. Tap Stop then Run so the bot reloads using the saved key.
Step 8 · Fund your bot
The bot has a wallet but no funds yet.
- Send
/walletto your bot. It replies with the bot's address. - Tap and hold the address, tap Copy.
- Open the Solana wallet you already use (Phantom, Backpack, an exchange withdrawal). Send some SOL to the bot's address.
- Wait 10–30 seconds for confirmation.
- In the bot, tap 📊 Balance. You should see the SOL balance.
Step 9 · Make your first swap
Tap 💰 Buy. The bot shows your wallet's tokens — tap SOL.
Type the destination — e.g.
USDC— and send.Tap 25% to spend a quarter of your SOL.
The bot shows a confirmation card with expected output, minimum output, route, and slippage:
🔄 Confirm Swap 0.0125 SOL → ~2.43 USDC Min out: 2.41 USDC Route: orca-whirlpool • Slippage: 50 bps Quote expires in ~30s.Tap ✅ Confirm.
The bot replies "Signing & submitting…", then "Submitted: …" with a Solana Explorer link, then "✅ Confirmed" once the transaction lands.
Tap the explorer link to see your transaction on-chain. Send /balance again — SOL went down, USDC appeared.
What else can the bot do?
- 💸 Sell — flip any token back to SOL. Same flow as Buy.
- 💵 Price — type any token (
SOL,USDC,BONK,WIF, or a base58 mint) for a USD price. - 📋 Quote — preview a swap without executing.
- ⚙️ Settings — change slippage. Default is 0.5 % (50 bps). Volatile memecoins often need 3–5 %. See Quoting for the math.
- 📜 History — last 10 swaps in this chat with explorer links.
Slash shortcuts: /start, /menu, /wallet, /address, /balance, /history, /help.
Use Replit Agent
If you'd rather not copy three files yourself, paste this into Replit Agent on a fresh Repl. It follows this guide for you:
Set up a Solana Telegram trading bot in TypeScript on Replit, following this guide:
https://docs.venum.dev/guide/build-telegram-trading-bot
Use the Node.js template. Create exactly three files (package.json, tsconfig.json, src/index.ts) with the contents shown in the guide. Do not modify the code or add extra files. After the files exist, ask me for TELEGRAM_BOT_TOKEN and VENUM_API_KEY, add them as Replit Secrets, then click Run. After the first run prints WALLET_SECRET_KEY, prompt me to copy it into Secrets too.Replit is the recommended host: free tier is enough for personal use, Secrets are stored safely, and the mobile app works without any installs. For 24/7 uptime once you outgrow the free tier, see Manual Production Deploy.
Troubleshooting
Bot doesn't respond to /start
- Check the Replit console — you should see
listening for updates. - Verify secret names are spelled exactly:
TELEGRAM_BOT_TOKENandVENUM_API_KEY. Capital letters, underscores. - Make sure you opened the right bot in Telegram (the username you set in Step 1).
- Tap Stop, then Run again.
"Missing required secret"
The Replit Secret name doesn't match what the code expects. Open the lock icon, fix the name, then Stop and Run.
"No tokens in wallet to spend" when tapping Buy
The wallet is empty or the deposit hasn't confirmed yet. See Step 8.
"❌ Swap failed: insufficient lamports"
Solana charges ~0.00001 SOL per transaction. If you're trying to swap your exact SOL balance there's nothing left for fees. Send a bit more SOL and try again. Background reading: Quoting.
"❌ Swap failed: slippage tolerance exceeded"
The price moved between quote and submission. Common with volatile tokens. Open ⚙️ Settings, bump slippage to 3 % or 5 %, retry. See Quoting for slippage math.
"⏰ Quote expired"
You took longer than 30 seconds between the quote and Confirm. The quote is dropped on purpose — the price may have moved. Start over from the menu.
Replit puts the bot to sleep
The free tier sleeps idle Repls. Open the project to wake it. For 24/7 uptime, see Manual Production Deploy or Improve TX Landing Rate for the production flow.
"VenumApiError: 429 Too Many Requests"
You hit a rate limit. The free tier is generous, but if you're stress-testing, upgrade or back off.
I lost my WALLET_SECRET_KEY
The bot reads WALLET_SECRET_KEY first, then falls back to wallet.json in the file panel. If both are gone, the wallet is gone with whatever was in it. Withdraw before you delete the Repl or remove the secret.
Going further
Once you're comfortable, here's where each part of this bot opens up:
- Customize routing or fees. Composable Instructions —
/v1/swap/buildreturns an unsigned tx you can decompile and edit before signing. - Better landing rate. Improve TX Landing Rate covers Jito bundles and dedicated submission.
- Real-time price feeds. Pool State and
GET /v1/stream/prices— perfect for adding price alerts to this bot. - A production trading bot. Build a Trading Bot covers persistence, monitoring, and the full execution stack.
- A swap UI for the web. Build a Swap App is the frontend equivalent of this guide.
- Stop hitting public RPC. Solana RPC and Reduce RPC Costs.
Related
- Quick Start — the 5-minute SDK tour
- Authentication — API keys, rate limits, server-side use
- Quoting · Swap Building · Transaction Submission
- Venum SDK — full SDK reference
- Build a Trading Bot — production-grade backend (Trading Bot Backend)
- Build a Swap App — frontend swap UI
