Приймання криптоплатежів у 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 і кнопкою «Надіслати тест» у кабінеті.