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