← Blog
4 de noviembre, 2026 · 12 min

Cuando un gate x402 no puede correr: auditoría de hooks fail-open

Un gate es una verificación que corre antes de que se mueva el dinero. Si esa verificación lanza una excepción, ¿qué debería pasar? En el resource server TypeScript de x402 en main hoy, el pago pasa. La excepción se registra en el log y el ciclo sigue.

No es hipotético. Los servidores Go y Python, el cliente TypeScript y el facilitator TypeScript se detienen en la misma situación. Un componente falla abierto, y resulta ser el que más vendedores ejecutan.

Leímos el código de x402-foundation/x402 main (58e667ff, 3 de noviembre de 2026), reproducimos el comportamiento con el scheme batch-settlement real y un store que falla, y corrimos los dos fixes abiertos contra la misma prueba. Los fixes coinciden en la regla central y discrepan en dos detalles que importan a un comprador.

La regla que ya está escrita

Los hooks del resource server tienen seis fases relevantes. beforeVerify y beforeSettle pueden devolver { abort: true, reason } para detener el pago, o { skip: true, result } para responder localmente. afterVerify, afterSettle y las dos fases on*Failure solo observan.

Las dos primeras son gates. Ahí vive una extensión que revisa una firma, una lista de sanciones, un compromiso de request o el saldo de un canal. Puede fallar de dos maneras: devolver un abort o lanzar una excepción.

En main, solo la primera funciona. En x402ResourceServer.ts el ciclo de beforeVerify envuelve cada hook en try/catch, y el catch llama a warnResourceServerHookFailure y continúa. El ciclo de beforeSettle hace lo mismo, salvo que vuelve a lanzar un SettleError. Todo lo demás se traga. La siguiente línea de código es la llamada al facilitator.

El issue #3689 lo abrió el 4 de octubre de 2026 un autor que construye una extensión de request-commitment para Cardano. Su extensión envuelve toda la verificación en try/catch y convierte cualquier error en abort, "pero cada extensión con un gate tiene que acordarse de hacerlo, y nada en los tipos del hook lo señala".

Quién más falla abierto

Un comentarista del issue listó cómo se comportan los demás componentes. Verificamos cada uno contra main.

Es decir, el mismo SDK tiene un facilitator que trata un gate que lanza como una parada y un resource server que lo trata como aprobado. Ambos son nuestros para leer, y no coinciden.

Reproducción con el scheme real

A un autor de extensiones se le puede pedir que capture sus propios errores. Los autores de schemes están en la misma posición, y se publican en el mismo repo. El scheme servidor EVM batch-settlement registra hooks onBeforeVerify y onBeforeSettle. El SVM también.

Su handleBeforeVerify ya convierte los fallos de almacenamiento en abort. Su handleBeforeSettle en settle.ts llama a storage.updateChannel(...) sin try/catch alrededor. El beforeSettle del scheme SVM tiene la misma forma: await this.store.get(...) queda fuera del try que envuelve el commit del cargo. El store EVM es una interfaz ChannelStorage, y el repo trae implementaciones con Redis y archivo, así que un fallo de red ahí es un evento ordinario.

Construimos entonces la prueba más pequeña que lo ejercita. Un x402ResourceServer real, el BatchSettlementEvmScheme real, un store en memoria que lanza cuando se le pide, y un facilitator stub que cuenta llamadas. Tres casos:

La corrimos en main y en los heads de los dos fixes abiertos, reconstruyendo @x402/core cada vez (el paquete EVM resuelve core desde su salida compilada; si se omite la reconstrucción, se reporta main tres veces).

// facilitator calls = cuántas veces se llegó al facilitator stub
main 58e667ff
  A  settle  -> success:true,  facilitator.settle calls: 1
  B  verify  -> isValid:false verification_state_unavailable, calls: 0
  C  verify  -> isValid:true,  calls: 1
  C  settle  -> success:true,  calls: 1

PR #3702 head 55147fc8
  A  settle  -> SettleError extension_hook_error "extension hook failed", calls: 0
  B  igual que main (el scheme ya hace abort)
  C  verify  -> isValid:false extension_hook_error, calls: 0
  C  settle  -> SettleError extension_hook_error, calls: 0

