Free

Faire migrer par Claude une intégration x402 faite maison vers la gem communautaire

Migration fait-main → gem : -622/+317 lignes nettes. Le contrôleur passe de 30 lignes de plomberie de protocole à 4. Pièges : importmap supprime les pins en silence, YAML lit 0x... comme un entier.


Le diff d'un commit :

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

Supprimé :

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

Ajouté : une ligne dans Gemfile, config/initializers/x402.rb (29 lignes), deux méthodes record_x402! sur Purchase/Subscription + les tests de modèle correspondants.

Ce n'est pas une refactorisation — c'est remplacer la partie que j'ai écrite par la partie que quelqu'un d'autre a écrite. La version faite maison tournait depuis deux semaines. Paiements uniques, abonnements, enregistrement du tx_hash — tout fonctionnait. Alors pourquoi migrer ?

Ce billet explique comment faire faire ce genre de migration à Claude, et quand ça vaut le coup.


Contexte : à quoi ressemblait la version faite maison

x402 est un protocole HTTP 402 Payment Required. Le client signe une autorisation EIP-3009, le serveur vérifie et règle une transaction on-chain via un facilitator.

Le PaymentHandler fait maison, en gros :

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

Environ 30 lignes de plomberie de protocole dans le contrôleur : décoder la signature, construire requirements, verify, settle, gérer les erreurs. L'adresse du contrat USDC codée en dur dans le code. Le frontend pareil — window.ethereum.request écrit à la main, changement de chain manuel, assemblage manuel du header X-PAYMENT.

Le déclencheur : les librairies ont mûri

Faire scanner hebdomadairement par Claude l'écosystème des protocoles dont vous dépendez est une bonne habitude — surtout pour un protocole comme x402, récent. Claude peut suivre l'évolution de la gem x402-rails (côté Ruby) et de x402-fetch (côté JS), voir la communauté se structurer.

Puis un jour :

Vous : « x402-rails et x402-fetch sont-ils matures maintenant ? Si oui, migre. »

Claude lit les README et changelogs, rapporte : protocole v1 stable, mode non-optimistic donne les résultats de settlement, facilitator par défaut sur payai.network. Migration possible.

Après migration : le contrôleur fait 4 lignes

La même action subscribe après 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? # la gem a rendu un 402 ou une erreur, déjà 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

La partie protocole est entièrement dans la gem. x402_paywall(amount:) règle tout en une ligne :

  • Première requête sans header X-PAYMENT → la gem rend un 402 + PaymentRequirements
  • Le client x402-fetch signe une autorisation EIP-3009, réessaye avec X-PAYMENT
  • La gem appelle les endpoints /verify et /settle du facilitator (non-optimistic, attend que settle finisse avant de revenir)
  • performed? détecte que la gem a déjà rendu et on fait return ; sinon request.env["x402.settlement_result"] et request.env["x402.payment"] contiennent le résultat de la transaction

Initialisation dans config/initializers/x402.rb (29 lignes) :

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

  config.currency   = "USDC"
  config.version    = 1
  config.optimistic = false # attendre le settle du facilitator avant de revenir, pour enregistrer tx_hash de façon synchrone
end

Voilà le cœur du mouvement « fait maison → librairie » : 139 lignes de services + 220 lignes de tests de services, écrits à la main, échangés contre un initializer de 29 lignes + un appel de contrôleur de 4 lignes.

Frontend : viem + x402-fetch, mais ne pas vendorer

Côté JS, la version faite maison assemblait les signatures à la main et appelait window.ethereum.request directement. Après migration : viem et x402-fetch.

Mais ces deux paquets bundlés font des centaines de KB. Les vendorer (copier le dist/ npm dans vendor/javascript/) ferait exploser la taille du repo. Solution : importmap + CDN jsdelivr + chargement paresseux :

# 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 est la clé : ils n'entrent pas dans le <link rel="modulepreload"> du first paint, donc la plupart des pages ne les téléchargent jamais.

