Handgeschrieben → gem-Migration: netto -622/+317 Zeilen. Controller schrumpft von 30 Zeilen Protokoll-Klempnerei auf 4. Fallen: importmap verwirft Pins still, YAML liest 0x... als Integer.
Der Diff eines Commits:
19 files changed, 317 insertions(+), 622 deletions(-)
Gelöscht:
app/services/x402/facilitator_client.rb 53 Zeilen
app/services/x402/payment_handler.rb 86 Zeilen
test/services/x402/facilitator_client_test.rb 112 Zeilen
test/services/x402/payment_handler_test.rb 108 Zeilen
Hinzugefügt: eine Zeile in Gemfile, config/initializers/x402.rb (29 Zeilen), zwei record_x402!-Methoden auf Purchase/Subscription + zugehörige Model-Tests.
Das ist kein Refactor — das ist den Teil, den ich geschrieben habe, gegen den Teil tauschen, den jemand anderes geschrieben hat. Die handgeschriebene Version lief zwei Wochen. Einmalzahlungen, Abos, tx_hash-Erfassung — alles lief. Warum dann migrieren?
Der Beitrag handelt davon, wie man Claude diese Art von Migration machen lässt, und wann es sich lohnt.
x402 ist ein HTTP-402-Payment-Required-Protokoll. Der Client signiert eine EIP-3009-Autorisierung, der Server verifiziert und settelt eine On-chain-Transaktion über einen Facilitator.
Der handgeschriebene PaymentHandler etwa so:
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
Ungefähr 30 Zeilen Protokoll-Klempnerei im Controller: Signatur decodieren, requirements aufbauen, verify, settle, Fehler behandeln. USDC-Contract-Adresse hart im Code. Frontend genauso — handgeschriebenes window.ethereum.request, manuelles Chain-Switching, manueller X-PAYMENT-Header-Aufbau.
Claude wöchentlich das Ökosystem der Protokolle scannen zu lassen, von denen man abhängt, ist eine gute Gewohnheit — besonders bei einem kurz existierenden Protokoll wie x402. Claude kann die Gem x402-rails (Ruby-Seite) und x402-fetch (JS-Seite) wachsen sehen, die Community reifen sehen.
Bis eines Tages:
Du: „Sind
x402-railsundx402-fetchinzwischen reif? Wenn ja, migriere."
Claude liest READMEs und Changelogs und meldet zurück: v1-Protokoll stabil, Non-optimistic-Modus liefert Settlement-Ergebnisse, Facilitator-Default auf payai.network. Migration möglich.
Dieselbe subscribe-Action nach der Migration:
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 hat 402 oder Fehler gerendert, bereits gestoppt
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
Der Protokollteil steckt komplett in der Gem. x402_paywall(amount:) erledigt ihn in einer Zeile:
X-PAYMENT-Header → Gem rendert 402 + PaymentRequirementsx402-fetch signiert eine EIP-3009-Autorisierung, versucht erneut mit X-PAYMENT/verify und /settle des Facilitators auf (non-optimistic, wartet auf settle, bevor sie zurückkehrt)performed? erkennt, dass die Gem bereits gerendert hat, und wir return; sonst halten request.env["x402.settlement_result"] und request.env["x402.payment"] das ErgebnisInitialisierung in config/initializers/x402.rb (29 Zeilen):
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 (echtes USDC). dev/test → Base Sepolia (kostenloses Testnet-USDC)
config.chain = Rails.env.production? ? "base" : "base-sepolia"
config.currency = "USDC"
config.version = 1
config.optimistic = false # auf Facilitator-Settle warten, bevor wir zurückkehren, um tx_hash synchron zu erfassen
end
Das ist der Kern der „handgeschrieben → Bibliothek"-Bewegung: 139 Zeilen Services + 220 Zeilen Service-Tests, handgeschrieben, gegen einen 29-zeiligen Initializer + einen 4-zeiligen Controller-Aufruf getauscht.
Auf JS-Seite hat die Handversion Signaturen selbst zusammengesetzt und window.ethereum.request direkt aufgerufen. Nach der Migration: viem und x402-fetch.
Nur sind diese zwei Pakete gebundelt Hunderte KB. Vendor'n (npm-dist/ nach vendor/javascript/ kopieren) bläst das Repo auf. Lösung: importmap + jsdelivr-CDN + Lazy-Load:
# 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 ist der Clou: Sie landen nicht im First-Paint-<link rel="modulepreload">, also laden die meisten Seiten sie gar nicht erst.
Im Stimulus-Controller, beim ersten pay-Click laden:
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
}
Nutzer ohne Wallet laden die 300+ KB nie. Nutzer mit MetaMask, die „zahlen" klicken, warten einmal auf jsdelivr (CDN-gecacht), folgende Clicks sofort.
Die Handversion war aus der Referenzimplementierung eines anderen Projekts kopiert. Beim Migrieren ließ ich Claude nach aufgestauten Gerüchen suchen. Drei kamen raus:
selectedAddress zu verwendenAlter Code:
js
const address = window.ethereum.selectedAddress
selectedAddress ist in neueren MetaMasks deprecated. Korrekter Weg:
const accounts = await window.ethereum.request({ method: "eth_requestAccounts" })
const address = accounts[0]
eth_requestAccounts löst auch den Verbindungsdialog aus — wenn der Nutzer sein Wallet der Seite noch nie verbunden hat, ist das die Autorisierungs-Tür.
Alt:
js
if (error.message.includes("User rejected")) { ... }
if (error.message.includes("chain")) { ... }
String-Matching bricht immer bei der nächsten Wallet-Copy-Änderung. Auf typisierte Codes umstellen:
// EIP-1193-Standard: 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// eigene Codes durch den Flow
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }
Beim Werfen eigener Fehler auch Code anhängen:
throw Object.assign(new Error("no_account"), { code: "NO_ACCOUNT" })
Der alte Code hatte „Connecting wallet..." und alle anderen Strings im JS einzementiert. Nach data-value-Attributen verschoben, die aus ERB injiziert werden:
<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 liest this.labelConnectingValue. 19 Sprachen unabhängig übersetzt. Null JS-Änderungen.
Die Migration rannte in zwei Fallen, die mit dem x402-Protokoll selbst nichts zu tun haben und nicht im Gem-README stehen.
Die Gem x402-rails bringt ein paar eigene Stimulus-Controller mit. Nach der Gem-Installation spuckte der Klick auf den Pay-Button:
Uncaught Error: no Stimulus controller registered for "x402-pay"
Gegraben. importmap.rb hatte klar:
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
Aber vendor/javascript/@hotwired--stimulus.js existierte nicht. importmap fehlt keinen Error in diesem Fall — es verwirft den Pin einfach stumm. Folge: Der Gem-Controller findet Stimulus nicht, scheitert beim Registrieren, jeder nachfolgende Controller stirbt.
Fix: Die Vendor-Datei nachlegen:
./bin/importmap pin @hotwired/stimulus
Das lädt das npm-Paket nach vendor/javascript/. Diese Art stummer Fehler ist typisch für das, was Claude übersieht — er sieht den Pin in importmap.rb und nimmt OK an, ohne von selbst zu prüfen, ob die entsprechende Datei in vendor/javascript/ tatsächlich existiert. Bei dieser Art Diagnose Claude beim nächsten Mal beide Enden prüfen lassen.
0x... als IntegerProduction-Credentials, naiv geschrieben:
x402:
wallet_address: 0xAbCd...
Nach dem Deploy warf jeder x402-Click 422 mit der Meldung, wallet_address matche die EVM-Adress-Regex nicht.
YAML parste 0xAbCd... als hexadezimalen Integer. Auf der Ruby-Seite gab Rails.application.credentials.dig(:x402, :wallet_address) einen Integer zurück, keinen String. Das spätere .to_s vor dem Einsetzen in PaymentRequirements wandelte ihn in einen Dezimalstring — keine gültige Adresse mehr.
Der Fix ist ein Zeichen — Anführungszeichen setzen:
x402:
wallet_address: "0xAbCd..."
Diese Art Falle bekommt Claude anfangs nicht mit; man muss von der Fehlermeldung rückwärts zur YAML-Parse-Ebene laufen. Einmal gelernt, reflexhaft Anführungszeichen um jeden mit 0x beginnenden Wert in YAML.
Nach der Migration sinkt die Anzahl der Test-Dateien nicht, aber der Ort wechselt:
Gelöscht:
- test/services/x402/facilitator_client_test.rb (112 Zeilen)
- test/services/x402/payment_handler_test.rb (108 Zeilen)
Hinzu:
- test/models/purchase_test.rb wächst um 40 Zeilen Tests für record_x402!
- test/models/subscription_test.rb wächst um 69 Zeilen Tests für record_x402!
Service-Layer-Tests (wie das Protokoll läuft) — alle weg. Ersetzt durch Model-Layer-Tests (wie Daten nach einer erfolgreichen Zahlung erfasst werden).
Sinnvoll — das Protokollverhalten gehört der Gem, die sich selbst testet. Du musst nur den Teil testen, den du geschrieben hast: wie eine Purchase- / Subscription-Zeile nach Eingang des Settlement-Ergebnisses eingefügt wird und wie tx_hash gespeichert wird.
Das ist zugleich das harte Signal für „soll ich migrieren?": Wenn deine Tests große Blöcke enthalten, die „das Payload, das ich sende, hat die richtige Form" oder „wenn der Facilitator isValid=false zurückgibt, gehe ich so damit um" prüfen — das ist Protokollverhalten, gehört zur Bibliothek. Wenn eine Test-Datei unter test/services/ über 100 Zeilen hat, testet dieser Service höchstwahrscheinlich ein Protokoll / eine externe Schnittstelle, die eine Bibliothek sein sollte.
Nicht jedes „Community hat eine Gem veröffentlicht" verdient die Migration. Claude vorher folgende Fragen stellen lassen:
0.x-Bibliothek hat noch eine bewegliche API; 1.x ist der Zeitpunkt zum Festschreiben.Sind diese 5 erfüllt, reicht der Migrations-Prompt in einem Satz:
„Gem
x402-railsv1 ist stabil. Tausch aktuellenPaymentHandler+FacilitatorClientaus. Behalte dieselben Endpoints und Response-Shapes — ich will nur, dass die Protokollarbeit in die Gem wandert. Verschiebe die Tests entsprechend auf die Model-Ebene."
Claude wird: Gem-Dokumentation lesen → Initializer schreiben → Controller umschreiben → alten Service löschen → Tests neu aufbauen. Unterwegs fragt er zwei- bis dreimal nach Bestätigung (z. B. „soll dieses Verhalten erhalten bleiben?"). Am Ende bin/rails test, alles grün, dann commit.
Die echte Einsicht ist nicht „Bibliotheken schlagen handgeschrieben". Manchmal ist handgeschrieben der richtige Weg — Protokoll-Anpassung, Latenzsensibilität, Compliance.
Der echte Entscheidungspunkt lautet:
Diese Datei in deinem services/-Ordner — die, die du bei jedem Protokoll-Update ändern musst — gibt es dafür inzwischen eine Gem, die explizit diese Sache pflegt?
Wenn ja, ist das nicht deine Business-Logik. Es ist eine „protokollzahme" Streunerkatze, die du in deinem Projekt adoptiert hast. Zwei Wochen gefüttert, läuft gut — gehört dir aber nicht. Lass Claude sie an die Community zurückgeben. Was dir bleibt, ist das Protokollergebnis in dein Model zu schreiben — dieser Teil ist projektspezifisch.
Nach der Migration enthält mein x402-Verzeichnis nur noch: einen 29-zeiligen Initializer + einen 4-zeiligen Controller-Aufruf + zwei record_x402!-Methoden. Die 139 Zeilen handgeschriebener Services und die 220 Zeilen Service-Tests, die damit einhergingen — alle weg. Weniger Code. Gleiches Verhalten. Engere Tests. Das ist eine erfolgreiche Migration.