PR #4109 head d342db01
  A  settle  -> SettleError extension_hook_error "ECONNREFUSED redis:6379 (update)", calls: 0
  B  igual que main
  C  verify  -> isValid:false extension_hook_error "sanctions-list fetch timed out", calls: 0
  C  settle  -> SettleError extension_hook_error, calls: 0

El caso C es la demostración limpia: en main un hook que no pudo completar su verificación devuelve una verificación pagada y un settlement pagado. El caso B muestra el patrón previsto, un abort con razón. El caso A muestra al mismo scheme haciendo lo correcto en una fase y no en la otra.

Lo que el caso A prueba y lo que no. En main, con el store caído, el payload de voucher llega a facilitator.settle. Nuestro stub respondió éxito. El facilitator EVM batch real no: su settle despacha según payloads de deposit, claim, refund y settle, y devuelve invalid_payload_type para cualquier otro, incluido un voucher suelto. Aquí la ruta fail-open la frena una verificación ajena más abajo, la reserva nunca se confirma y el comprador recibe un error opaco en vez de la causa. Eso es suerte, no diseño. Una extensión de KYC o sanciones no tiene ese respaldo.

Dos fixes, dos desacuerdos

Hay dos PR abiertos contra #3689, y ambos dicen cerrarlo.

El PR #3702 se abrió el 5 de octubre. El PR #4109 se abrió el 2 de noviembre. Ambos hacen lo que describe la opción 1 del issue: un throw en beforeVerify devuelve isValid: false con razón extension_hook_error, un throw en beforeSettle lanza un SettleError(400) con la misma razón, no se llama al facilitator y el warning del log se conserva. Las fases consultivas siguen registrando y continuando. Ambos dejan los aborts explícitos como están.

Qué se le dice al pagador

#3702 devuelve un texto fijo: "extension hook failed". El comentario de su código da la razón: "El mensaje lanzado puede llevar una URL de RPC, una ruta o un error de almacenamiento; el log de warning ya tiene el detalle."

#4109 reenvía error.message. En la corrida del caso A, el pagador recibe ECONNREFUSED redis:6379 (update), la dirección interna del store del vendedor, dentro de una respuesta que va a un comprador anónimo. En el caso C recibe el texto del error de la lista de sanciones.

Nosotros tomaríamos el texto fijo. Los operadores ya tienen la línea del log. Los compradores deben recibir un código accionable y nada sobre la infraestructura del vendedor.

A qué versión del código apunta

La rama detrás de #3702 es anterior a partes de main actual. Un diff contra main muestra ruido ajeno, como AfterSettleAbortError apareciendo como eliminado, porque la rama no lo ha visto. El hunk relevante es pequeño y limpio, pero necesita rebase antes de mergear. #4109 está más cerca de main y conserva esa maquinaria.

El tercer resultado

El hilo se asentó en un marco útil. Un gate tiene tres resultados, no dos: pasó, falló (abort con la razón propia del gate) y no pudo correr. Colapsar el tercero en alguno de los otros produce este bug, o su imagen espejo, que un comentarista reportó desde su propia producción: un servicio firewall que respondió 200 con verdict: REJECT para una transacción que nunca simuló, porque su RPC estaba limitado por tasa. Un gate que no pudo completar había reportado un veredicto sobre la transacción.

El fix nombra el tercer resultado con su propia razón, extension_hook_error, separada de los códigos de firma inválida y pago inválido. Así un comprador distingue "tu autorización es mala" (detenerse) de "el gate del vendedor falló" (reintentar más tarde).

El transporte todavía lo difumina. En x402HTTPResourceServer.ts, un verify con isValid: false se convierte en una respuesta payment-required mediante createPaymentRequiredResponse(..., verifyResult.invalidReason, ...). Un SettleError pasa por buildSettlementFailureResponse, cuyo status está fijado en 402. Así, un gate que no pudo correr llega al comprador como HTTP 402 en ambas fases. La única señal que distingue es la cadena de razón.

Eso importa por lo que los compradores hacen con un 402. El cliente fetch de x402, como vimos en la auditoría de doble firma, lanza "Payment already attempted" cuando un request que ya lleva un header de pago recibe un 402. Una caída reintentable del vendedor y una autorización rechazada llegan con la misma forma, y el cliente no tiene una rama para la razón. Un 503 para errores de gate de clase infraestructura sería un transporte más honesto. Ningún PR lo hace, y el hilo lo deja fuera de alcance.

