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 або помилку, вже halt

  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 рядків сервісних тестів, що йшли з ними, — усе відлетіло. Коду менше. Поведінка та сама. Тести щільніші. Це — успішна міграція.