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.
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.
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-railsetx402-fetchsont-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.
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 :
X-PAYMENT → la gem rend un 402 + PaymentRequirementsx402-fetch signe une autorisation EIP-3009, réessaye avec X-PAYMENT/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 transactionInitialisation 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.
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.
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 :
selectedAddressAncien 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.
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" })
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.
La migration a buté sur deux pièges sans rapport direct avec le protocole x402 et absents du README de la gem.
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.
0x... comme un entierLes 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.
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.
Tout « la communauté a sorti une gem » ne vaut pas migration. Faites d'abord poser par Claude les questions suivantes :
0.x a encore une API mouvante ; 1.x, on peut se verrouiller.Ces 5 points cochés, le prompt de migration tient en une phrase :
« La gem
x402-railsv1 est stable. Remplace l'actuelPaymentHandler+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.
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.