Приём криптоплатежей в 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 и кнопкой «Отправить тест» в кабинете.