Dónde deja esto a la fase de settle

El gate de settle tiene un problema más duro que el de verify. En el flujo after-handler, el handler ya corrió cuando se ejecuta beforeSettle. Fallar cerrado ahí significa que el vendedor hizo el trabajo y no cobra. Es el costo correcto para una verificación que no pudo confirmar que tenía permiso de tomar el dinero, pero es un costo, y argumenta por hacer las verificaciones riesgosas en beforeVerify, antes del handler.

En main ya existe un segundo mecanismo fail-closed para el momento opuesto. AfterSettleAbortError permite que un hook afterSettle convierta un éxito on-chain en success: false conservando el hash de transacción, el pagador y el monto. Su documentación da como caso de uso un fallo del store de vouchers después de que los fondos se movieron. Cualquier otro throw en afterSettle se registra y se ignora. El SDK tiene así tres tratamientos de fallo a lo largo del ciclo de vida:

La asimetría es deliberada, y razonable una vez escrita. No estaba escrita en los tipos de los hooks, que es lo que pedía #3689.

Qué sigue abierto

Tres puntos quedan fuera de ambos PR.

Qué significa para LLM4Agents

Somos un vendedor. Nuestro gateway cotiza un precio, verifica un pago y lo liquida, y cualquier extensión que agreguemos vive en estas dos fases. Si ejecutamos el resource server TypeScript con un gate que puede fallar por I/O de red (una consulta de precio, una allow-list, un libro de gasto), un error transitorio hoy dejaría pasar un pago sin verificar.

La amenaza está acotada. La verificación y el settlement siguen corriendo, así que no se crea ni se pierde dinero en la cadena. Lo que falla abierto es la política: la verificación que agregamos para rechazar algunos pagos no los rechaza mientras su dependencia está caída. Para un gateway cuyo discurso incluye controles de gasto e identidad de agente, es el peor momento para que una política caduque en silencio.

El lado comprador es la otra mitad. Nuestros agentes ven un 402 en ambos resultados. Si queremos que reintenten un fallo de gate y no reintenten una firma mala, la cadena de razón es la única señal, y el SDK no ramifica sobre ella.

Cómo mantenerse en la frontera

  1. Envolver nuestros propios gates hoy. Cada gate que registremos debe capturar sus errores y devolver un abort explícito con una razón reintentable. Funciona en el main actual y sigue funcionando cuando cualquiera de los PR se mergee.
  2. Agregar la prueba de paridad a nuestro harness. Un gate que lance en verify y en settle debe dejar el conteo de llamadas al facilitator en cero. Correrla en cada actualización del SDK. El caso C es la plantilla.
  3. Preferir las verificaciones riesgosas en beforeVerify. Un gate fallido ahí no cuesta nada. Un gate beforeSettle fallido cuesta un handler terminado.
  4. Nunca devolver el texto de la excepción a los pagadores. Si un fix que adoptemos reenvía error.message, sobrescribirlo con un texto fijo en nuestro borde. Comentar en #4109 en esa dirección.
  5. Enseñar la razón a nuestros ejemplos de cliente. Tratar extension_hook_error como reintento con backoff y las razones de pago inválido como terminales. Combinarlo con la regla de retener y conciliar para que un reintento no firme dos veces.
  6. Poner un deadline a cada gate propio. Un temporizador que resuelva a un abort con la misma razón reintentable, para que una dependencia colgada no mantenga abierto un request ni lo deje pasar.
  7. Seguir #3689, #3702 y #4109. Dos PR para un issue significa que hay una decisión pendiente de un maintainer. El resultado sobre el texto de error y sobre un status distinto de 402 definirá qué pueden automatizar con seguridad los compradores. Nuestra revisión previa de la extensión de recovery y del middleware del vendedor muestra dónde más se usa esta superficie de hooks.

Construye agentes sobre gates que fallan cerrado

Registra un agente en LLM4Agents y paga por request en stablecoins sobre un gateway compatible con OpenAI.

Registrar agente