Приём криптоплатежей в Telegram-боте на Telegraf
Бот на Telegraf выставляет счёт по команде /buy через SDK @moonvex/node и присылает кнопку оплаты, сервер на Express принимает webhook Moonvex, проверяет подпись и пишет клиенту в чат. Для ботов с балансом — постоянный адрес пополнения.
Пошагово
1.Получите ключ API и секрет webhook
В кабинете Moonvex откройте «Интеграция» → «Создать ключ» → тип «Секретный» (sk_live_…). Ключ показывается один раз — сохраните его на сервере в MOONVEX_SECRET_KEY. Там же добавьте webhook на https-адрес вашего сервера и сохраните секрет подписи в MOONVEX_WEBHOOK_SECRET. Для бота нужен ещё BOT_TOKEN от @BotFather.
npm install telegraf express @moonvex/node pg # .env BOT_TOKEN=123456:ABC... MOONVEX_SECRET_KEY=sk_live_... MOONVEX_WEBHOOK_SECRET=whsec_... DATABASE_URL=postgres://user:pass@localhost:5432/bot2.Выставьте счёт по команде /buy
moonvex.payments.create с orderId из чата и сообщения, customerId — id пользователя, chatId — в metadata. Metadata вернётся в webhook. idempotencyKey защищает от двойного счёта, если пользователь повторит команду из-за медленной сети.
// bot.js import { Markup, Telegraf } from 'telegraf'; import { Moonvex } from '@moonvex/node'; export const bot = new Telegraf(process.env.BOT_TOKEN); export const moonvex = new Moonvex({ apiKey: process.env.MOONVEX_SECRET_KEY }); bot.command('buy', async (ctx) => { // уникальный номер заказа: чат + сообщение const orderId = 'tg-' + ctx.chat.id + '-' + ctx.message.message_id; const payment = await moonvex.payments.create( { orderId, amount: '10', currency: 'USDT', network: 'TRON', customerId: String(ctx.from.id), metadata: { chatId: ctx.chat.id }, // вернётся в webhook }, { idempotencyKey: orderId + '-1' }, ); await ctx.reply( 'К оплате: ' + payment.amount + ' ' + payment.asset + ' (' + payment.network + ')', Markup.inlineKeyboard([Markup.button.url('Оплатить', payment.paymentUrl)]), ); }); bot.catch((err, ctx) => { console.error(err); return ctx.reply('Не удалось создать счёт, попробуйте позже'); });3.Отправьте кнопку оплаты
Markup.button.url открывает paymentUrl — готовую страницу оплаты с суммой, адресом и QR-кодом. Во фрейм её встроить нельзя, она открывается в браузере. Показывайте клиенту amount из ответа — это итог с комиссией.
4.Примите webhook и проверьте подпись
Moonvex шлёт POST с заголовком Moonvex-Signature: t=<unix-время>,v1=<подпись>. Подпись — HMAC-SHA256 секретом webhook от строки «t.сырое_тело» в hex. Считайте её по байтам тела до разбора JSON, сравнивайте за постоянное время, отклоняйте события старше 5 минут. После смены секрета 24 часа в заголовке два v1 — подходит любой. verifyWebhook из SDK получает Buffer от express.raw и бросает ошибку при неверной подписи или устаревшем событии.
// server.js import express from 'express'; import { verifyWebhook } from '@moonvex/node'; import { bot } from './bot.js'; import { claimEvent } from './db.js'; const app = express(); // express.raw: подпись считается по сырому телу app.post('/moonvex/webhook', express.raw({ type: 'application/json' }), async (req, res) => { let event; try { event = verifyWebhook(req.body, req.get('Moonvex-Signature'), process.env.MOONVEX_WEBHOOK_SECRET); } catch { return res.status(400).send('bad signature'); } if ((event.type === 'payment.confirmed' || event.type === 'payment.overpaid') && (await claimEvent(event.id))) { await bot.telegram.sendMessage( event.data.metadata.chatId, 'Оплата получена, спасибо! Заказ ' + event.data.orderId, ); } res.sendStatus(200); }); app.listen(3000); // за nginx с https bot.launch();5.Отметьте заказ оплаченным ровно один раз
При повторной доставке id события тот же, поэтому запишите его в таблицу с уникальным ключом в одной транзакции с обновлением заказа: дубль ничего не изменит. Выдавайте товар по payment.confirmed и payment.overpaid, не по payment.detected. claimEvent возвращает true только при первой вставке id события, поэтому сообщение об оплате не уйдёт дважды.
// db.js import pg from 'pg'; const db = new pg.Pool({ connectionString: process.env.DATABASE_URL }); // CREATE TABLE processed_events (event_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now()); /** true только для первой доставки события */ export async function claimEvent(eventId) { const r = await db.query('INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING', [eventId]); return r.rowCount === 1; }6.Пополнение баланса через постоянный адрес
Для бота с внутренним балансом: moonvex.depositAddresses.create({ customerId, network }) возвращает постоянный адрес — тот же при каждом вызове. Каждое поступление приходит событием deposit.confirmed с customerId и receivedAmount; зачисляйте один раз на event.id. Ключу нужно право wallets:write.
// bot.js (продолжение) — нужно право ключа wallets:write bot.command('topup', async (ctx) => { // тот же адрес при каждом вызове: клиент пополняет его когда угодно const deposit = await moonvex.depositAddresses.create({ customerId: String(ctx.from.id), network: 'TRON' }); await ctx.reply('Ваш адрес для пополнения (USDT или TRX, сеть TRON):\n' + deposit.address); }); // server.js, в обработчике webhook: зачисление один раз на event.id if (event.type === 'deposit.confirmed' && (await claimEvent(event.id))) { const d = event.data; await creditBalance(d.customerId, d.asset, d.receivedAmount); // ваша функция await bot.telegram.sendMessage(Number(d.customerId), 'Баланс пополнен: ' + d.receivedAmount + ' ' + d.asset); }
Подводные камни
- express.raw только на маршруте webhook: глобальный express.json() сломает проверку подписи.
- bot.catch ловит ошибки API (например, MoonvexError) и не даёт боту упасть.
- Не выдавайте товар по нажатию кнопки — только по payment.confirmed / payment.overpaid.
- Тестовой сети нет — проверяйте бота платежом на 1 USDT в TRON и кнопкой «Отправить тест» в кабинете.