Приём криптоплатежей в Ruby on Rails

Как принимать криптовалюту в приложении на Rails: небольшой модуль на Net::HTTP создаёт платёж, контроллер ведёт клиента на страницу оплаты, контроллер webhook проверяет подпись по request.raw_post и отмечает заказ один раз.

Пошагово

  1. 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:uniq
  2. 2.Создайте платёж

    Модуль 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
    end
  3. 3.Отправьте клиента на paymentUrl

    paymentUrl — готовая страница оплаты: точная сумма с комиссией, адрес, QR-код, официальный контракт токена и таймер, статус обновляется сам. Открывайте её редиректом или в новой вкладке — встроить во фрейм нельзя. Возврат клиента на ваш сайт ещё не оплата: заказ отмечается оплаченным только по webhook или статусу из GET /v1/payments/:id.

  4. 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_order
  5. 5.Отметьте заказ оплаченным ровно один раз

    При повторной доставке 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
    end
  6. 6.Проверьте интеграцию

    Тестовой сети у 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.

Частые вопросы

Можно ли использовать Faraday или HTTParty?

Да. Нужны только POST /v1/payments с JSON и заголовки Authorization: Bearer и Idempotency-Key. Net::HTTP в примере выбран, чтобы не добавлять гемов.

Почему amount в ответе больше цены заказа?

Комиссию Moonvex платит клиент сверху, поэтому amount — итог к оплате. Ваша цена остаётся в pricing.amount, ваш доход при полной оплате — в merchantAmount. Сверяйте заказ по orderId и pricing.amount, а клиенту показывайте amount.

Есть ли тестовый режим или sandbox?

Нет, тестовой сети нет — Moonvex работает только в основных сетях. Подпись проверяется кнопкой «Отправить тест» (событие webhook.test), а весь путь оплаты — реальным платежом на небольшую сумму, например 1 USDT в TRON.

Другие гайды

Начните принимать оплату сегодня

Аккаунт и первый проект за минуту, без абонентской платы.

Создать аккаунт