Dans le contrôleur Stimulus, charger au premier clic 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
}

Les utilisateurs sans wallet ne téléchargent jamais ces 300+ KB. Ceux qui ont MetaMask et cliquent sur « payer » attendent une fois sur jsdelivr (avec cache CDN), les clics suivants sont instantanés.

Au passage, correction de 3 problèmes dans l'ancienne implémentation

La version faite maison avait été copiée d'une implémentation de référence d'un autre projet. Pendant la migration, j'ai fait chercher par Claude les odeurs accumulées. 3 sont apparues :

1. Ne plus utiliser selectedAddress

Ancien code :
js
const address = window.ethereum.selectedAddress

selectedAddress est deprecated dans les MetaMask récents. La bonne façon :

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

eth_requestAccounts déclenche aussi la boîte de dialogue de connexion — si l'utilisateur n'a pas encore connecté son wallet au site, c'est la porte d'autorisation.

2. Ne pas matcher les erreurs sur des chaînes

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

Le matching de chaînes casse toujours au prochain changement de copie du wallet. Passer à des codes typés :

// Standard EIP-1193 : 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// codes maison qui traversent le flux
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }

Quand vous throw vos propres erreurs, attachez aussi le code :

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

3. Strings d'UI via i18n, pas d'anglais codé en dur

L'ancien code avait « Connecting wallet... » et toutes les autres strings enfouies dans le JS. Je les ai déplacées vers des attributs data-value injectés depuis 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>

Le JS lit this.labelConnectingValue. Les 19 langues se traduisent indépendamment. Zéro modification de JS.

Deux pièges réels

La migration a buté sur deux pièges sans rapport direct avec le protocole x402 et absents du README de la gem.

Piège 1 : importmap supprime silencieusement les pins sans fichier vendor

La gem x402-rails embarque plusieurs Stimulus controllers. Après installation, le clic sur le bouton de paiement a craché :

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

J'ai creusé. importmap.rb avait clairement :

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

Mais vendor/javascript/@hotwired--stimulus.js n'existait pas. importmap ne génère pas d'erreur dans ce cas — il supprime simplement ce pin en silence. Résultat : le controller de la gem ne trouve pas Stimulus, échoue à s'enregistrer, et chaque controller suivant meurt.

Fix : vendorer le fichier :

./bin/importmap pin @hotwired/stimulus

Cela télécharge le paquet npm dans vendor/javascript/. Ce genre d'échec silencieux est typique de ce que Claude laisse passer — il voit le pin dans importmap.rb et suppose que tout va bien, sans vérifier de sa propre initiative si le fichier correspondant dans vendor/javascript/ existe réellement. La prochaine fois que vous faites ce genre de diagnostic, faites vérifier par Claude les deux extrémités.

Piège 2 : credentials.yml parse 0x... comme un entier

Les credentials de production, écrits naïvement :

x402:
  wallet_address: 0xAbCd...

Après déploiement, chaque clic x402 renvoyait 422 avec une erreur disant que wallet_address ne matchait pas la regex d'adresse EVM.

YAML parsait 0xAbCd... comme un entier hexadécimal. Côté Ruby, Rails.application.credentials.dig(:x402, :wallet_address) renvoyait un Integer, pas un String. Le .to_s suivant, avant d'entrer dans PaymentRequirements, le convertissait en chaîne décimale — plus une adresse valide du tout.

Le fix tient en un caractère — ajouter des guillemets :

x402:
  wallet_address: "0xAbCd..."

Claude ne voit pas ce genre de piège d'emblée ; il faut remonter depuis le message d'erreur jusqu'à la couche de parsing YAML. Une fois appris, la prochaine fois, réflexe de guillemets sur toute valeur commençant par 0x en YAML.

