Migrazione a mano → gem: netto -622/+317 righe. Il controller passa da 30 righe di plumbing di protocollo a 4. Trappole: importmap scarta i pin silenziosamente, YAML legge 0x... come intero.
Il diff di un commit:
19 files changed, 317 insertions(+), 622 deletions(-)
Eliminato:
app/services/x402/facilitator_client.rb 53 righe
app/services/x402/payment_handler.rb 86 righe
test/services/x402/facilitator_client_test.rb 112 righe
test/services/x402/payment_handler_test.rb 108 righe
Aggiunto: una riga nel Gemfile, config/initializers/x402.rb (29 righe), due metodi record_x402! su Purchase/Subscription + i test di model corrispondenti.
Non è un refactor — è scambiare la parte che ho scritto io con la parte scritta da qualcun altro. La versione a mano girava da due settimane. Pagamenti una tantum, abbonamenti, registrazione del tx_hash — tutto funzionante. Allora perché migrare?
Questo post spiega come far fare a Claude questo tipo di migrazione, e quando ne vale la pena.
x402 è un protocollo HTTP 402 Payment Required. Il client firma un'autorizzazione EIP-3009, il server verifica e regola una transazione on-chain tramite un facilitator.
Il PaymentHandler a mano, grossomodo:
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
Circa 30 righe di plumbing di protocollo dentro il controller: decodificare la firma, costruire requirements, verify, settle, gestire gli errori. Indirizzo del contratto USDC hardcoded nel codice. Il frontend uguale — window.ethereum.request scritto a mano, cambio chain manuale, assemblaggio manuale dell'header X-PAYMENT.
Far scansionare settimanalmente a Claude l'ecosistema dei protocolli da cui dipendi è una buona abitudine — soprattutto per un protocollo come x402 che è uscito da poco. Claude può seguire l'evolversi della gem x402-rails (lato Ruby) e di x402-fetch (lato JS), vedere la community prendere forma.
Finché un giorno:
Tu: "
x402-railsex402-fetchsono maturi adesso? Se sì, migra."
Claude legge README e changelog, riporta: protocollo v1 stabile, modalità non-optimistic fornisce risultati di settlement, facilitator di default su payai.network. Via alla migrazione.
La stessa action subscribe post-migrazione:
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 ha renderizzato 402 o errore, già 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 parte di protocollo sta tutta dentro la gem. x402_paywall(amount:) risolve in una riga:
X-PAYMENT → la gem renderizza 402 + PaymentRequirementsx402-fetch firma un'autorizzazione EIP-3009, ritenta con X-PAYMENT/verify e /settle del facilitator (non-optimistic, aspetta che settle finisca prima di tornare)performed? rileva che la gem ha già renderizzato e facciamo return; altrimenti request.env["x402.settlement_result"] e request.env["x402.payment"] contengono il risultatoInizializzazione in config/initializers/x402.rb (29 righe):
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 (USDC vero). dev/test → Base Sepolia (USDC testnet gratuito)
config.chain = Rails.env.production? ? "base" : "base-sepolia"
config.currency = "USDC"
config.version = 1
config.optimistic = false # aspetta il settle del facilitator prima di tornare, per registrare tx_hash sincrono
end
Ecco il cuore del movimento "a mano → libreria": 139 righe di services + 220 righe di test services, a mano, scambiate per un initializer di 29 righe + una chiamata di controller di 4 righe.
Lato JS, la versione a mano assemblava la firma da sé e chiamava window.ethereum.request direttamente. Post-migrazione: viem e x402-fetch.
Solo che questi due pacchetti bundleati fanno centinaia di KB. Vendorarli (copiare il dist/ npm in vendor/javascript/) fa esplodere la dimensione del repo. Soluzione: importmap + CDN jsdelivr + 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 è la chiave: non entrano nel <link rel="modulepreload"> del first paint, quindi la maggior parte delle pagine nemmeno li scarica.
Nel controller Stimulus, carica al primo click su 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
}
Gli utenti senza wallet non caricano mai quei 300+ KB. Gli utenti con MetaMask che cliccano "paga" aspettano una volta su jsdelivr (con cache CDN), i click successivi sono istantanei.
La versione a mano era copiata da un'implementazione di riferimento di un altro progetto. Migrando, ho fatto cercare a Claude i puzzi accumulati. Ne sono usciti 3:
selectedAddressCodice vecchio:
js
const address = window.ethereum.selectedAddress
selectedAddress è deprecated nei MetaMask recenti. Modo corretto:
const accounts = await window.ethereum.request({ method: "eth_requestAccounts" })
const address = accounts[0]
eth_requestAccounts fa scattare anche il dialog di connessione — se l'utente non ha ancora connesso il wallet al sito, è la porta di autorizzazione.
Vecchio:
js
if (error.message.includes("User rejected")) { ... }
if (error.message.includes("chain")) { ... }
Il matching su stringhe si rompe sempre al prossimo cambio di copy del wallet. Passa a codici tipizzati:
// Standard EIP-1193: 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// codici custom che attraversano il flusso
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }
Quando lanci i tuoi errori, attacca anche il code:
throw Object.assign(new Error("no_account"), { code: "NO_ACCOUNT" })
Il codice vecchio aveva "Connecting wallet..." e tutte le altre stringhe incastrate nel JS. Spostate in attributi data-value iniettati da 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 legge this.labelConnectingValue. 19 lingue traducono indipendenti. Zero modifiche al JS.
La migrazione è cascata in due trappole che con il protocollo x402 non c'entrano nulla e non sono nel README della gem.
La gem x402-rails porta con sé alcuni Stimulus controller propri. Installata la gem, cliccando il pulsante di pagamento, il browser sputava:
Uncaught Error: no Stimulus controller registered for "x402-pay"
Scavato. importmap.rb aveva chiaramente:
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
Ma vendor/javascript/@hotwired--stimulus.js non esisteva. importmap non dà errore in questa situazione — scarta semplicemente quel pin in silenzio. Risultato: il controller della gem non trova Stimulus, fallisce nel registrarsi, e ogni controller successivo muore.
Fix: vendora il file:
./bin/importmap pin @hotwired/stimulus
Questo scarica il pacchetto npm in vendor/javascript/. Questo tipo di fallimento silenzioso è tipico di ciò che Claude si lascia scappare — vede il pin in importmap.rb e presume OK, senza verificare di sua iniziativa se il file corrispondente in vendor/javascript/ esista davvero. La prossima diagnosi di questo tipo, fai controllare a Claude entrambe le estremità.
0x... come interoCredentials di produzione, scritti ingenuamente:
x402:
wallet_address: 0xAbCd...
Dopo il deploy, ogni click x402 tornava 422 con errore che wallet_address non matchava la regex dell'indirizzo EVM.
YAML parsava 0xAbCd... come intero esadecimale. Lato Ruby, Rails.application.credentials.dig(:x402, :wallet_address) restituiva un Integer, non una String. Il .to_s successivo, prima di entrare in PaymentRequirements, lo convertiva in una stringa decimale — più un indirizzo valido.
Il fix è un carattere — aggiungere virgolette:
x402:
wallet_address: "0xAbCd..."
Questo tipo di trappola Claude non la coglie all'inizio; devi risalire dal messaggio d'errore fino al layer di parsing YAML. Imparato una volta, la prossima, riflesso di virgolette su ogni valore che inizia con 0x in YAML.
Post-migrazione, il numero di file di test non cala, ma la posizione si sposta:
Eliminati:
- test/services/x402/facilitator_client_test.rb (112 righe)
- test/services/x402/payment_handler_test.rb (108 righe)
Aggiunti:
- test/models/purchase_test.rb guadagna 40 righe testando record_x402!
- test/models/subscription_test.rb guadagna 69 righe testando record_x402!
I test del layer service (come gira il protocollo) — tutti via. Sostituiti da test del layer model (come i dati vengono registrati dopo un pagamento riuscito).
Ha senso — il comportamento del protocollo appartiene alla gem, che si testa da sé. Devi solo testare la parte che hai scritto tu: come una riga Purchase / Subscription viene inserita dopo l'arrivo del settlement, e come il tx_hash viene salvato.
Questo è anche il segnale duro per "dovrei migrare?": se i tuoi test contengono grossi blocchi che affermano "il payload che mando ha la forma giusta" o "quando il facilitator torna isValid=false, gestisco così" — quello è comportamento di protocollo, appartiene alla libreria. Se qualche file di test sotto test/services/ supera le 100 righe, molto probabilmente quel service sta testando un protocollo / interfaccia esterna che dovrebbe essere una libreria.
Non ogni "la community ha tirato fuori una gem" vale la migrazione. Fai chiedere prima a Claude:
0.x ha ancora API in movimento; 1.x è il punto in cui ti blocchi.Soddisfatti questi 5, il prompt di migrazione sta in una frase:
"La gem
x402-railsv1 è stabile. SostituisciPaymentHandler+FacilitatorClientattuali. Mantieni gli stessi endpoint e shape di risposta — voglio solo il lavoro di protocollo dentro la gem. Sposta i test al layer model di conseguenza."
Claude farà: leggere la doc della gem → scrivere l'initializer → riscrivere il controller → cancellare il vecchio service → ricostruire i test. Lungo la strada chiederà conferma due o tre volte (es. "vuoi preservare questo comportamento?"). Finito, esegui bin/rails test, tutto verde, commit.
L'insight vero non è "le librerie battono il fatto a mano". A volte il fatto a mano è la mossa giusta — customizzazione di protocollo, sensibilità di latenza, compliance.
Il vero punto di decisione è:
Quel file nella tua cartella services/ — quello che devi modificare ogni volta che il protocollo si aggiorna — esiste ora una gem che mantiene specificamente quella cosa?
Se sì, allora non è la tua logica di business. È un gatto randagio "protocollato" che hai adottato nel tuo progetto. Due settimane a nutrirlo, gira bene — ma non è tuo. Fallo restituire a Claude alla community. Quello che tieni è scrivere il risultato del protocollo nel tuo model — quella parte è specifica del tuo progetto.
Post-migrazione, la mia cartella x402 contiene solo: un initializer di 29 righe + una chiamata di controller di 4 righe + due metodi record_x402!. Le 139 righe di servizi a mano, e le 220 righe di test di servizio che li accompagnavano — tutte via. Meno codice. Stesso comportamento. Test più stretti. Questa è una migrazione riuscita.