Приймання криптоплатежів у 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.