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