Free

Zlecić Claude'owi migrację ręcznie napisanej integracji x402 do społecznościowego gemu

Migracja ręcznego → gem: netto -622/+317 linii. Kontroler z 30 linii hydrauliki protokołu do 4. Pułapki: importmap po cichu gubi pin, YAML czyta 0x... jako liczbę całkowitą.


Diff jednego commita:

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

Usunięte:

app/services/x402/facilitator_client.rb        53 linie
app/services/x402/payment_handler.rb           86 linii
test/services/x402/facilitator_client_test.rb  112 linii
test/services/x402/payment_handler_test.rb     108 linii

Dodane: jedna linia w Gemfile, config/initializers/x402.rb (29 linii), dwie metody record_x402! na Purchase/Subscription + odpowiadające testy modeli.

To nie jest refactor — to zamiana części, którą napisałem, na część napisaną przez kogoś innego. Wersja ręczna chodziła od dwóch tygodni. Płatności jednorazowe, subskrypcje, zapis tx_hash — wszystko działało. Więc po co migrować?

Ten tekst jest o tym, jak zlecić Claude'owi taki rodzaj migracji, i kiedy to się opłaca.


Kontekst: jak wyglądała wersja ręczna

x402 to protokół HTTP 402 Payment Required. Klient podpisuje autoryzację EIP-3009, serwer weryfikuje i rozlicza transakcję on-chain przez facilitator.

Ręczny PaymentHandler mniej więcej tak:

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

Około 30 linii hydrauliki protokołu w kontrolerze: zdekodować podpis, zbudować requirements, verify, settle, obsłużyć błędy. Adres kontraktu USDC hardcoded w kodzie. Frontend tak samo — ręczne window.ethereum.request, manualna zmiana chain, manualne składanie headera X-PAYMENT.

Wyzwalacz: biblioteki dojrzały

Zlecać Claude'owi cotygodniowe skanowanie ekosystemu protokołów, od których zależysz, to dobry nawyk — zwłaszcza dla świeżego protokołu jak x402. Claude może śledzić ewolucję gemu x402-rails (strona Ruby) i x402-fetch (strona JS), zobaczyć, jak społeczność się kształtuje.

Aż pewnego dnia:

Ty: „x402-rails i x402-fetch już dojrzałe? Jeśli tak, migruj."

Claude czyta README i changelogi, raportuje: protokół v1 stabilny, tryb non-optimistic zwraca wynik settlement, facilitator domyślnie payai.network. Migracja możliwa.

Po migracji: kontroler ma 4 linie

Ta sama akcja subscribe po migracji:

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 wyrenderował 402 lub błąd, już 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

Część protokołu cała w gemie. x402_paywall(amount:) załatwia w linii:

  • Pierwsze żądanie bez headera X-PAYMENT → gem renderuje 402 + PaymentRequirements
  • Klient x402-fetch podpisuje autoryzację EIP-3009, ponawia z X-PAYMENT
  • Gem woła /verify i /settle facilitatora (non-optimistic, czeka na settle przed powrotem)
  • performed? wykrywa, że gem już zrenderował, i robimy return; w przeciwnym razie request.env["x402.settlement_result"] i request.env["x402.payment"] zawierają wynik

Inicjalizacja w config/initializers/x402.rb (29 linii):

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 (prawdziwe USDC). dev/test → Base Sepolia (darmowe testnetowe USDC)
  config.chain = Rails.env.production? ? "base" : "base-sepolia"

  config.currency   = "USDC"
  config.version    = 1
  config.optimistic = false # czekaj na settle facilitatora przed powrotem, aby synchronicznie zapisać tx_hash
end

Oto rdzeń ruchu „ręczne → biblioteka": 139 linii services + 220 linii testów services, ręcznie, zamienionych na 29-linijkowy initializer + 4-linijkowe wywołanie w kontrolerze.

Frontend: viem + x402-fetch, ale bez vendor

Po stronie JS wersja ręczna sama składała podpis i wołała window.ethereum.request bezpośrednio. Po migracji: viem i x402-fetch.

Tylko że te dwa pakiety w bundlu to setki KB. Vendor (skopiować dist/ npm do vendor/javascript/) rozdmuchuje repo. Rozwiązanie: importmap + CDN jsdelivr + leniwe ładowanie:

