Free

Как заставить Claude перенести самописную интеграцию x402 на коммьюнити-gem

Миграция самописного → gem: чистые -622/+317 строк. Контроллер ужимается с 30 строк протокольного водопровода до 4. Ловушки: importmap молча отбрасывает pin, YAML читает 0x... как целое.


Diff одного коммита:

19 files changed, 317 insertions(+), 622 deletions(-)

Удалено:

app/services/x402/facilitator_client.rb        53 строки
app/services/x402/payment_handler.rb           86 строк
test/services/x402/facilitator_client_test.rb  112 строк
test/services/x402/payment_handler_test.rb     108 строк

Добавлено: одна строка в Gemfile, config/initializers/x402.rb (29 строк), два метода record_x402! на Purchase/Subscription + соответствующие тесты моделей.

Это не рефакторинг — это замена части, которую я написал, на часть, которую написал кто-то другой. Самописная версия работала две недели. Разовые оплаты, подписки, запись tx_hash — всё исправно. Тогда зачем мигрировать?

Этот пост — о том, как заставить Claude сделать такую миграцию, и когда это того стоит.


Контекст: как выглядела самописная версия

x402 — это протокол HTTP 402 Payment Required. Клиент подписывает EIP-3009-авторизацию, сервер верифицирует и проводит on-chain транзакцию через facilitator.

Самописный PaymentHandler примерно такой:

handler = X402::PaymentHandler.new
payment_payload = handler.decode_payment_signature(params[:payment_signature])
requirements = {
  scheme: "exact",
  network: X402::PaymentHandler::NETWORK.call,
  maxAmountRequired: (plan.price_cents * 10_000).to_s,
  payTo: X402::PaymentHandler::WALLET_ADDRESS.call,
  token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
  description: "#{plan.key} subscription"
}

verify_result = handler.facilitator.verify(payment_payload, requirements)
unless verify_result["isValid"]
  render json: { error: verify_result["invalidReason"] || "Verification failed" }, status: :unprocessable_entity and return
end

settle_result = handler.facilitator.settle(payment_payload, requirements)
unless settle_result["success"]
  render json: { error: settle_result["errorReason"] || "Settlement failed" }, status: :unprocessable_entity and return
end

Около 30 строк протокольного водопровода внутри контроллера: декодировать подпись, собрать requirements, verify, settle, обработать ошибки. Адрес контракта USDC захардкожен в коде. Фронт — то же: самописный window.ethereum.request, ручное переключение chain, ручная сборка header X-PAYMENT.

Триггер: библиотеки созрели

Заставлять Claude раз в неделю сканировать экосистему протоколов, от которых вы зависите, — хорошая привычка, особенно для недавно появившегося протокола вроде x402. Claude может следить, как развиваются gem x402-rails (Ruby) и x402-fetch (JS), видеть, как формируется сообщество.

Пока в какой-то день:

Вы: «x402-rails и x402-fetch уже зрелые? Если да — перенеси.»

Claude читает README и changelog, возвращается с докладом: протокол v1 стабилен, non-optimistic режим даёт результат settlement, facilitator по умолчанию payai.network. Миграция возможна.

После миграции: контроллер — 4 строки

Тот же subscribe после миграции:

def subscribe
  plan = Plan.find(params[:plan])

  if Current.user.subscriptions.active.exists?(plan: plan.key)
    render json: { success: true, plan: plan.key, already_active: true }
    return
  end

  x402_paywall(amount: plan.price_dollars)
  return if performed? # gem отрендерила 402 или ошибку, уже halted

  settlement = request.env["x402.settlement_result"]
  payment    = request.env["x402.payment"]
  return render_failure("settlement failed") unless settlement&.success?

  Subscription.record_x402!(user: Current.user, plan: plan, payment: payment, settlement: settlement)
end

Протокольная часть целиком внутри gem. x402_paywall(amount:) решает всё в одну строку:

  • Первый запрос без header X-PAYMENT → gem рендерит 402 + PaymentRequirements
  • Клиент x402-fetch подписывает EIP-3009-авторизацию, ретраит с X-PAYMENT
  • Gem вызывает /verify и /settle facilitator'а (non-optimistic, ждёт settle перед возвратом)
  • performed? обнаруживает, что gem уже отрендерила, и мы делаем return; иначе request.env["x402.settlement_result"] и request.env["x402.payment"] содержат результат

Инициализация в config/initializers/x402.rb (29 строк):

X402.configure do |config|
  config.wallet_address = Rails.application.credentials.dig(:x402, :wallet_address)
  config.facilitator    = Rails.application.credentials.dig(:x402, :facilitator_url) ||
                          "https://facilitator.payai.network"

  # production → Base mainnet (настоящий USDC). dev/test → Base Sepolia (бесплатный testnet USDC)
  config.chain = Rails.env.production? ? "base" : "base-sepolia"

  config.currency   = "USDC"
  config.version    = 1
  config.optimistic = false # ждать settle от facilitator перед возвратом, чтобы синхронно записать tx_hash