La forme des tests change (c'est le signal le plus important)

Après migration, le nombre de fichiers de test ne baisse pas, mais leur emplacement change :

Supprimés :
- test/services/x402/facilitator_client_test.rb (112 lignes)
- test/services/x402/payment_handler_test.rb (108 lignes)

Ajoutés :
- test/models/purchase_test.rb gagne 40 lignes de tests pour record_x402!
- test/models/subscription_test.rb gagne 69 lignes de tests pour record_x402!

Les tests de la couche service (comment le protocole tourne) — tous partis. Remplacés par des tests de la couche modèle (comment les données sont enregistrées après un paiement réussi).

Logique — le comportement du protocole appartient à la gem, qui se teste elle-même. Vous n'avez besoin de tester que la partie que vous avez écrite : comment une ligne Purchase / Subscription est insérée après arrivée du settlement, et comment tx_hash est stocké.

C'est aussi le signal dur pour « dois-je migrer ? » : si vos tests contiennent de gros pans affirmant « le payload que j'envoie a la bonne forme » ou « quand le facilitator renvoie isValid=false, je gère ainsi » — c'est du comportement de protocole, qui appartient à la librairie. Si un fichier de test sous test/services/ dépasse 100 lignes, ce service teste probablement un protocole / une interface externe qui devrait être une librairie.

Quand laisser Claude faire ce genre de migration

Tout « la communauté a sorti une gem » ne vaut pas migration. Faites d'abord poser par Claude les questions suivantes :

  1. Version de la librairie. Une librairie 0.x a encore une API mouvante ; 1.x, on peut se verrouiller.
  2. Delta de code ≥ 200 lignes. Le mien : net de -305 lignes. Sous 100 lignes nettes, le coût de bascule ne vaut pas la peine.
  3. Consolidation réelle des tests. Si après migration vos tests affirment toujours 90 % des mêmes choses avec un nouveau jeu de stubs — le comportement n'est pas passé dans la librairie, seulement le nom de l'API a changé. Ne migrez pas.
  4. La config se consolide. Dans la version faite maison, adresse du contrat USDC, nom du réseau, URL du facilitator dispersés sur 3 endroits. Après : tout dans un initializer de 29 lignes. C'est de la valeur.
  5. Chemin de mise à jour clair. Comment la librairie se met-elle à jour ? Y a-t-il une convention de changelog pour les breaking changes ? Sinon, enveloppez-la dans un adapter maison pour que la gem ne fuie pas dans 50 points d'appel.

Ces 5 points cochés, le prompt de migration tient en une phrase :

« La gem x402-rails v1 est stable. Remplace l'actuel PaymentHandler + FacilitatorClient. Garde les mêmes endpoints et shapes de réponse — je veux juste que le travail protocolaire aille dans la gem. Déplace les tests vers la couche modèle en conséquence. »

Claude fera : lire la doc de la gem → écrire l'initializer → réécrire le contrôleur → supprimer l'ancien service → reconstruire les tests. En chemin il demandera deux ou trois confirmations (par ex. « veux-tu préserver ce comportement ? »). Terminé, lancez bin/rails test, tout vert, commit.

À retenir

Le vrai insight n'est pas « les librairies battent le fait maison ». Parfois le fait maison est la bonne décision — personnalisation du protocole, latence sensible, conformité.

Le vrai point de décision :

Ce fichier dans votre dossier services/ — celui qu'il faut modifier à chaque mise à jour du protocole — existe-t-il désormais une gem qui maintient spécifiquement cette chose ?

Si oui, ce n'est pas votre logique métier. C'est un chat errant « apprivoisé-protocole » que vous avez adopté dans votre projet. Deux semaines à le nourrir, il tourne bien — mais il n'est pas à vous. Faites le rendre par Claude à la communauté. Ce que vous gardez, c'est écrire le résultat du protocole dans votre modèle — cette partie-là est spécifique à votre projet.

Après migration, mon dossier x402 ne contient plus que : un initializer de 29 lignes + un appel de contrôleur de 4 lignes + deux méthodes record_x402!. Les 139 lignes de services faits main et les 220 lignes de tests de services qui les accompagnaient — tout est parti. Moins de code. Même comportement. Tests plus serrés. Voilà une migration réussie.