Free

Far migrare a Claude un'integrazione x402 scritta a mano sulla gem della community

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.


Contesto: com'era la versione a mano

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.

Il trigger: le librerie sono mature

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-rails e x402-fetch sono 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.

Dopo la migrazione: il controller diventa 4 righe

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:

  • Prima request senza header X-PAYMENT → la gem renderizza 402 + PaymentRequirements
  • Il client x402-fetch firma un'autorizzazione EIP-3009, ritenta con X-PAYMENT
  • La gem chiama /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 risultato

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

Frontend: viem + x402-fetch, ma niente vendor

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.

Nel passaggio, corretti 3 problemi nella vecchia implementazione

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:

1. Smetti di usare selectedAddress

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

2. Non matchare gli errori su stringhe

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

3. Stringhe UI via i18n, non inglese hardcoded

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.

Due trappole reali

La migrazione è cascata in due trappole che con il protocollo x402 non c'entrano nulla e non sono nel README della gem.

Trappola 1: importmap scarta i pin in silenzio se manca il file vendor

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

Trappola 2: credentials.yml parsa 0x... come intero

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

La forma dei test cambia (è il segnale più importante)

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.

Quando lasciare che Claude faccia questo tipo di migrazione

Non ogni "la community ha tirato fuori una gem" vale la migrazione. Fai chiedere prima a Claude:

  1. Numero di versione della libreria. Una libreria 0.x ha ancora API in movimento; 1.x è il punto in cui ti blocchi.
  2. Delta di codice ≥ 200 righe. Il mio netto -305 righe. Sotto 100 righe nette, lo switching cost non vale.
  3. La consolidazione dei test è reale. Se post-migrazione i test continuano ad affermare il 90% delle stesse cose con un nuovo set di stub — il comportamento non si è spostato nella libreria, solo il nome dell'API è cambiato. Non migrare.
  4. La config si consolida. Nella versione a mano, indirizzo del contratto USDC, nome della network, URL del facilitator sparsi in 3 posti. Dopo: tutto in un initializer di 29 righe. Questo è valore.
  5. Percorso di upgrade chiaro. Come si aggiorna la libreria in futuro? C'è una convenzione di changelog per i breaking change? Se no, avvolgila in un tuo adapter perché la gem non sanguini su 50 call site.

Soddisfatti questi 5, il prompt di migrazione sta in una frase:

"La gem x402-rails v1 è stabile. Sostituisci PaymentHandler + FacilitatorClient attuali. 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.

La morale

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.