Приём криптоплатежей в NestJS
Как подключить приём USDT, BTC и других монет в приложение на NestJS: провайдер с SDK, контроллер с редиректом на страницу оплаты и контроллер webhook, который читает req.rawBody и проверяет подпись HMAC.
Пошагово
1.Получите ключ API и секрет webhook
В кабинете Moonvex откройте «Интеграция» → «Создать ключ» → тип «Секретный» (sk_live_…). Ключ показывается один раз — сохраните его на сервере в MOONVEX_SECRET_KEY. Там же добавьте webhook на https-адрес вашего сервера и сохраните секрет подписи в MOONVEX_WEBHOOK_SECRET. В NestJS удобно читать их через ConfigModule; в примере для краткости — process.env.
npm install @moonvex/node # .env MOONVEX_SECRET_KEY=sk_live_... MOONVEX_WEBHOOK_SECRET=whsec_...2.Создайте сервис и контроллер оплаты
MoonvexService оборачивает клиент SDK, контроллер находит заказ и создаёт платёж с idempotencyKey из номера заказа. Декоратор @Redirect() с возвращаемым { url, statusCode: 303 } отправит клиента на страницу оплаты.
// moonvex.service.ts import { Injectable } from '@nestjs/common'; import { Moonvex, type Payment } from '@moonvex/node'; @Injectable() export class MoonvexService { readonly client = new Moonvex({ apiKey: process.env.MOONVEX_SECRET_KEY! }); createPayment(orderId: string, amount: string, attempt = 1): Promise<Payment> { return this.client.payments.create( { orderId, amount, currency: 'USDT', network: 'TRON' }, { idempotencyKey: 'order-' + orderId + '-' + attempt }, ); } } // orders.controller.ts import { Controller, Param, Post, Redirect } from '@nestjs/common'; import { MoonvexService } from './moonvex.service'; import { OrdersService } from './orders.service'; @Controller('orders') export class OrdersController { constructor( private readonly orders: OrdersService, private readonly moonvex: MoonvexService, ) {} @Post(':id/pay') @Redirect() async pay(@Param('id') id: string) { const order = await this.orders.findOne(id); // ваш сервис заказов; total — строка const payment = await this.moonvex.createPayment(String(order.id), order.total); return { url: payment.paymentUrl, statusCode: 303 }; } }3.Отправьте клиента на paymentUrl
paymentUrl — готовая страница оплаты: точная сумма с комиссией, адрес, QR-код, официальный контракт токена и таймер, статус обновляется сам. Открывайте её редиректом или в новой вкладке — встроить во фрейм нельзя. Возврат клиента на ваш сайт ещё не оплата: заказ отмечается оплаченным только по webhook или статусу из GET /v1/payments/:id.
4.Примите webhook и проверьте подпись
Moonvex шлёт POST с заголовком Moonvex-Signature: t=<unix-время>,v1=<подпись>. Подпись — HMAC-SHA256 секретом webhook от строки «t.сырое_тело» в hex. Считайте её по байтам тела до разбора JSON, сравнивайте за постоянное время, отклоняйте события старше 5 минут. После смены секрета 24 часа в заголовке два v1 — подходит любой. В NestJS включите rawBody: true в NestFactory.create — тогда исходные байты будут в req.rawBody, а обычный парсинг JSON продолжит работать для остальных маршрутов.
// main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { // rawBody: true — Nest сохранит исходные байты в req.rawBody const app = await NestFactory.create(AppModule, { rawBody: true }); await app.listen(3000); } bootstrap(); // moonvex-webhook.controller.ts import { BadRequestException, Controller, Headers, HttpCode, Post, Req, type RawBodyRequest } from '@nestjs/common'; import type { Request } from 'express'; import { verifyWebhook, type Payment, type WebhookEvent } from '@moonvex/node'; import { OrdersService } from './orders.service'; @Controller('moonvex') export class MoonvexWebhookController { constructor(private readonly orders: OrdersService) {} @Post('webhook') @HttpCode(200) async handle(@Req() req: RawBodyRequest<Request>, @Headers('moonvex-signature') signature?: string) { let event: WebhookEvent<Payment>; try { event = verifyWebhook<Payment>(req.rawBody!, signature, process.env.MOONVEX_WEBHOOK_SECRET!); } catch { throw new BadRequestException('bad signature'); } if (event.type === 'payment.confirmed' || event.type === 'payment.overpaid') { await this.orders.markPaidOnce(event.id, event.data.orderId!); } return { received: true }; } }5.Отметьте заказ оплаченным ровно один раз
При повторной доставке id события тот же, поэтому запишите его в таблицу с уникальным ключом в одной транзакции с обновлением заказа: дубль ничего не изменит. Выдавайте товар по payment.confirmed и payment.overpaid, не по payment.detected. В примере — транзакция TypeORM: INSERT … ON CONFLICT DO NOTHING RETURNING и обновление заказа. С Prisma то же делается через $transaction и уникальный ключ.
// orders.service.ts import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; @Injectable() export class OrdersService { constructor(private readonly dataSource: DataSource) {} // CREATE TABLE processed_events (event_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now()); async markPaidOnce(eventId: string, orderId: string): Promise<void> { await this.dataSource.transaction(async (m) => { const fresh: unknown[] = await m.query( 'INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id', [eventId], ); if (fresh.length === 0) return; // событие уже обработано await m.query( "UPDATE orders SET status = 'paid', paid_at = now() WHERE id = $1 AND status <> 'paid'", [orderId], ); }); } }6.Проверьте интеграцию
Тестовой сети у Moonvex нет — всё работает в основных сетях. Кнопка «Отправить тест» в разделе «Интеграция» пришлёт событие webhook.test — так проверяется подпись. Затем оплатите небольшой счёт, например 1 USDT в TRON: это обычный платёж, деньги придут на баланс. Журнал доставок с кодами ответов и кнопкой «Повторить» — там же.
Подводные камни
- Без rawBody: true поле req.rawBody будет undefined, а @Body() уже содержит разобранный объект — по нему подпись не проверить.
- Если у вас глобальный AuthGuard, пометьте контроллер webhook публичным (например, своим декоратором @Public()) — Moonvex не передаёт ваш JWT.
- Nest по умолчанию отвечает на POST кодом 201 — это тоже успешный 2xx, но @HttpCode(200) делает ответ явным.
- Тип WebhookEvent<Payment> из SDK даёт автодополнение по event.data: orderId, status, amount, pricing.