x402 recovery: la respuesta pagada que llega gratis
La llamada de pago que más falla no es un pago fallido. Es un pago que se settleó mientras la respuesta nunca llegó. Una extensión propuesta para x402, el PR #3848, intenta estandarizar qué hace el seller después. Reprodujimos la falla en un harness con el middleware Express oficial 2.28.0, y una regla del draft resultó ser todo el juego.
Imagina un agente que firma una autorización de $0.001, el seller ejecuta el handler, el facilitator settlea y la conexión TCP muere antes de que el body llegue al buyer. El dinero se fue. La respuesta no está en mano. ¿Qué debería hacer el reintento?
x402 hoy tiene tres respuestas parciales. El buyer puede firmar de nuevo y pagar dos veces. El facilitator puede rechazar la autorización vieja porque su nonce ya se usó. O el seller puede implementar su propia idempotencia, que el protocolo ni prohíbe ni describe. Un draft nuevo, el PR #3848 con su issue compañero #3847, propone una cuarta: una extensión recovery. Leímos la spec en el commit 5ac11fc (versión 0.4, fechada 2026-10-20) y ejecutamos todo lo que se podía ejecutar.
Qué propone el draft
El PR lo abrió el 2026-10-18 a32116150-ctrl, que opera un pequeño servicio de pago llamado verify402. Agrega un archivo, specs/extensions/extension-recovery.md, de 412 líneas. No cambia código de SDK. Al 2026-10-21 está abierto, sin review de un maintainer y sin labels. Un comentario de bot al crearlo marcó un commit sin firmar y avisó que el PR se cierra tras una semana de inactividad. El autor dice que el draft se escribió con asistencia de IA y fue revisado por una persona.
La spec tiene tres partes.
Evidence bundle
Cada respuesta pagada lleva requestHash y responseHash, ambos SHA-256 sobre JSON canónico (claves ordenadas recursivamente, sin espacios). El hash de la respuesta se calcula después de quitar un conjunto declarado de claves, excludedKeys. Ese conjunto es la "proyección". La idea es que un tercero pueda recalcular ambos hashes a partir de los bytes.
Recovery de la respuesta ya settleada
Si el caller envía la misma idempotency key (header por defecto x-idempotency-key), el seller devuelve la respuesta registrada sin cobrar de nuevo, hasta maxReplays veces dentro de ttlSeconds desde el settlement. Pasada la ventana, una tombstone dice "esta operación se cobró antes" y el paywall se rearma.
La regla fail-closed
El núcleo normativo, citado del draft: los registros de recovery reproducibles DEBEN estar condicionados a un settlement confirmado. Un settlement fallido, abortado o pendiente nunca debe dejar un resultado gratis reproducible.
Las partes 1 y 2 son comodidades. La parte 3 es donde los sellers se lastiman, así que ahí gastamos el tiempo de harness.
El harness
Instalamos @x402/core, @x402/express, @x402/evm y @x402/fetch en 2.28.0, la versión que usó la reproducción independiente que cita el draft. El facilitator es un mock que modela una propiedad de EIP-3009: el nonce de una autorización se settlea una sola vez. Un segundo settle con el mismo nonce devuelve authorization_used. También podemos hacerlo fallar para un recurso elegido. No hay cadena de por medio, así que nada de esto es una afirmación sobre settlement en vivo.
Primero, un hecho del middleware que decide todo lo demás. En @x402/express el orden dentro de una request pagada es: verify, ejecutar el handler de la ruta con la respuesta en buffer, y solo entonces llamar a processSettlement. Si el settlement falla, el body en buffer se descarta y el cliente recibe un 402. Si el handler devuelve un status de 400 o más, el middleware cancela en lugar de settlear. Así que cualquier estado que un handler escribe existe antes de saber el resultado del settlement. Esa es la raíz del primer defecto que nombra el draft.
El punto de extensión es el hook de servidor onAfterSettle. Su contexto trae el resultado del settle y, para HTTP, transportContext.responseBody, los bytes exactos que están por salir. En nuestras corridas solo se disparó con settlements exitosos: cinco llamadas al hook para cinco settles exitosos, cero para los fallidos. Es útil, porque "un settle fallido no promueve nada" sale de la propia semántica del hook, siempre que la promoción ocurra ahí.
Escenario 1: el settle falla después de que corrió el handler
Armamos dos variantes de la misma ruta. La ingenua registra el veredicto en el handler bajo la idempotency key del caller. Una capa de replay frente al paywall sirve gratis las keys registradas. La gated solo hace stage del registro en el handler y lo promueve desde onAfterSettle.
/naive call1 (settle falla) {"status":402}
/naive call2 misma key, sin pago {"status":200,"replayed":true}
/naive settlements exitosos {"n":0}
/gated call1 (settle falla) {"status":402}
/gated call2 misma key, sin pago {"status":402,"replayed":false}
/gated settlements exitosos {"n":0}
El seller ingenuo entregó una respuesta de pago a un buyer que nunca pagó, con cero settlements exitosos en los libros. Cualquiera que logre que un settlement falle puede leer la respuesta: envía un pago que el facilitator rechazará y reintenta con la misma key. El seller gated devuelve el muro de pago otra vez, que es el resultado correcto.
Es el defecto de "recovery antes del settlement" que el draft atribuye a una reproducción independiente. Reprodujimos la misma clase con código propio y no requiere nada exótico. Es lo que sale de poner la escritura al caché junto a la llamada res.json().
También es primo del problema de nuestra auditoría del two-phase gap: valor entregado entre verify y settle. Allí el hueco deja a un buyer obtener una primera respuesta gratis. Aquí el hueco deja que un caché de replay convierta una respuesta sin settlear en muchas.
Escenario 2: el settle funciona y la respuesta se pierde
Es el caso para el que existe la extensión. Pagamos en una ruta simple sin recovery y reintentamos.
/plain reintento firma de nuevo status 200, settlements: 2, sameNonce: false
/plain reintento reenvía el MISMO payload status 402, settlements: 2
/gated reintento misma key s1 200, s2 200, replayed: true, segundo pago: ninguno, settlements: 1
Dos cosas para leer. Si el buyer firma una autorización nueva, que es lo que hace el cliente de referencia en cada 402, el seller no tiene forma de saber que es la misma compra. Dos settlements, una respuesta recibida. Si el buyer reenvía la autorización vieja, el facilitator mock la rechaza, como lo haría el contrato de USDC. El buyer se queda sin dinero y recibe un 402. Con una key y el store gated, el reintento no cuesta nada.
El lado buyer de esto es un issue abierto por sí solo. El issue #3438 apunta a createNonce() en el cliente EVM: cada llamada acuña un nonce nuevo de 32 bytes, y el wrapper de fetch puede ver más de un 402 por compra. Los guards de replay del ecosistema admiten entonces ambas, con razón, porque a nivel de protocolo son dos pagos. El recovery tiene que vivir por encima del nonce.
Escenario 3: output idéntico no es la misma operación
El primer draft emparejaba registros en stage con settlements por el hash de la respuesta. Un reviewer, NSPG13, señaló la falla: dos requests distintas pueden devolver ambas {"verdict":"no_match"}. El draft se revisó en v0.2 para promover por un operationId generado por el servidor y tratar el hash como un chequeo adicional.
Construimos la falla para verla. La operación B entra primero en stage, con su settle retrasado y luego fallido. La operación A arranca 100 ms después y settlea. Ambas producen la misma proyección, {"verdict":"no_match"}. Corrimos el hook dos veces, una promoviendo todo registro en stage cuyo hash coincide, otra promoviendo por operation id.
promoción por hash: A 200, B 402
promoción por hash: B reintenta, misma key, sin pago 200 replayed: true
promoción por opid: A 200, B 402
promoción por opid: B reintenta, misma key, sin pago 402 replayed: false
hashes de proyección idénticos: true
La promoción por hash le dio a B una respuesta gratis que B nunca pagó, porque el settlement de A coincidió con los bytes en stage de B. La promoción por operation id mantuvo a B inerte. El texto v0.4 lo convierte en un control de conformidad (§5.4 control 1), y debe serlo. Una implementación plausible pasa todos los tests del camino feliz y falla solo este.
Una salvedad para juzgar la severidad: el payload de B aquí es la respuesta a una consulta que el caller de B hizo, así que el valor filtrado es un veredicto que el buyer ya iba a recibir. Con datos por caller, una promoción por hash se vuelve una fuga entre cuentas. Por eso el draft también dice "contenido igual no es permiso" para su content companion opcional.
Escenario 4: la key ya tiene casa, y está en el payload
El draft introduce un header de request, x-idempotency-key. Pero x402 ya incluye una extensión de idempotencia, payment-identifier, especificada en specs/extensions/payment_identifier.md e implementada en los paquetes de TypeScript y Go, con ejemplos en Python. La cubrimos en la auditoría de la capa de extensiones. La spec publicada ya dice: mismo id y mismo payload devuelve la respuesta en caché, mismo id y distinto payload devuelve 409, y el id debe atarse a un fingerprint de la request. La regla de conflicto del draft de recovery (§5.2) es la misma regla con un scope de payer añadido.
Lo que no se publica es un store. El ejemplo de servidor del README de TypeScript llama a un idempotencyStore que es pseudocódigo. Buscamos lógica de caché, dedup e idempotencia en las distribuciones 2.28.0 de core, express y extensions y encontramos solo helpers: generatePaymentId, extractPaymentIdentifier, validadores. Cada seller escribe el store, y por lo tanto cada seller puede escribir el bug del Escenario 1.
Así que corrimos la extensión publicada con el mismo patrón. El id vive en PaymentPayload.extensions, de modo que la búsqueda de replay tiene que parsear el header PAYMENT-SIGNATURE antes del paywall.
call1 200 {"verdict":"ok"} | call2 200 {"verdict":"ok","replayed":true}
settle calls: 1
nonce1 != nonce2: true
autorización de call2 sin settlear: validBefore - now = 300 s, value 1000
Funciona, y muestra un costo que el diseño por header del draft evita. Para reintentar vía payment-identifier, el buyer debe firmar una autorización nueva y gastable. El seller sirve el replay y nunca la settlea. Durante 300 segundos, la ventana por defecto en nuestra corrida, el seller guarda una transferencia firmada de $0.001 que el buyer quería gratis. Un seller que se porta bien nunca la tocará. El buyer tiene que confiar en eso.
La misma propiedad es también la mejor respuesta al último punto abierto del review. NSPG13 pidió, en v0.3, un scope de payer verificado antes del replay, porque una dirección aportada por el caller no prueba nada. El texto v0.4 acepta "un challenge del servidor firmado por el payer, una sesión autenticada atada a ese payer, o una prueba equivalente que el rail ya verifica". Una autorización EIP-3009 es exactamente una firma del payer que el rail ya verifica, y una llamada /verify del facilitator la comprueba sin settlear. El costo es la autorización de 300 segundos de arriba, más una salvedad que razonamos pero no medimos: un header PAYMENT-SIGNATURE capturado es una prueba bearer hasta que pasa su validBefore, así que un camino de replay que lo acepte también debe revisar la expiración. Ambos diseños son viables. Ninguno es gratis.
Dos mecanismos, y un hook que decide cuál te toca
v0.3 sumó un punto de un segundo operador en producción. El autor de scvd.store, comentando en #3847, escribió que su servicio nunca escribe una entrada de caché de forma especulativa: la escritura ocurre solo después de confirmar el settlement y con un status de respuesta menor a 300. Como su runtime settlea en línea antes de enviar headers, no hay estado en stage. El draft ahora nombra ambos: stage-then-promote asíncrono cuando la confirmación llega por un hook después de construir el body, y write-after-settle síncrono cuando la confirmación llega primero. No podemos revisar el código de scvd.store; repetimos su relato. El razonamiento de diseño se sostiene por sí solo.
En el middleware Express, te toca el primer mecanismo. El body se construye antes del settlement, así que un seller necesita el par stage-then-promote: un handler que hace stage bajo un operationId generado por el servidor y puesto en el body, y un hook que promueve al éxito. Nuestra ruta gated se mantiene corta. El único paso sutil es llevar el operation id en el body bajo una clave excluida, para que el hook lo lea de responseBody y el hash no cambie.
// handler: solo stage, aún no reproducible
const body = { ...verdict, recovery: { operationId: randomUUID() } };
staged.set(body.recovery.operationId, { key, hash: projHash(body), body });
// hook: promover con el settlement propio de la operación
rs.onAfterSettle(async ctx => {
if (!ctx.result.success || !ctx.result.transaction) return;
const b = JSON.parse(Buffer.from(ctx.transportContext.responseBody).toString());
const s = staged.get(b.recovery?.operationId);
if (s && s.hash === projHash(b)) promoted.set(s.key, s.body);
});
Qué cambiaríamos en el draft
Las reglas de comportamiento son sólidas, y el autor dice con criterio que la forma del wire es inestable. Cuatro puntos sobre el diseño.
Primero, converger la key. Dos mecanismos de idempotencia en un protocolo, un header en uno y un campo del payload en el otro, es como los sellers terminan implementando ambos de forma inconsistente. El draft debería decir cómo se compone recovery con payment-identifier, o definirse como la capa de semántica de replay sobre ese id. El issue #3776 argumenta desde otro ángulo que todo artefacto de correlación debe derivarse una vez en el ingress y nunca re-derivarse aguas abajo. Eso apunta a una sola key.
Segundo, el presupuesto de replay es una exposición. maxReplays y ttlSeconds son perillas por operación que fija el seller. La propia sección de seguridad del draft llama a la key un secreto bearer. Los defaults deberían ser cortos y bajos, y una key que quedó en logs de un cliente debe tratarse como gastada.
Tercero, el evidence bundle se solapa con los recibos firmados. La auditoría de receipts cubre offer-receipt, que ya tiene formato de firma. Los hashes del bundle no están firmados y son autorreportados, por eso el draft dice que "no firma nada por sí mismo". Sin firma, un hash recalculable prueba consistencia, no procedencia.
Cuarto, definir qué significa "settlement confirmado" por cadena. Un success de settle puede ser un recibo preconfirmado en una red y una transacción finalizada en otra, como midió nuestra auditoría de finality. La regla fail-closed es tan estricta como la confirmación que espera.
Qué significa para LLM4Agents
Nuestro gateway vende llamadas a modelos sobre x402, y una llamada a un modelo es el peor caso para respuestas perdidas: es lenta, así que la conexión tiene más tiempo para romperse, y la respuesta es cara de regenerar y no reproducible, porque una segunda muestra puede diferir de la primera. Un camino de recovery que devuelve la misma respuesta registrada vale más aquí que en una consulta determinista. Lo decimos como propiedad de la carga de trabajo, no como afirmación sobre el comportamiento actual de ningún gateway.
La misma propiedad es la amenaza. Cualquier caché de replay en una ruta de modelo es un almacén de respuestas gratis si su regla de promoción está mal. El Escenario 1 muestra que el bug cuesta una línea. El Escenario 3 muestra que el arreglo obvio, emparejar por output, también está mal. Un seller que se equivoca regala completions a cualquiera que pueda hacer fallar un settlement.
También cambia cómo debemos escribir la guía para buyers. Nuestra auditoría wait-or-pay ya le decía a los agentes que reutilicen un mismo payment-identifier entre reintentos de una llamada lógica. El Escenario 2 agrega el número detrás de ese consejo: una firma nueva en un reintento es un segundo settlement, y el seller no puede distinguirlo.
Cómo mantenerse en la frontera
En orden:
Primero, portar los controles, no la extensión. Los controles 1 a 4 y 8 de §5.4 son deterministas y no necesitan facilitator. Agrégalos como tests a cualquier ruta de seller que operemos y que cachee o reproduzca: el output idéntico no debe promover en cruz, una referencia de settlement ausente o vacía no debe promover, una respuesta sin operation id no debe promover, una request cambiada bajo la misma key debe dar conflicto, y un reintento con la dirección correcta pero sin prueba no debe reproducir.
Segundo, estandarizar en payment-identifier para los reintentos del lado buyer y poner el store detrás, con clave el id más un fingerprint de la request, devolviendo 409 ante un desajuste. Eso sigue la spec publicada y evita una segunda key.
Tercero, cablear la promoción por onAfterSettle con el operation id en el body, como en el snippet de arriba, y nunca escribir un registro reproducible desde un handler.
Cuarto, decidir el mecanismo de prueba de control antes de habilitar cualquier replay. Nuestra recomendación es la autorización firmada más una llamada /verify y un chequeo de expiración, con la exposición de 300 segundos documentada para los buyers. La alternativa es una sesión autenticada, que solo sirve a callers que tengan una.
Quinto, retener el evidence bundle hasta que se resuelva la historia de la firma. Si se quieren hashes recalculables, enviarlos dentro de un payload de offer-receipt en lugar de una estructura paralela sin firmar.
Sexto, vigilar tres cosas aguas arriba: si #3848 recibe un review de un maintainer antes de su ventana de inactividad, si #3438 cambia el cliente para que un reintento pueda reutilizar un payload, y si el SDK incorpora un store de idempotencia real detrás de payment-identifier. Lo último quitaría la mayor parte de este problema a todos los sellers a la vez. Lectura relacionada: nuestra auditoría de Sui encontró el mismo patrón en el facilitator, donde dos instancias con dedup en memoria reportaron 20 éxitos para 10 pagos. El estado de idempotencia siempre termina en la capa menos revisada.
Paga por llamada, en stablecoins, sobre una API compatible con OpenAI
Un 402, una firma, más de 345 modelos. Sin suscripción, sin mínimo.
Registra tu agente