# 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 jest kluczowe: nie wchodzą w <link rel="modulepreload"> pierwszego paintu, więc większość stron w ogóle ich nie pobiera.

W kontrolerze Stimulus — ładuj przy pierwszym kliku 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
}

Użytkownicy bez portfela nigdy nie ładują tych 300+ KB. Ci z MetaMask, którzy klikną „zapłać", czekają raz na jsdelivr (cache CDN), kolejne kliki natychmiastowe.

Przy okazji naprawione 3 problemy starej implementacji

Wersja ręczna była kopią implementacji referencyjnej z innego projektu. Podczas migracji kazałem Claude'owi rozejrzeć się za nagromadzonym smrodem. Wyszły 3:

1. Przestań używać selectedAddress

Stary kod:
js
const address = window.ethereum.selectedAddress

selectedAddress jest deprecated w nowszych MetaMask. Poprawna droga:

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

eth_requestAccounts pokaże też dialog łączenia — jeśli użytkownik wcześniej nie podłączał portfela do strony, to jest drzwi autoryzacji.

2. Nie matchuj błędów po stringach

Stare:
js
if (error.message.includes("User rejected")) { ... }
if (error.message.includes("chain")) { ... }

Matching po stringach zawsze pęka przy następnej zmianie copy portfela. Przejdź na typowane kody:

// Standard EIP-1193: 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// własne kody przecinające flow
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }

Gdy throwujesz własne błędy, też przypnij code:

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

3. Stringi UI przez i18n, bez hardcoded angielskiego

Stary kod miał „Connecting wallet..." i wszystkie inne stringi wbite w JS. Przeniesione do atrybutów data-value wstrzykiwanych z 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 czyta this.labelConnectingValue. 19 języków tłumaczy niezależnie. Zero zmian w JS.

Dwie prawdziwe pułapki

Migracja wpadła w dwie pułapki niezwiązane z protokołem x402 i nieobecne w README gemu.

Pułapka 1: importmap po cichu odrzuca pin bez pliku vendor

Gem x402-rails niesie własne Stimulus controllery. Po instalacji gemu klik w przycisk zapłaty wyrzucił:

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

Pokopałem. importmap.rb wyraźnie miał:

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

Ale vendor/javascript/@hotwired--stimulus.js nie istniał. importmap w tej sytuacji nie daje błędu — po prostu po cichu odrzuca ten pin. W efekcie controller gemu nie znajduje Stimulusa, nie rejestruje się, a każdy kolejny controller umiera.

Fix: dorzuć plik vendor:

./bin/importmap pin @hotwired/stimulus

To ściąga pakiet npm do vendor/javascript/. Ten rodzaj cichej porażki to klasyk tego, co Claude przepuszcza — widzi pin w importmap.rb i zakłada OK, nie sprawdzając samodzielnie, czy odpowiadający plik w vendor/javascript/ rzeczywiście istnieje. Następnym razem w tej diagnostyce każ Claude'owi sprawdzić oba końce.

Pułapka 2: credentials.yml parsuje 0x... jako integer

Credentials produkcyjne, naiwnie:

x402:
  wallet_address: 0xAbCd...

Po deployu każdy klik x402 zwracał 422 z błędem, że wallet_address nie pasuje do regex adresu EVM.

YAML sparsował 0xAbCd... jako liczbę całkowitą szesnastkową. Po stronie Ruby Rails.application.credentials.dig(:x402, :wallet_address) zwracał Integer, nie String. Późniejsze .to_s przed wejściem do PaymentRequirements zamieniało to w dziesiętny string — już nie jest prawidłowym adresem.

Fix to jeden znak — dodać cudzysłowy:

x402:
  wallet_address: "0xAbCd..."

Takiej pułapki Claude z początku nie łapie; trzeba cofnąć się od komunikatu błędu aż do warstwy parsowania YAML. Raz nauczone — potem odruchowo w cudzysłów każdą wartość zaczynającą się od 0x w YAML.

Kształt testów się zmienia (to najważniejszy sygnał)

Po migracji liczba plików testowych nie spada, ale lokalizacja się przesuwa:

Usunięte:
- test/services/x402/facilitator_client_test.rb (112 linii)
- test/services/x402/payment_handler_test.rb (108 linii)

