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.
npm install @0xgasless/core grammy ethers dotenvTELEGRAM_BOT_TOKEN=123456:ABC-DEF...OXGAS_API_KEY=0xgas_live_sk_...FUJI_RPC=https://api.avax-test.network/ext/bc/C/rpcWallet helper
Section titled “Wallet helper”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.
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.
Nonce + balance helpers
Section titled “Nonce + balance helpers”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.
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.
The bot
Section titled “The bot”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();Run it
Section titled “Run it”ts-node bot.tsOpen Telegram, message your bot, and try:
/start/wallet/balance/send 0xDEAD...DEAD 0.001Patterns to keep in mind
Section titled “Patterns to keep in mind”1. The userId mapping is yours
Section titled “1. The userId mapping is yours”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.
2. Don’t trust the user’s amount
Section titled “2. Don’t trust the user’s amount”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.
3. Idempotency on /start
Section titled “3. Idempotency on /start”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.
4. Pre-compute the explorer URL by chain
Section titled “4. Pre-compute the explorer URL by chain”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.
What’s next
Section titled “What’s next”- Errors — every error class and what to do when it fires.
- Security model — what the platform sees vs. what stays in your code.