Free

Dejar que Claude migre una integración x402 hecha a mano a la gema de la comunidad

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.


Contexto: cómo se veía la versión hecha a mano

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.

El detonante: las librerías maduraron

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-rails y x402-fetch ya 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.

Después de migrar: el controller queda en 4 líneas

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:

  • Primera request sin header X-PAYMENT → la gema renderiza 402 + PaymentRequirements
  • El cliente x402-fetch firma una autorización EIP-3009, reintenta con X-PAYMENT
  • La gema llama a /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 resultado

Inicializació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.

Frontend: viem + x402-fetch, pero sin vendor

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.

De paso, arreglar 3 problemas de la implementación vieja

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:

1. Deja de usar selectedAddress

Có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.

2. No matchees errores por string

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

3. Strings de UI vía i18n, no inglés hardcodeado

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.

Dos trampas reales

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.

Trampa 1: importmap descarta pins en silencio si falta el archivo vendor

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.

Trampa 2: credentials.yml parsea 0x... como entero

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

La forma de los tests cambia (esta es la señal más importante)

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.

Cuándo dejar que Claude haga este tipo de migración

No todo "la comunidad sacó una gema" vale la migración. Hacé que Claude pregunte primero estas cosas:

  1. Versión de la librería. Una librería 0.x todavía tiene API en movimiento; 1.x es cuando te bloqueás.
  2. Delta de código ≥ 200 líneas. La mía dio -305 neto. Debajo de 100 líneas, el switching cost no da.
  3. La consolidación de tests es real. Si post-migración tus tests siguen afirmando 90% de lo mismo con otro set de stubs — el comportamiento no se mudó a la librería, solo la API cambió de nombre. No migres.
  4. La config se consolida. En la versión a mano, dirección del contrato USDC, nombre de red, URL del facilitator estaban dispersos en 3 lugares. Después: todo en un initializer de 29 líneas. Eso es valor.
  5. El camino de upgrade es claro. ¿Cómo se actualiza la librería? ¿Hay convención de changelog para breaking changes? Si no, envolvela en un adaptador propio para que la gema no se filtre por 50 call sites.

Con estos 5 OK, el prompt de migración es una frase:

"La gema x402-rails v1 está estable. Reemplazá PaymentHandler + FacilitatorClient actuales. 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 moraleja

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.