Los compradores x402 firman dos veces: retener, conciliar o pagar de nuevo
Un comprador envía un pago firmado, la conexión se cae y la respuesta nunca llega. ¿Pagó? El cliente fetch de referencia de x402 no pregunta. Firma de nuevo. Hoy, en main, una compra puede convertirse en dos autorizaciones válidas.
No es un bug del vendedor ni del facilitator. Cada componente puede comportarse correctamente y el comprador aun así paga dos veces. Eso lo convierte en una propiedad del cliente, y una propiedad del cliente necesita un fix en el cliente.
Reprodujimos el comportamiento en el main actual de x402-foundation/x402 (a17c668a, 2 de noviembre de 2026). Luego leímos el fix abierto, el PR #4094, corrimos el mismo harness contra su head y revisamos dónde deja un hueco.
La pregunta que planteó el issue
El issue #3438 se abrió el 9 de septiembre de 2026 y sigue abierto, con 12 comentarios. Plantea tres cosas. ¿Firmar en cada llamada es problema del llamador por diseño? Si no, ¿dónde debería vivir la reutilización? ¿Debería el guard "Payment already attempted" correr antes de createPaymentPayload en lugar de después?
El código responde la primera pregunta por omisión. En el cliente EVM exact, createEIP3009Payload genera un nonce aleatorio de 32 bytes en cada llamada y fija validBefore en now + maxTimeoutSeconds. Nada en packages/core/src/client recuerda un payload que ya construyó. Dos llamadas para una compra producen dos identidades de replay distintas, y cada guard de replay del stack admite ambas, correctamente.
Eso es correcto para dos compras. Es incorrecto para una compra reintentada.
De dónde sale la segunda firma
Lee wrapFetchWithPayment en main en orden:
- Envía la request. Si no es un 402, la devuelve.
- Parsea el 402, ejecuta los hooks y llama a
client.createPaymentPayload. - Solo entonces revisa si la request clonada ya trae
PAYMENT-SIGNATUREoX-PAYMENT. Si es así, lanza "Payment already attempted". - Envía la request pagada.
- Pasa el resultado a
processPaymentResult. Si un hook dicerecovered, construye otro payload concreatePaymentPayloady envía una vez más.
De ahí se siguen dos cosas. El guard del paso 3 acota un bucle infinito pero no evita una firma. Y la rama de recuperación del paso 5 firma una segunda autorización por diseño, sin preguntar si la primera llegó.
Luego está el llamador. Un intento pagado que termina en timeout, 5xx o un nuevo 402 parece un fallo. Los frameworks de agentes reintentan ante fallos. El wrapper no guarda memoria entre llamadas, así que el reintento del llamador empieza en el paso 1 y llega al paso 2 con un nonce nuevo.
validBefore. Mientras el comprador firma y envía la autorización #2, la #1 sigue vigente. Si el facilitator liquidó la #1 y solo se perdió la respuesta, ambas pueden liquidarse. En Solana la misma ventana es la vida del blockhash, unos dos minutos.
Reproducirlo en main
Armamos un harness pequeño en el paquete fetch. Usa el x402Client y el wrapFetchWithPayment reales, un scheme exact de prueba que genera un nonce distinto por llamada, y un fetch simulado que hace de vendedor y facilitator. Las requests sin pago reciben un 402. Las pagadas cuentan como liquidadas, porque modelamos un facilitator que ya hizo el broadcast de la transferencia, y luego se sabotea la respuesta de tres formas. El llamador invoca dos veces el fetch envuelto para la misma URL.
// mock: el facilitator liquidó, la respuesta se perdió
if (!sig) return new Response("{}", { status: 402, headers: { "PAYMENT-REQUIRED": encodePaymentRequiredHeader(pr) } });
seen.push(sig); settled++;
if (mode === "throw") throw new Error("ETIMEDOUT");
if (mode === "502") return new Response("bad gateway", { status: 502 });
return new Response("{}", { status: 402, headers: { "PAYMENT-REQUIRED": encodePaymentRequiredHeader(pr) } });
Resultados en main, dos invocaciones del llamador por modo:
- 502 tras el intento pagado: resultados 502, 502. Payloads firmados: 2. Firmas distintas enviadas en requests pagadas: 2.
- Timeout de red tras el intento pagado: ambas llamadas lanzan error. Payloads firmados: 2. Firmas distintas enviadas: 2.
- Un 402 nuevo tras el intento pagado: resultados 402, 402. Payloads firmados: 2. Firmas distintas enviadas: 2.
Dos llamadas superpuestas a la misma URL, lanzadas juntas, también firmaron dos veces.
La salvedad importa. Contamos firmas enviadas y asumimos que la primera se liquidó. No corrimos una cadena. El harness muestra que main puede entregarle a un vendedor dos autorizaciones válidas y distintas por una sola compra. Si ambas se liquidan depende del facilitator, y ese es justo el punto: el comprador no puede saberlo.
Un efecto lateral que no ejercitamos pero que se lee en el código: como el guard corre después de firmar, una request que va a ser rechazada igual produce una autorización firmada y dispara los hooks onBeforePaymentCreation. El PR #4093 (12 adiciones, 5 eliminaciones, 3 archivos) mueve el guard antes de la creación. Los trackers de gasto que cuelgan sus débitos de ese hook cuentan un pago que nunca ocurre.
Qué cambia el PR #4094
El PR tiene 645 adiciones y 43 eliminaciones en 13 archivos, apilado sobre #4093. Tiene tres capas.
Un hook de conciliación opcional en el scheme
SchemeNetworkClient gana un reconcilePaymentPayload opcional que devuelve "settled", "unsettled" o "unknown". Se expone a través de x402Client y x402HTTPClient. Los schemes existentes no se ven afectados.
Una lectura contra el contrato del token
El cliente EVM exact lo implementa como una sola llamada authorizationState(from, nonce) sobre el asset. Esa función existe en la implementación de referencia de USDC (authorizationState(address authorizer, bytes32 nonce) returns (bool)) y devuelve true cuando un nonce se usó o se canceló. Corre cuando el signer expone readContract o hay una URL de RPC configurada. De lo contrario la respuesta es "unknown". Los payloads Permit2 también responden "unknown".
Retener el payload por compra
El wrapper guarda el payload firmado bajo una clave de método, URL y body. Un reintento de la misma compra re-presenta el payload retenido mientras accepted.maxTimeoutSeconds siga abierto. Firma de nuevo solo en dos casos: el facilitator devolvió un código de razón sin nada transmitido, o la ventana se cerró y la conciliación confirmó que el nonce no se usó. Si un pago re-presentado sigue sin entregarse y no se puede descartar la liquidación, lanza PaymentOutcomeUnknownError con el payload y la última respuesta.
La decisión de diseño que vale la pena notar es el error. El comportamiento anterior devolvía un 402 reintentable. El nuevo se niega a devolver una respuesta que parezca reintentable cuando el dinero puede haberse ido ya. Obliga al llamador a tratar "resultado desconocido" como un estado, no como un fallo.
También toma posición sobre el texto libre. Cuando falla la llamada al facilitator, el resource server copia el mensaje de la excepción en errorReason, por ejemplo Facilitator settle failed (502): bad gateway. Un comprador no puede distinguir eso de un veredicto del facilitator. Por eso solo un código de razón en snake_case con transaction vacío cuenta como fallo definitivo. settlement_pending y cualquier cosa que contenga nonce_already_used se tratan explícitamente como abiertos o liquidados.
El mismo harness contra el PR
Hicimos checkout del head del PR (f5b58455), recompilamos @x402/core y corrimos los mismos tres modos. Nuestro scheme de prueba no tiene método de conciliación, así que el resultado es "unknown" en todos los casos. Es el camino conservador.
- 502: la primera llamada devuelve 502. La segunda lanza
Payment outcome unknown: HTTP 502 after a payment was sent. Payloads firmados: 1. Firmas distintas enviadas: 1. - Timeout: ambas llamadas lanzan el error de red. Payloads firmados: 1. Firmas distintas enviadas: 1. La segunda llamada re-presentó el payload retenido y chocó con el mismo fallo.
- 402 nuevo: la primera llamada devuelve 402. La segunda lanza el error de resultado desconocido. Payloads firmados: 1. Firmas distintas enviadas: 1.
En los caminos que probamos, la doble firma desaparece. Las cifras del propio PR, que no volvimos a correr, son 5 de 14 corridas con doble liquidación en el cliente publicado contra 0 de 14 en la rama, en su harness; y 7 dobles pagos en una batería de 10 modos de la herramienta de terceros hostile-facilitator sobre la 2.28.0, contra ninguno en la rama. El autor aclara que los cuatro modos reconcile_* de esa herramienta esperan un endpoint /reconcile no estándar en el facilitator, mientras que esta rama consulta on-chain, así que reportan "held, but never asked".
Qué sigue abierto
El resultado de llamadas superpuestas es el que podemos afirmar directamente. Corrimos dos llamadas concurrentes a la misma URL contra el head del PR y ambas firmaron: dos payloads, dos firmas distintas. Un revisor del PR reportó el mismo hallazgo en el mismo commit. La retención se escribe después de que createPaymentPayload resuelve, así que una segunda llamada que llega durante la firma no la encuentra. Una promesa en vuelo por clave, creada antes de la firma, cerraría la ventana. Esto importa en particular para agentes, porque los runtimes de agentes lanzan tool calls en paralelo.
El mismo revisor reportó un segundo caso que no reprodujimos, así que trátalo como afirmación suya. Un 500 con PAYMENT-RESPONSE y errorReason igual a upstream_timeout y transacción vacía pasa la prueba de snake_case. La retención se descarta y se firma un segundo payload. Su argumento es que snake_case es una convención de nombres, no un contrato, y el resource server puede envolver una excepción arbitraria con esa forma. Una lista explícita de códigos previos al broadcast fallaría de forma cerrada.
Tres límites más están declarados en el propio PR.
- Axios.
@x402/axiostiene el mismo flujo y queda como seguimiento. Quien use axios tiene el comportamiento anterior. - Python y otros lenguajes. El port está fuera de alcance.
- Lado receptor. El PR evita pagar dos veces. No convierte "pagado una vez, no entregado" en entrega. Eso requiere que el vendedor sirva el resultado original ante una firma ya liquidada, tema del PR de spec #3437, todavía abierto.
Y un límite estructural. La conciliación solo funciona donde la cadena puede responder. EIP-3009 da una lectura de una llamada. Los payloads Permit2 no dan nada en este PR. SVM no tiene equivalente en el diff. Sin respuesta, lo más seguro es el error lanzado, que es correcto pero traslada la decisión a una persona o a un orquestador.
El costo de esperar a que cierre la ventana
Hay un trade-off silencioso en "firmar de nuevo solo después de que cierre la ventana". La ventana es maxTimeoutSeconds, elegida por el vendedor. Un comprador que no debe volver a firmar hasta que pase quedará bloqueado ese tiempo en una compra ambigua. Un vendedor que anuncia 600 segundos le pide a los compradores esperar hasta diez minutos antes de reintentar con certeza. Los vendedores que quieran recuperación rápida deberían anunciar ventanas cortas. Los compradores deberían leer el campo como un presupuesto de reintento, no como un formalismo.
Ya escribimos sobre el lado vendedor de este hueco en la auditoría de la recovery extension y sobre qué significa "éxito" en cada cadena en la auditoría de finalidad de liquidación. Este post es la tercera pieza: qué hace el comprador cuando ninguna de esas respuestas llega.
Qué significa para LLM4Agents
La plataforma está en ambos extremos de x402. Los agentes llaman a nuestro gateway y pagan por request, y muchos de esos agentes usan exactamente esta librería cliente o una parecida. El problema de la doble firma es entonces un riesgo para los balances de nuestros usuarios antes que para los nuestros.
Del lado comprador, un agente autónomo es el peor caso para este bug. Reintenta sin humano, ejecuta tool calls en paralelo y no tiene la intuición de que "502 después de pagar" es distinto de "502 antes de pagar". La primera vez que importe será un bucle nocturno que paga dos veces por cada respuesta inestable.
Del lado vendedor, la exposición es reputacional y operativa. Un comprador que pagó dos veces por una chat completion abrirá una disputa con nosotros, no con su SDK. Nuestro análisis de spend controls cubrió el tope por pago, que se verifica en cada firma. Nada ahí cuenta cuántas firmas produce una compra. Este bug vive en ese hueco.
Cómo mantenerse en la frontera
- Probar nuestro propio camino de cliente con el harness de arriba. Correr los tres modos y el caso concurrente contra el wrapper fetch o axios que recomienden nuestros quickstarts documentados. Registrar las firmas enviadas por compra. Hacerlo antes de que llegue el reporte de otra persona.
- Documentar la retención en nuestras recetas de agentes ahora. Mientras #4094 o algo equivalente no salga en un release, mostrar un wrapper pequeño del lado del llamador: clave por método, URL y body, retener el payload durante la ventana, serializar llamadas superpuestas con una promesa en vuelo, y tratar 5xx, timeouts y 402 nuevos tras un intento pagado como "desconocido", no como "fallido".
- Hacer de "resultado desconocido" un estado de primera clase en los ejemplos de SDK. Devolverlo al agente como un resultado distinto, con el payload y la última respuesta adjuntos, para que el planificador consulte un balance o un recibo en lugar de reintentar.
- Darles a los compradores una señal de conciliación. Si nuestro gateway puede responder "¿se liquidó el nonce de esta autorización?" para una firma de pago dada, exponerlo. Funciona en cualquier cadena y evita que el comprador tenga que hacer una lectura RPC. Hacerlo como endpoint de solo lectura con la misma clave que el payment identifier.
- Servir el resultado original ante una firma repetida. Es la mitad receptora de #3437 y la idea de replay ledger de nuestro post anterior sobre recuperación. Convierte "pagado una vez, no entregado" en entregado.
- Revisar el
maxTimeoutSecondsque anunciamos. También es el presupuesto de reintento del comprador. Más corto es mejor para recuperar, siempre que la firma y la liquidación quepan dentro. - Seguir #4094, #4093, #3437 y #3438. El orden de merge y la respuesta a la pregunta "por diseño" decidirán si la retención vive en el SDK o en el código de cada integrador. Seguir aparte el seguimiento de axios.
Construye agentes que pagan una sola vez
Registra un agente en LLM4Agents y paga por request en stablecoins con un gateway compatible con OpenAI.
Registrar agente