Free

Claude eine handgeschriebene x402-Integration auf das Community-Gem migrieren lassen

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.


Hintergrund: Wie die handgeschriebene Version aussah

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.

Der Auslöser: die Bibliotheken sind reif

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-rails und x402-fetch inzwischen 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.

Nach der Migration: der Controller wird zu 4 Zeilen

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:

  • Erste Anfrage ohne X-PAYMENT-Header → Gem rendert 402 + PaymentRequirements
  • Client x402-fetch signiert eine EIP-3009-Autorisierung, versucht erneut mit X-PAYMENT
  • Gem ruft /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 Ergebnis

Initialisierung 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.

Frontend: viem + x402-fetch, aber nicht vendor'n

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.

Nebenbei drei Probleme der alten Implementierung behoben

Die Handversion war aus der Referenzimplementierung eines anderen Projekts kopiert. Beim Migrieren ließ ich Claude nach aufgestauten Gerüchen suchen. Drei kamen raus:

1. Hör auf, selectedAddress zu verwenden

Alter 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.

2. Keine String-Matches auf Fehler

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" })

3. UI-Strings über i18n, kein hartcodiertes Englisch

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.

Zwei echte Fallen

Die Migration rannte in zwei Fallen, die mit dem x402-Protokoll selbst nichts zu tun haben und nicht im Gem-README stehen.

Falle 1: importmap verwirft stumm Pins ohne Vendor-Datei

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.

Falle 2: credentials.yml parst 0x... als Integer

Production-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.

Die Form der Tests ändert sich (das ist das wichtigste Signal)

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.

Wann man Claude diese Art Migration machen lassen sollte

Nicht jedes „Community hat eine Gem veröffentlicht" verdient die Migration. Claude vorher folgende Fragen stellen lassen:

  1. Version der Bibliothek. Eine 0.x-Bibliothek hat noch eine bewegliche API; 1.x ist der Zeitpunkt zum Festschreiben.
  2. Code-Delta ≥ 200 Zeilen. Meins netto -305 Zeilen. Unter 100 Zeilen netto lohnt sich der Switching-Cost nicht.
  3. Test-Konsolidierung ist real. Wenn deine Tests nach der Migration 90 % derselben Dinge mit neuen Stubs behaupten — das Verhalten zog nicht in die Bibliothek um, nur der API-Name änderte sich. Nicht migrieren.
  4. Config konsolidiert sich. In der Handversion USDC-Contract-Adresse, Netzwerkname, Facilitator-URL auf 3 Orte verteilt. Danach: alles in einem 29-zeiligen Initializer. Das ist Wert.
  5. Upgrade-Pfad klar. Wie wird die Bibliothek später aktualisiert? Gibt es eine Changelog-Konvention für Breaking Changes? Wenn nicht, wickle sie in einen eigenen Adapter, damit die Gem nicht in 50 Call-Sites durchblutet.

Sind diese 5 erfüllt, reicht der Migrations-Prompt in einem Satz:

„Gem x402-rails v1 ist stabil. Tausch aktuellen PaymentHandler + FacilitatorClient aus. 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.

Lehre

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.