Migración hecho-a-mano → gem: neto -622/+317 líneas. El controlador pasa de 30 líneas de plomería de protocolo a 4. Trampas: importmap descarta pins en silencio, YAML lee 0x... como entero.
Diff de un commit:
19 files changed, 317 insertions(+), 622 deletions(-)
Borrado:
app/services/x402/facilitator_client.rb 53 líneas
app/services/x402/payment_handler.rb 86 líneas
test/services/x402/facilitator_client_test.rb 112 líneas
test/services/x402/payment_handler_test.rb 108 líneas
Añadido: una línea en Gemfile, config/initializers/x402.rb (29 líneas), dos métodos record_x402! en Purchase/Subscription + sus tests de modelo.
Esto no es una refactorización — es cambiar la parte que escribí por la parte que escribió alguien más. La versión hecha a mano llevaba dos semanas en producción. Pagos únicos, suscripciones, registro de tx_hash — todo funcionaba. ¿Entonces por qué migrar?
Este post trata de cómo hacer que Claude haga este tipo de migración, y cuándo vale la pena.
x402 es un protocolo HTTP 402 Payment Required. El cliente firma una autorización EIP-3009, el servidor verifica y liquida una transacción on-chain a través de un facilitator.
El PaymentHandler hecho a mano, más o menos:
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
Unas 30 líneas de plomería de protocolo dentro del controller: decodificar la firma, construir requirements, verify, settle, manejar errores. La dirección del contrato USDC hardcodeada en el código. El frontend igual — window.ethereum.request escrito a mano, cambio de cadena manual, armado manual del header X-PAYMENT.
Hacer que Claude escanee cada semana el ecosistema de los protocolos de los que dependes es un buen hábito — sobre todo para un protocolo que lleva poco tiempo en la calle. Claude puede seguir cómo evolucionan la gema x402-rails (lado Ruby) y x402-fetch (lado JS), y ver la comunidad tomar forma.
Hasta que un día:
Tú: "¿
x402-railsyx402-fetchya son maduros? Si lo son, migra."
Claude lee los README y changelogs, reporta: protocolo v1 estable, modo non-optimistic te entrega los resultados de settlement, el facilitator por defecto es payai.network. Adelante.
La misma acción subscribe post-migración:
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 gema renderizó 402 o error, ya está 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 del protocolo está toda dentro de la gema. x402_paywall(amount:) lo maneja en una línea:
X-PAYMENT → la gema renderiza 402 + PaymentRequirementsx402-fetch firma una autorización EIP-3009, reintenta con X-PAYMENT/verify y /settle del facilitator (non-optimistic, espera settle antes de volver)performed? detecta que la gema ya renderizó y hacemos return; si no, request.env["x402.settlement_result"] y request.env["x402.payment"] contienen el resultadoInicialización en config/initializers/x402.rb (29 líneas):
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 real). dev/test → Base Sepolia (USDC gratis de testnet)
config.chain = Rails.env.production? ? "base" : "base-sepolia"
config.currency = "USDC"
config.version = 1
config.optimistic = false # esperar el settle del facilitator antes de volver, para registrar tx_hash sincronamente
end
Ese es el núcleo del movimiento "hecho a mano → librería": 139 líneas de services + 220 líneas de tests de services, a mano, intercambiadas por 29 líneas de initializer + 4 líneas de llamada en el controller.
En JS, la versión a mano armaba firmas a mano y llamaba window.ethereum.request directo. Post-migración: viem y x402-fetch.
Pero esos dos paquetes bundleados suman cientos de KB. Vendorearlos (copiar el dist/ de npm a vendor/javascript/) inflaría el repo. Solución: importmap + CDN de jsdelivr + carga perezosa:
# 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 es la clave: no entran en el <link rel="modulepreload"> del first paint, así que la mayoría de las páginas ni los descargan.
En el controller de Stimulus, cargar al primer click en 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
}
Los usuarios sin wallet nunca cargan esos 300+ KB. Los que tienen MetaMask y hacen click en "pagar" esperan una vez en jsdelivr (con caché CDN), los siguientes clicks son instantáneos.
La versión hecha a mano estaba copiada de una implementación de referencia de otro proyecto. Al migrar, hice que Claude escaneara la podredumbre acumulada. Aparecieron 3:
selectedAddressCódigo viejo:
js
const address = window.ethereum.selectedAddress
selectedAddress está deprecado en MetaMask reciente. Forma correcta:
const accounts = await window.ethereum.request({ method: "eth_requestAccounts" })
const address = accounts[0]
eth_requestAccounts también dispara el diálogo de conexión — si el usuario no había conectado la wallet al sitio antes, ese es el punto de entrada de autorización.
Viejo:
js
if (error.message.includes("User rejected")) { ... }
if (error.message.includes("chain")) { ... }
El matching por string siempre se rompe cuando la próxima wallet cambia el copy. Cambia a codes tipados:
// Estándar EIP-1193: 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// codes propios que pasan por el flujo
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }
Cuando tiras tus propios errores, ponles code también:
throw Object.assign(new Error("no_account"), { code: "NO_ACCOUNT" })
El código viejo tenía "Connecting wallet..." y todos los demás strings incrustados en el JS. Los moví a atributos data-value inyectados desde 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 lee this.labelConnectingValue. Los 19 idiomas traducen independientemente. Cero cambios en JS.
La migración se topó con dos trampas que no tienen nada que ver con el protocolo x402 y no están en el README de la gema.
La gema x402-rails trae sus propios Stimulus controllers. Instalada la gema, al clickear el botón de pago saltó:
Uncaught Error: no Stimulus controller registered for "x402-pay"
Investigando. importmap.rb claramente tenía:
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
Pero vendor/javascript/@hotwired--stimulus.js no existía. importmap no tira error en esta situación — simplemente descarta ese pin en silencio. Entonces el controller de la gema no encuentra Stimulus, falla al registrarse, y todo controller posterior muere.
Fix: vendorear el archivo:
./bin/importmap pin @hotwired/stimulus
Esto baja el paquete npm a vendor/javascript/. Este tipo de fallo silencioso es típico de lo que Claude deja pasar — ve el pin en importmap.rb y asume OK, sin verificar por iniciativa propia si el archivo correspondiente en vendor/javascript/ existe. La próxima vez que hagas este tipo de diagnóstico, haz que Claude chequee ambos extremos.
0x... como enteroCredenciales de producción, escritas ingenuamente:
x402:
wallet_address: 0xAbCd...
Tras el deploy, cada click de x402 devolvía 422 con un error de que wallet_address no matcheaba el regex de dirección EVM.
YAML parseaba 0xAbCd... como entero hexadecimal. Del lado Ruby, Rails.application.credentials.dig(:x402, :wallet_address) devolvía un Integer, no un String. El .to_s posterior al entrar en PaymentRequirements lo convertía en un número decimal — ya no era una dirección válida.
El fix es un carácter — agregar comillas:
x402:
wallet_address: "0xAbCd..."
Claude no agarra esto al principio; tienes que ir hacia atrás desde el mensaje de error hasta la capa de parseo YAML. Una vez aprendido, acostúmbrate a comillar cualquier valor que empiece con 0x en YAML.
Post-migración, el conteo de archivos de test no baja, pero su ubicación cambia:
Borrados:
- test/services/x402/facilitator_client_test.rb (112 líneas)
- test/services/x402/payment_handler_test.rb (108 líneas)
Añadidos:
- test/models/purchase_test.rb ganó 40 líneas testeando record_x402!
- test/models/subscription_test.rb ganó 69 líneas testeando record_x402!
Los tests de capa servicio (cómo corre el protocolo) desaparecieron. Reemplazados por tests de capa modelo (cómo se registra la data tras un pago exitoso).
Tiene sentido — el comportamiento del protocolo pertenece a la gema, que se testea sola. Vos solo necesitás testear la parte que escribiste: cómo se inserta una fila Purchase / Subscription cuando aterriza el resultado de settlement, y cómo se guarda tx_hash.
Esta es también la señal dura de "¿debería migrar?": si tus tests tienen trozos grandes afirmando "el payload que mando tiene la forma correcta" o "cuando el facilitator devuelve isValid=false, manejo así" — eso es comportamiento de protocolo, pertenece a la librería. Si algún archivo de test bajo test/services/ pasa las 100 líneas, probablemente ese service está testeando un protocolo / interfaz externa que debería ser una librería.
No todo "la comunidad sacó una gema" vale la migración. Hacé que Claude pregunte primero estas cosas:
0.x todavía tiene API en movimiento; 1.x es cuando te bloqueás.Con estos 5 OK, el prompt de migración es una frase:
"La gema
x402-railsv1 está estable. ReemplazáPaymentHandler+FacilitatorClientactuales. Mantené los mismos endpoints y shapes de respuesta — solo quiero el laburo del protocolo dentro de la gema. Mové los tests a la capa model en consecuencia."
Claude hará: leer docs de la gema → escribir initializer → reescribir controller → borrar service viejo → reconstruir tests. Va a pedir confirmación dos o tres veces en el camino (p.ej. "¿querés preservar este comportamiento?"). Cuando termina, corré bin/rails test, todo verde, commit.
La intuición real no es "las librerías son mejores que lo hecho a mano". A veces hecho a mano es la movida correcta — customización de protocolo, sensibilidad de latencia, compliance.
El punto de decisión real es:
Ese archivo en tu carpeta services/ — el que tenés que cambiar cada vez que el protocolo se actualiza — ¿hay una gema que se dedica específicamente a mantener esa cosa?
Si sí, entonces no es tu lógica de negocio. Es un gato callejero "domado por protocolo" que adoptaste en tu proyecto. Dos semanas alimentándolo, corre bien — pero no es tuyo. Hacé que Claude lo devuelva a la comunidad. Lo que te quedás es escribir el resultado del protocolo en tu modelo — esa parte es específica de tu proyecto.
Post-migración, mi carpeta x402 solo tiene: un initializer de 29 líneas + una llamada de controller de 4 líneas + dos métodos record_x402!. Las 139 líneas de servicios hechos a mano, y las 220 líneas de tests de servicio que venían con ellos — todas fuera. Menos código. Mismo comportamiento. Tests más apretados. Esa es una migración exitosa.