Миграция самописного → 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. Миграция возможна.
Тот же 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:) решает всё в одну строку:
X-PAYMENT → gem рендерит 402 + PaymentRequirementsx402-fetch подписывает EIP-3009-авторизацию, ретраит с X-PAYMENT/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-строчный вызов в контроллере.
На 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-кэшем), дальше мгновенно.
Самописная версия была скопирована из референса другого проекта. Во время миграции я попросил Claude поискать накопившийся запах. Нашёл 3:
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 ещё и вызывает диалог подключения — если пользователь раньше не подключал кошелёк к сайту, это вход авторизации.
Старое:
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" })
Старый код хранил «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.
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 проверить оба конца.
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 тестирует протокол / внешний интерфейс, который должен быть библиотекой.
Не каждое «коммьюнити выпустило gem» стоит миграции. Пусть Claude сначала задаст эти вопросы:
0.x API ещё двигается; 1.x — точка фиксации.Проходят эти 5 — prompt миграции умещается в одно предложение:
«gem
x402-railsv1 стабильна. Замени текущиеPaymentHandler+FacilitatorClient. Сохрани те же endpoints и формы ответов — я хочу, чтобы в gem ушла только протокольная работа. Тесты соответственно переведи на модельный слой.»
Claude сделает: прочтёт документацию gem → напишет initializer → перепишет контроллер → удалит старые service → перестроит тесты. По пути попросит подтверждение раз-два (например, «сохранить это поведение?»). Закончил — bin/rails test, всё зелёное, commit.
Настоящий инсайт — не «библиотеки лучше самописного». Иногда самописное — правильный ход: кастомизация протокола, чувствительность к задержке, соответствие требованиям.
Настоящая точка решения:
Тот файл в папке services/, который приходится менять при каждом обновлении протокола — существует ли теперь gem, специально поддерживающая именно эту штуку?
Если да — это не ваша бизнес-логика. Это «приручённый протоколом» уличный кот, которого вы приютили в проекте. Две недели подкармливали, работает хорошо — но он не ваш. Пусть Claude вернёт его сообществу. У вас остаётся запись результата протокола в вашу модель — эта часть уникальна для вашего проекта.
После миграции в моей папке x402 лежит только: 29-строчный initializer + 4-строчный вызов контроллера + два метода record_x402!. 139 строк самописного сервисного слоя и 220 строк сервисных тестов, что шли с ними — всё улетело. Кода меньше. Поведение то же. Тесты плотнее. Это — успешная миграция.