Приймання криптоплатежів у Ruby on Rails
Як приймати криптовалюту в застосунку на Rails: невеликий модуль на Net::HTTP створює платіж, контролер веде клієнта на сторінку оплати, контролер webhook перевіряє підпис за request.raw_post і позначає замовлення один раз.
Покроково
1.Отримайте ключ API і секрет webhook
У кабінеті Moonvex відкрийте «Інтеграція» → «Створити ключ» → тип «Секретний» (sk_live_…). Ключ показується один раз — збережіть його на сервері в MOONVEX_SECRET_KEY. Там само додайте webhook на https-адресу вашого сервера і збережіть секрет підпису в MOONVEX_WEBHOOK_SECRET. У Rails підійдуть credentials або змінні оточення. Генератор моделі ProcessedEvent з унікальним індексом — у прикладі.
# ключі — у credentials або змінних оточення export MOONVEX_SECRET_KEY=sk_live_... export MOONVEX_WEBHOOK_SECRET=whsec_... bin/rails g model ProcessedEvent event_id:string:uniq2.Створіть платіж
Модуль Moonvex надсилає POST /v1/payments через Net::HTTP. Сума — BigDecimal#to_s("F"), рядок без експоненти. У Rails 7+ зовнішній редирект потребує allow_other_host: true.
# app/services/moonvex.rb require "net/http" require "json" module Moonvex API = URI("https://api.moonvex.org") def self.create_payment(order_id:, amount:, attempt: 1) uri = URI.join(API, "/v1/payments") req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{ENV.fetch('MOONVEX_SECRET_KEY')}", "Idempotency-Key" => "order-#{order_id}-#{attempt}", "Content-Type" => "application/json") req.body = { orderId: order_id.to_s, amount: amount, currency: "USDT", network: "TRON" }.to_json res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 15) { |http| http.request(req) } body = JSON.parse(res.body) raise "Moonvex #{body.dig('error', 'code')}: #{body.dig('error', 'message')}" unless res.is_a?(Net::HTTPSuccess) body end end # app/controllers/orders_controller.rb class OrdersController < ApplicationController def pay order = current_user.orders.find(params[:id]) # BigDecimal → рядок "25.0", без Float payment = Moonvex.create_payment(order_id: order.id, amount: order.total.to_s("F")) order.update!(moonvex_payment_id: payment["id"]) # Rails 7+: зовнішній редирект дозволяється явно redirect_to payment["paymentUrl"], allow_other_host: true, status: :see_other end end3.Надішліть клієнта на 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 — підходить будь-який. skip_forgery_protection вимикає CSRF лише для цього контролера; сире тіло — request.raw_post. Підписи порівнюються ActiveSupport::SecurityUtils.secure_compare.
# app/controllers/moonvex_webhooks_controller.rb class MoonvexWebhooksController < ApplicationController skip_forgery_protection # у Moonvex немає CSRF-токена — захищає підпис skip_before_action :authenticate_user!, raise: false def create raw = request.raw_post # сире тіло, не params return head :bad_request unless verify(raw, request.headers["Moonvex-Signature"], ENV.fetch("MOONVEX_WEBHOOK_SECRET")) event = JSON.parse(raw) if %w[payment.confirmed payment.overpaid].include?(event["type"]) Order.mark_paid_once!(event["id"], event.dig("data", "orderId")) end head :ok end private def verify(raw, header, secret, tolerance: 300) parts = header.to_s.split(",").filter_map { |p| p.split("=", 2) if p.include?("=") } ts = parts.find { |k, _| k == "t" }&.last return false unless ts&.match?(/\A\d+\z/) && (Time.now.to_i - ts.to_i).abs <= tolerance expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw}") # secure_compare — порівняння за сталий час parts.any? { |k, v| k == "v1" && ActiveSupport::SecurityUtils.secure_compare(expected, v) } end end # config/routes.rb post "/moonvex/webhook", to: "moonvex_webhooks#create" post "/orders/:id/pay", to: "orders#pay", as: :pay_order5.Позначте замовлення оплаченим рівно один раз
Під час повторної доставки id події той самий, тому запишіть його в таблицю з унікальним ключем в одній транзакції з оновленням замовлення: дубль нічого не змінить. Видавайте товар за payment.confirmed і payment.overpaid, а не за payment.detected. Унікальний індекс на event_id: повтор викликає ActiveRecord::RecordNotUnique, транзакція відкочується, замовлення не змінюється.
# app/models/order.rb class Order < ApplicationRecord def self.mark_paid_once!(event_id, order_id) transaction do ProcessedEvent.create!(event_id: event_id) # унікальний індекс не пропустить дубль order = lock.find(order_id) order.update!(status: "paid") unless order.status == "paid" end rescue ActiveRecord::RecordNotUnique # подію вже оброблено — це нормально end end6.Перевірте інтеграцію
Тестової мережі в Moonvex немає — усе працює в основних мережах. Кнопка «Надіслати тест» у розділі «Інтеграція» надішле подію webhook.test — так перевіряється підпис. Потім оплатіть невеликий рахунок, наприклад 1 USDT у TRON: це звичайний платіж, гроші надійдуть на баланс. Журнал доставок із кодами відповідей і кнопкою «Повторити» — там само.
Підводні камені
- Без skip_forgery_protection Rails поверне 422 InvalidAuthenticityToken на кожен webhook.
- Не використовуйте params для перевірки підпису — це вже розібрані дані.
- Rails 7 без allow_other_host: true кине UnsafeRedirectError під час редиректу на pay.moonvex.org.
- Важку обробку надсилайте в Active Job після відповіді head :ok.