Приём криптоплатежей в ASP.NET Core
Интеграция Moonvex в приложение на ASP.NET Core (Minimal API, .NET 8): именованный HttpClient создаёт платёж, webhook читает тело целиком, проверяет подпись HMACSHA256 и отмечает заказ через EF Core ровно один раз.
Пошагово
1.Получите ключ API и секрет webhook
В кабинете Moonvex откройте «Интеграция» → «Создать ключ» → тип «Секретный» (sk_live_…). Ключ показывается один раз — сохраните его на сервере в MOONVEX_SECRET_KEY. Там же добавьте webhook на https-адрес вашего сервера и сохраните секрет подписи в MOONVEX_WEBHOOK_SECRET. В ASP.NET Core храните их в конфигурации: User Secrets при разработке, переменные окружения в продакшене.
// appsettings.json (в продакшене — переменные окружения Moonvex__SecretKey и Moonvex__WebhookSecret) { "Moonvex": { "SecretKey": "sk_live_...", "WebhookSecret": "whsec_..." } }2.Создайте платёж через HttpClient
Именованный клиент moonvex регистрируется через AddHttpClient с базовым адресом и Bearer-ключом. Сумма форматируется с CultureInfo.InvariantCulture — иначе на сервере с русской локалью получится 25,00.
// Program.cs using System.Globalization; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; var builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient("moonvex", c => { c.BaseAddress = new Uri("https://api.moonvex.org"); c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", builder.Configuration["Moonvex:SecretKey"]); c.Timeout = TimeSpan.FromSeconds(15); }); builder.Services.AddDbContext<AppDb>(); var app = builder.Build(); app.MapPost("/orders/{id:long}/pay", async (long id, AppDb db, IHttpClientFactory http) => { var order = await db.Orders.FindAsync(id); if (order is null) return Results.NotFound(); using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/payments") { Content = JsonContent.Create(new { orderId = id.ToString(CultureInfo.InvariantCulture), // decimal → "25.00" с точкой при любой культуре amount = order.Total.ToString("0.00", CultureInfo.InvariantCulture), currency = "USDT", network = "TRON", }), }; request.Headers.Add("Idempotency-Key", "order-" + id + "-1"); using var response = await http.CreateClient("moonvex").SendAsync(request); var payment = await response.Content.ReadFromJsonAsync<JsonElement>(); if (!response.IsSuccessStatusCode) return Results.Problem(payment.GetProperty("error").GetProperty("code").GetString(), statusCode: 502); return Results.Redirect(payment.GetProperty("paymentUrl").GetString()!); });3.Отправьте клиента на paymentUrl
paymentUrl — готовая страница оплаты: точная сумма с комиссией, адрес, QR-код, официальный контракт токена и таймер, статус обновляется сам. Открывайте её редиректом или в новой вкладке — встроить во фрейм нельзя. Возврат клиента на ваш сайт ещё не оплата: заказ отмечается оплаченным только по webhook или статусу из GET /v1/payments/:id. Results.Redirect(url) отправит клиента на страницу оплаты.
4.Примите webhook и проверьте подпись
Moonvex шлёт POST с заголовком Moonvex-Signature: t=<unix-время>,v1=<подпись>. Подпись — HMAC-SHA256 секретом webhook от строки «t.сырое_тело» в hex. Считайте её по байтам тела до разбора JSON, сравнивайте за постоянное время, отклоняйте события старше 5 минут. После смены секрета 24 часа в заголовке два v1 — подходит любой. Тело копируется из request.Body в MemoryStream без привязки модели. HMACSHA256.HashData и CryptographicOperations.FixedTimeEquals — встроенные средства .NET 6+.
// Program.cs (продолжение) app.MapPost("/moonvex/webhook", async (HttpRequest request, AppDb db, IConfiguration config) => { // читаем тело целиком, без модели — нужны исходные байты using var ms = new MemoryStream(); await request.Body.CopyToAsync(ms); var raw = ms.ToArray(); if (!MoonvexSignature.Verify(raw, request.Headers["Moonvex-Signature"], config["Moonvex:WebhookSecret"]!)) return Results.BadRequest("bad signature"); using var doc = JsonDocument.Parse(raw); var ev = doc.RootElement; var type = ev.GetProperty("type").GetString(); if (type is "payment.confirmed" or "payment.overpaid") { var orderId = long.Parse(ev.GetProperty("data").GetProperty("orderId").GetString()!, CultureInfo.InvariantCulture); await OrderPayments.MarkPaidOnceAsync(db, ev.GetProperty("id").GetString()!, orderId); } return Results.Ok(); }); app.Run(); static class MoonvexSignature { public static bool Verify(byte[] raw, string? header, string secret, int toleranceSec = 300) { if (string.IsNullOrEmpty(header)) return false; string? ts = null; var sigs = new List<string>(); foreach (var part in header.Split(',')) { var kv = part.Split('=', 2); if (kv.Length != 2) continue; if (kv[0] == "t") ts = kv[1]; else if (kv[0] == "v1") sigs.Add(kv[1]); } if (!long.TryParse(ts, NumberStyles.None, CultureInfo.InvariantCulture, out var t)) return false; if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > toleranceSec) return false; var signed = Encoding.UTF8.GetBytes(ts + ".").Concat(raw).ToArray(); var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed); foreach (var s in sigs) { byte[] got; try { got = Convert.FromHexString(s); } catch (FormatException) { continue; } // FixedTimeEquals — сравнение за постоянное время if (CryptographicOperations.FixedTimeEquals(got, expected)) return true; } return false; } }5.Отметьте заказ оплаченным ровно один раз
При повторной доставке id события тот же, поэтому запишите его в таблицу с уникальным ключом в одной транзакции с обновлением заказа: дубль ничего не изменит. Выдавайте товар по payment.confirmed и payment.overpaid, не по payment.detected. ExecuteSqlAsync возвращает число затронутых строк, ExecuteUpdateAsync обновляет заказ без загрузки — оба из EF Core 7+.
// OrderPayments.cs (EF Core 7+, PostgreSQL) // CREATE TABLE processed_events (event_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now()); using Microsoft.EntityFrameworkCore; static class OrderPayments { public static async Task MarkPaidOnceAsync(AppDb db, string eventId, long orderId) { await using var tx = await db.Database.BeginTransactionAsync(); // параметры интерполяции EF Core превращает в SQL-параметры, не в текст var fresh = await db.Database.ExecuteSqlAsync( $"INSERT INTO processed_events (event_id) VALUES ({eventId}) ON CONFLICT DO NOTHING"); if (fresh == 1) { await db.Orders .Where(o => o.Id == orderId && o.Status != "paid") .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "paid")); } await tx.CommitAsync(); } }6.Проверьте интеграцию
Тестовой сети у Moonvex нет — всё работает в основных сетях. Кнопка «Отправить тест» в разделе «Интеграция» пришлёт событие webhook.test — так проверяется подпись. Затем оплатите небольшой счёт, например 1 USDT в TRON: это обычный платёж, деньги придут на баланс. Журнал доставок с кодами ответов и кнопкой «Повторить» — там же.
Подводные камни
- Форматируйте суммы с CultureInfo.InvariantCulture: десятичная запятая в amount — ошибка валидации.
- Не объявляйте параметр-модель для тела webhook: после десериализации байты не совпадут с подписанными.
- Глобальная политика авторизации (FallbackPolicy) закроет webhook — добавьте .AllowAnonymous() к маршруту.
- Создавайте HttpClient через IHttpClientFactory, а не new HttpClient() на каждый запрос.