end

Вот суть движения «самописное → библиотека»: 139 строк services + 220 строк service-тестов, самописных, меняются на 29-строчный initializer + 4-строчный вызов в контроллере.

Фронт: viem + x402-fetch, но без vendor

На JS-стороне самописная версия сама собирала подпись и звала window.ethereum.request напрямую. После миграции: viem и x402-fetch.

Вот только эти два пакета в бандле — сотни КБ. Vendor'ить (копировать dist/ npm в vendor/javascript/) — репо разбухнет. Решение: importmap + CDN jsdelivr + ленивая загрузка:

# config/importmap.rb
pin "viem",        to: "https://cdn.jsdelivr.net/npm/viem/+esm",        preload: false
pin "viem/chains", to: "https://cdn.jsdelivr.net/npm/viem/chains/+esm", preload: false
pin "x402-fetch",  to: "https://cdn.jsdelivr.net/npm/x402-fetch/+esm",  preload: false

preload: false — ключ: они не попадают в <link rel="modulepreload"> первого пейнта, так что большинство страниц их вовсе не загружает.

В Stimulus-контроллере — грузить на первом клике pay:

async loadDeps() {
  if (this._deps) return this._deps
  const [{ wrapFetchWithPayment }, { createWalletClient, custom }, { base, baseSepolia }] =
    await Promise.all([
      import("x402-fetch"),
      import("viem"),
      import("viem/chains")
    ])
  this._deps = { wrapFetchWithPayment, createWalletClient, custom, base, baseSepolia }
  return this._deps
}

Пользователи без кошелька никогда не грузят эти 300+ КБ. Те, у кого MetaMask и кто нажал «оплатить», ждут один раз на jsdelivr (с CDN-кэшем), дальше мгновенно.

По пути починили 3 проблемы в старой реализации

Самописная версия была скопирована из референса другого проекта. Во время миграции я попросил Claude поискать накопившийся запах. Нашёл 3:

1. Хватит использовать selectedAddress

Старый код:
js
const address = window.ethereum.selectedAddress

selectedAddress в новых MetaMask deprecated. Правильно:

const accounts = await window.ethereum.request({ method: "eth_requestAccounts" })
const address = accounts[0]

eth_requestAccounts ещё и вызывает диалог подключения — если пользователь раньше не подключал кошелёк к сайту, это вход авторизации.

2. Не матчите ошибки по строкам

Старое:
js
if (error.message.includes("User rejected")) { ... }
if (error.message.includes("chain")) { ... }

Матчинг по строкам всегда ломается на следующем изменении текста в кошельке. Перейти на типизированные коды:

// Стандарт EIP-1193: 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// собственные коды, проходящие через flow
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }

При throw собственных ошибок тоже навешивайте code:

throw Object.assign(new Error("no_account"), { code: "NO_ACCOUNT" })

3. Строки UI через i18n, без захардкоженного английского

Старый код хранил «Connecting wallet...» и все прочие строки прямо в JS. Перенесены в data-value атрибуты, инжектируемые из ERB:

<button data-controller="x402-payment"
        data-x402-payment-label-connecting-value="<%= t('paywall.x402.connecting') %>"
        data-x402-payment-label-signing-value="<%= t('paywall.x402.signing') %>"
        data-x402-payment-error-rejected-value="<%= t('paywall.x402.error.rejected') %>"
        ...>
  <%= t('paywall.x402.pay_button') %>
</button>

JS читает this.labelConnectingValue. 19 языков переводятся независимо. В JS — ноль правок.

Две настоящие ловушки

Миграция наткнулась на две ловушки, не связанные с протоколом x402 и отсутствующие в README gem.

Ловушка 1: importmap молча отбрасывает pin без vendor-файла

Gem x402-rails тянет с собой несколько Stimulus-контроллеров. После установки gem нажатие «оплатить» выдавало:

Uncaught Error: no Stimulus controller registered for "x402-pay"

Копнул. В importmap.rb явно:

pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2

Но vendor/javascript/@hotwired--stimulus.js не существует. importmap в такой ситуации не ошибается — молча отбрасывает этот pin. В итоге контроллер gem не находит Stimulus, не регистрируется, и все последующие контроллеры умирают.

Фикс: положить vendor-файл:

./bin/importmap pin @hotwired/stimulus

Команда скачивает npm-пакет в vendor/javascript/. Такой тихий отказ — классика того, что Claude пропускает: он видит pin в importmap.rb и полагает, что всё OK, сам не проверяя, существует ли соответствующий файл в vendor/javascript/. В следующий раз при такой диагностике просите Claude проверить оба конца.

