Приймання криптоплатежів у Telegram-боті на aiogram
Бот на aiogram 3 виставляє рахунок Moonvex за командою /buy і надсилає кнопку оплати, а сервер на aiohttp у тому самому процесі приймає webhook, перевіряє підпис і повідомляє клієнта про оплату. Для ботів із балансом — постійна адреса поповнення.
Покроково
1.Отримайте ключ API і секрет webhook
У кабінеті Moonvex відкрийте «Інтеграція» → «Створити ключ» → тип «Секретний» (sk_live_…). Ключ показується один раз — збережіть його на сервері в MOONVEX_SECRET_KEY. Там само додайте webhook на https-адресу вашого сервера і збережіть секрет підпису в MOONVEX_WEBHOOK_SECRET. Для бота потрібен ще BOT_TOKEN від @BotFather.
pip install aiogram httpx asyncpg export BOT_TOKEN=123456:ABC... export MOONVEX_SECRET_KEY=sk_live_... export MOONVEX_WEBHOOK_SECRET=whsec_... export DATABASE_URL=postgresql://user:pass@localhost:5432/bot2.Виставте рахунок за командою /buy
Хендлер створює платіж через httpx.AsyncClient: orderId з чату й повідомлення, customerId — id користувача Telegram, chatId — у metadata. Metadata повертається у webhook, і бот знає, кому написати.
# bot.py import os import httpx from aiogram import Bot, Dispatcher from aiogram.filters import Command from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message bot = Bot(os.environ["BOT_TOKEN"]) dp = Dispatcher() moonvex = httpx.AsyncClient( base_url="https://api.moonvex.org", headers={"Authorization": "Bearer " + os.environ["MOONVEX_SECRET_KEY"]}, timeout=15, ) @dp.message(Command("buy")) async def buy(message: Message) -> None: # унікальний номер замовлення: чат + повідомлення order_id = f"tg-{message.chat.id}-{message.message_id}" r = await moonvex.post( "/v1/payments", headers={"Idempotency-Key": order_id + "-1"}, json={ "orderId": order_id, "amount": "10", "currency": "USDT", "network": "TRON", "customerId": str(message.from_user.id), "metadata": {"chatId": message.chat.id}, # повернеться у webhook }, ) payment = r.json() if r.status_code >= 400: await message.answer("Не вдалося створити рахунок, спробуйте пізніше") return kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Оплатити", url=payment["paymentUrl"])]]) await message.answer(f"До сплати: {payment['amount']} {payment['asset']} ({payment['network']})", reply_markup=kb)3.Надішліть кнопку оплати
paymentUrl — готова сторінка оплати із сумою, адресою і QR-кодом; відкрийте її inline-кнопкою з url. Вона відкривається в браузері, вбудовувати її у фрейм не можна. Показуйте клієнтові amount із відповіді — це підсумок із комісією.
4.Прийміть webhook і перевірте підпис
Moonvex надсилає POST із заголовком Moonvex-Signature: t=<unix-час>,v1=<підпис>. Підпис — HMAC-SHA256 секретом webhook від рядка «t.сире_тіло» у hex. Рахуйте його за байтами тіла до розбору JSON, порівнюйте за сталий час, відхиляйте події, старші за 5 хвилин. Після зміни секрету 24 години в заголовку два v1 — підходить будь-який. aiohttp читає сире тіло через await request.read(). Webhook-сервер слухає свій порт, бот працює через polling; ззовні потрібен https — наприклад, nginx перед портом 8080.
# bot.py (продовження) import asyncio import hashlib import hmac import json import time import asyncpg from aiohttp import web def verify_moonvex(raw: bytes, header: str, secret: str, tolerance: int = 300) -> bool: parts = [p.split("=", 1) for p in (header or "").split(",") if "=" in p] t = next((v for k, v in parts if k == "t"), None) if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), t.encode() + b"." + raw, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1") async def moonvex_webhook(request: web.Request) -> web.Response: raw = await request.read() # сирі байти if not verify_moonvex(raw, request.headers.get("Moonvex-Signature", ""), os.environ["MOONVEX_WEBHOOK_SECRET"]): return web.Response(status=400, text="bad signature") event = json.loads(raw) if event["type"] in ("payment.confirmed", "payment.overpaid") and await claim_event(request.app["db"], event["id"]): chat_id = event["data"]["metadata"]["chatId"] await bot.send_message(chat_id, "Оплату отримано, дякуємо! Замовлення " + event["data"]["orderId"]) return web.Response(text="ok") async def main() -> None: app = web.Application() app["db"] = await asyncpg.create_pool(os.environ["DATABASE_URL"]) app.router.add_post("/moonvex/webhook", moonvex_webhook) runner = web.AppRunner(app) await runner.setup() await web.TCPSite(runner, "0.0.0.0", 8080).start() # за nginx з https await dp.start_polling(bot) if __name__ == "__main__": asyncio.run(main())5.Позначте замовлення оплаченим рівно один раз
Під час повторної доставки id події той самий, тому запишіть його в таблицю з унікальним ключем в одній транзакції з оновленням замовлення: дубль нічого не змінить. Видавайте товар за payment.confirmed і payment.overpaid, а не за payment.detected. claim_event вставляє id події з ON CONFLICT DO NOTHING і повертає True лише для першої доставки — повідомлення не прийде двічі.
# bot.py (продовження) # CREATE TABLE processed_events (event_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now()); async def claim_event(db: asyncpg.Pool, event_id: str) -> bool: """True лише для першої доставки події""" fresh = await db.fetchval( "INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id", event_id, ) return fresh is not None6.Поповнення балансу через постійну адресу
Якщо в боті є внутрішній баланс, видайте користувачеві постійну депозитну адресу: POST /v1/deposit-addresses з customerId і network. Повторний виклик поверне ту саму адресу. Кожне надходження приходить подією deposit.confirmed з customerId і receivedAmount — зараховуйте його один раз на event id. Ключу потрібне право wallets:write.
# bot.py (продовження) — потрібне право ключа wallets:write @dp.message(Command("topup")) async def topup(message: Message) -> None: r = await moonvex.post( "/v1/deposit-addresses", json={"customerId": str(message.from_user.id), "network": "TRON"}, ) if r.status_code >= 400: await message.answer("Не вдалося отримати адресу, спробуйте пізніше") return # та сама адреса під час кожного виклику: клієнт може поповнювати її будь-коли await message.answer("Ваша адреса для поповнення (USDT або TRX, мережа TRON):\n" + r.json()["address"]) # у moonvex_webhook: зарахування на баланс клієнта, один раз на event id if event["type"] == "deposit.confirmed" and await claim_event(request.app["db"], event["id"]): d = event["data"] await credit_balance(d["customerId"], d["asset"], d["receivedAmount"]) # ваша функція await bot.send_message(int(d["customerId"]), "Баланс поповнено: " + d["receivedAmount"] + " " + d["asset"])
Підводні камені
- Не позначайте оплату за натисканням кнопки або поверненням у бот — лише за webhook payment.confirmed / payment.overpaid.
- Зберігайте chat_id у metadata: значення metadata — рядки, числа, true/false або null.
- Якщо надсилання повідомлення після claim_event впало, подію вже позначено. Критичні дії ставте у власну чергу в тій самій транзакції.
- Тестової мережі немає — перевіряйте бота платежем на 1 USDT у TRON і кнопкою «Надіслати тест» у кабінеті.