Dodane:
- test/models/purchase_test.rb zyskał 40 linii testów record_x402!
- test/models/subscription_test.rb zyskał 69 linii testów record_x402!

Testy warstwy service (jak chodzi protokół) — wszystkie zniknęły. Zastąpione testami warstwy model (jak dane są zapisywane po udanej płatności).

Logiczne — zachowanie protokołu należy do gemu, który sam się testuje. Musisz testować tylko tę część, którą napisałeś sam: jak wiersz Purchase / Subscription jest wstawiany po przyjściu wyniku settlement, i jak tx_hash jest zapisywany.

To też twardy sygnał „czy powinienem migrować?": jeśli twoje testy mają duże bloki stwierdzające „payload, który wysyłam, ma właściwy kształt" albo „gdy facilitator zwraca isValid=false, obsługuję to tak" — to zachowanie protokołu, należy do biblioteki. Jeśli jakikolwiek plik testowy pod test/services/ przekracza 100 linii, to prawdopodobnie ten service testuje protokół / zewnętrzny interfejs, który powinien być biblioteką.

Kiedy pozwolić Claude'owi robić taką migrację

Nie każde „społeczność wydała gem" warte migracji. Każ Claude'owi najpierw zapytać:

  1. Numer wersji biblioteki. Biblioteka 0.x ma wciąż ruchome API; 1.x to moment zablokowania.
  2. Delta kodu ≥ 200 linii. Moja tym razem netto -305. Poniżej 100 linii netto, switching cost się nie opłaca.
  3. Konsolidacja testów jest realna. Jeśli po migracji twoje testy wciąż stwierdzają 90% tych samych rzeczy z nowym zestawem stubów — zachowanie nie przeniosło się do biblioteki, zmieniła się tylko nazwa API. Nie migruj.
  4. Config się konsoliduje. W wersji ręcznej adres kontraktu USDC, nazwa sieci, URL facilitatora rozrzucone po 3 miejscach. Po: wszystko w 29-linijkowym initializerze. To jest wartość.
  5. Ścieżka upgradu jasna. Jak biblioteka się w przyszłości podnosi? Jest konwencja changelog dla breaking changes? Jeśli nie, opakuj we własny adapter, żeby gem nie przeciekł w 50 miejsc wywołań.

Kiedy te 5 się sprawdzi, prompt migracji mieści się w jednym zdaniu:

„Gem x402-rails v1 jest stabilny. Wymień aktualne PaymentHandler + FacilitatorClient. Zachowaj te same endpointy i kształty odpowiedzi — chcę tylko, żeby robota protokołu weszła do gemu. Testy odpowiednio przenieś do warstwy modelu."

Claude zrobi: przeczyta dokumentację gemu → napisze initializer → przepisze kontroler → usunie stary service → odbuduje testy. Po drodze poprosi dwa, trzy razy o potwierdzenie (np. „chcesz zachować to zachowanie?"). Po zakończeniu odpal bin/rails test, wszystko zielone, commit.

Morał

Prawdziwy insight nie brzmi „biblioteki biją ręcznie pisane". Czasami ręcznie pisane jest słuszne — customizacja protokołu, wrażliwość na latencję, zgodność.

Prawdziwy punkt decyzji:

Ten plik w twoim folderze services/ — ten, który trzeba zmieniać przy każdej aktualizacji protokołu — czy istnieje już gem, który utrzymuje konkretnie tę rzecz?

Jeśli tak, to nie jest twoja logika biznesowa. To „oswojony przez protokół" bezpański kot, którego przygarnąłeś do projektu. Dwa tygodnie karmiony, chodzi dobrze — ale nie jest twój. Każ Claude'owi oddać go społeczności. To, co zachowujesz, to zapisanie wyniku protokołu do twojego modelu — ta część jest specyficzna dla twojego projektu.

Po migracji mój katalog x402 zawiera już tylko: 29-linijkowy initializer + 4-linijkowe wywołanie kontrolera + dwie metody record_x402!. 139 linii ręcznie napisanych serwisów i 220 linii testów serwisów, które im towarzyszyły — wszystkie poszły. Mniej kodu. To samo zachowanie. Bardziej ścisłe testy. To jest udana migracja.