Ловушка 2: credentials.yml парсит 0x... как целое

Production-credentials, наивно:

x402:
  wallet_address: 0xAbCd...

После деплоя каждый клик x402 возвращал 422 с ошибкой, что wallet_address не подходит под regex EVM-адреса.

YAML распарсил 0xAbCd... как шестнадцатеричное целое. На Ruby-стороне Rails.application.credentials.dig(:x402, :wallet_address) возвращал Integer, не String. Последующий .to_s перед попаданием в PaymentRequirements превращал его в десятичную строку — уже не валидный адрес.

Фикс — один символ, добавить кавычки:

x402:
  wallet_address: "0xAbCd..."

Такие ловушки Claude сначала не ловит; надо идти назад от сообщения об ошибке вниз до слоя парсинга YAML. Поняв один раз, в дальнейшем рефлекторно берите в кавычки любое значение, начинающееся с 0x, в YAML.

Форма тестов меняется (это важнейший сигнал)

После миграции число тестовых файлов не уменьшилось, но расположение сместилось:

Удалены:
- test/services/x402/facilitator_client_test.rb (112 строк)
- test/services/x402/payment_handler_test.rb (108 строк)

Добавлены:
- test/models/purchase_test.rb прибавил 40 строк тестов для record_x402!
- test/models/subscription_test.rb прибавил 69 строк тестов для record_x402!

Тесты сервисного слоя (как работает протокол) — все улетели. Заменены тестами модельного слоя (как данные записываются после успешной оплаты).

Разумно — поведение протокола принадлежит gem, которая сама себя тестирует. Вам остаётся тестировать только то, что написали вы: как вставляется строка Purchase / Subscription после получения результата settlement и как сохраняется tx_hash.

Это же — жёсткий сигнал «нужно ли мигрировать?»: если в ваших тестах есть большие куски, утверждающие «payload, который я отправляю, имеет правильную форму» или «если facilitator возвращает isValid=false, я обрабатываю так» — это поведение протокола, ему место в библиотеке. Если какой-то файл теста в test/services/ превышает 100 строк, скорее всего этот service тестирует протокол / внешний интерфейс, который должен быть библиотекой.

Когда позволять Claude делать такую миграцию

Не каждое «коммьюнити выпустило gem» стоит миграции. Пусть Claude сначала задаст эти вопросы:

  1. Версия библиотеки. У 0.x API ещё двигается; 1.x — точка фиксации.
  2. Дельта кода ≥ 200 строк. У меня netto -305 строк. Ниже 100 строк netto — switching cost не оправдан.
  3. Консолидация тестов — реальна. Если после миграции ваши тесты по-прежнему утверждают 90 % того же с новым набором stub'ов — поведение не переехало в библиотеку, поменялось лишь имя API. Не мигрируйте.
  4. Конфиг консолидируется. В самописной версии адрес контракта USDC, имя сети, URL facilitator'а разбросаны по 3 местам. После — всё в 29-строчном initializer. Вот ценность.
  5. Путь апгрейда ясен. Как библиотека обновляется в будущем? Есть ли конвенция changelog для breaking changes? Нет — оберните в собственный adapter, чтобы gem не протёк в 50 call-site.

Проходят эти 5 — prompt миграции умещается в одно предложение:

«gem x402-rails v1 стабильна. Замени текущие PaymentHandler + FacilitatorClient. Сохрани те же endpoints и формы ответов — я хочу, чтобы в gem ушла только протокольная работа. Тесты соответственно переведи на модельный слой.»

Claude сделает: прочтёт документацию gem → напишет initializer → перепишет контроллер → удалит старые service → перестроит тесты. По пути попросит подтверждение раз-два (например, «сохранить это поведение?»). Закончил — bin/rails test, всё зелёное, commit.

Вывод

Настоящий инсайт — не «библиотеки лучше самописного». Иногда самописное — правильный ход: кастомизация протокола, чувствительность к задержке, соответствие требованиям.

Настоящая точка решения:

Тот файл в папке services/, который приходится менять при каждом обновлении протокола — существует ли теперь gem, специально поддерживающая именно эту штуку?

Если да — это не ваша бизнес-логика. Это «приручённый протоколом» уличный кот, которого вы приютили в проекте. Две недели подкармливали, работает хорошо — но он не ваш. Пусть Claude вернёт его сообществу. У вас остаётся запись результата протокола в вашу модель — эта часть уникальна для вашего проекта.

После миграции в моей папке x402 лежит только: 29-строчный initializer + 4-строчный вызов контроллера + два метода record_x402!. 139 строк самописного сервисного слоя и 220 строк сервисных тестов, что шли с ними — всё улетело. Кода меньше. Поведение то же. Тесты плотнее. Это — успешная миграция.