Skip to content

Telegram Bot Example

A working Telegram bot that:

  • Gives every user a unique server wallet on first message.
  • Shows balance and address on /wallet.
  • Sends AVAX with /send <address> <amount>.
  • Recovers cleanly from policy violations, insufficient gas, and network errors.

Uses grammY as the bot framework and @0xgasless/core for wallet management. The same patterns apply to Discord (discord.js), WhatsApp (whatsapp-web.js), or any other event-driven runtime.

Terminal window
npm install @0xgasless/core grammy ethers dotenv
.env
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
OXGAS_API_KEY=0xgas_live_sk_...
FUJI_RPC=https://api.avax-test.network/ext/bc/C/rpc

Wrap the SDK with a tiny helper that scopes wallets per Telegram user. Doing this in one place means your handler code never builds userId strings by hand.

wallet-service.ts
import { OxGasServerWallet } from '@0xgasless/core';
const wallet = new OxGasServerWallet({
apiKey: process.env.OXGAS_API_KEY!,
defaultChainId: 43113,
});
const uid = (telegramId: number) => `tg_${telegramId}`;
export async function getOrCreate(telegramId: number, username?: string) {
return wallet.getOrCreateWallet(uid(telegramId), {
label: username ? `@${username}` : `tg_${telegramId}`,
});
}
export async function sign(
telegramId: number,
tx: Parameters<typeof wallet.signTransaction>[1],
) {
return wallet.signTransaction(uid(telegramId), tx);
}
export async function broadcast(rawTx: string) {
return wallet.broadcastTransaction(rawTx, process.env.FUJI_RPC!);
}

uid() is the only place that knows how Telegram IDs map to wallet identifiers — change the prefix later and no other code needs to update.

The platform doesn’t track nonces or balances. Two short helpers cover both — your handler code stays focused on the conversation flow, not RPC plumbing.

rpc.ts
const FUJI_RPC = process.env.FUJI_RPC!;
async function rpc<T>(method: string, params: unknown[]): Promise<T> {
const r = await fetch(FUJI_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
}).then((r) => r.json());
if (r.error) throw new Error(r.error.message);
return r.result as T;
}
export async function nextNonce(address: string): Promise<number> {
const hex = await rpc<string>('eth_getTransactionCount', [address, 'pending']);
return parseInt(hex, 16);
}
export async function balance(address: string): Promise<bigint> {
const hex = await rpc<string>('eth_getBalance', [address, 'latest']);
return BigInt(hex);
}

'pending' for the nonce means in-flight transactions are accounted for — important if your bot sends quickly.

bot.ts
import 'dotenv/config';
import { Bot } from 'grammy';
import { formatEther, parseEther } from 'ethers';
import {
WalletNotFoundError,
PolicyViolationError,
ServerSigningError,
NetworkError,
} from '@0xgasless/core';
import { getOrCreate, sign, broadcast } from './wallet-service';
import { nextNonce, balance } from './rpc';
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!);
// ── /start ─────────────────────────────────────────────────────────
bot.command('start', async (ctx) => {
if (!ctx.from) return;
const info = await getOrCreate(ctx.from.id, ctx.from.username);
await ctx.reply(
`Welcome! Your wallet is:\n<code>${info.address}</code>\n\n` +
`Fund it from the Avalanche Fuji faucet, then try /balance.`,
{ parse_mode: 'HTML' },
);
});
// ── /wallet ────────────────────────────────────────────────────────
bot.command('wallet', async (ctx) => {
if (!ctx.from) return;
const info = await getOrCreate(ctx.from.id, ctx.from.username);
await ctx.reply(
`Wallet: <code>${info.address}</code>\nChain: Avalanche Fuji`,
{ parse_mode: 'HTML' },
);
});
// ── /balance ───────────────────────────────────────────────────────
bot.command('balance', async (ctx) => {
if (!ctx.from) return;
const info = await getOrCreate(ctx.from.id, ctx.from.username);
const wei = await balance(info.address);
await ctx.reply(`${formatEther(wei)} AVAX`);
});
// ── /send <to> <amount> ────────────────────────────────────────────
bot.command('send', async (ctx) => {
if (!ctx.from) return;
const [to, amount] = (ctx.match ?? '').trim().split(/\s+/);
if (!to?.startsWith('0x') || !amount) {
return ctx.reply('Usage: /send 0x... 0.01');
}
try {
const info = await getOrCreate(ctx.from.id, ctx.from.username);
const nonce = await nextNonce(info.address);
const signed = await sign(ctx.from.id, {
to: to as `0x${string}`,
value: parseEther(amount).toString(),
nonce,
chainId: 43113,
});
const { txHash } = await broadcast(signed.rawTx);
await ctx.reply(
`Sent.\nhttps://testnet.snowtrace.io/tx/${txHash}`,
);
} catch (err) {
await ctx.reply(formatError(err));
}
});
// ── Error mapping ──────────────────────────────────────────────────
function formatError(err: unknown): string {
if (err instanceof PolicyViolationError) {
return `Blocked: ${err.reason}`;
}
if (err instanceof WalletNotFoundError) {
return `No wallet yet. Send /start first.`;
}
if (err instanceof ServerSigningError) {
return `Signing failed. Try again in a moment.`;
}
if (err instanceof NetworkError) {
return `Network hiccup (${err.statusCode ?? ''}). Retrying may help.`;
}
console.error(err);
return `Something went wrong.`;
}
bot.start();
Terminal window
ts-node bot.ts

Open Telegram, message your bot, and try:

/start
/wallet
/balance
/send 0xDEAD...DEAD 0.001

Telegram IDs are stable, but if you ever migrate your user base to a new system (Discord IDs, internal UUIDs) you can’t reassign the wallet — uid() IS the identity. Keep the original userId in your database so you can recover the wallet later regardless of what the user-facing identifier becomes.

parseEther throws on malformed input — 1e9, 'abc', negative numbers — but it doesn’t enforce business rules. Cap the per-transaction amount in your handler (e.g. < 10 AVAX) before signing, so policy violations stay rare and signing latency is reserved for legitimate sends.

Telegram retries webhook deliveries on timeout. getOrCreate is idempotent at the SDK level, but if you do anything else in /start (DB insert, welcome email, free-balance airdrop) wrap it in your own idempotency check — INSERT ... ON CONFLICT or an idempotency key column.

Hardcoding testnet.snowtrace.io works on Fuji. For multi-chain bots, build a chainId → explorer map up front rather than scattering URLs through your handlers.

  • Errors — every error class and what to do when it fires.
  • Security model — what the platform sees vs. what stays in your code.