Dos pagos, un recibo: auditoría de la capa de recibos de x402
Hicimos dos pagos x402 distintos a la misma ruta en el mismo segundo. Cada pago tuvo su propio nonce y su propio settlement. El vendedor firmó un recibo para cada uno, y los dos recibos salieron idénticos, byte por byte, firma incluida. Cinco pull requests abiertos en el repositorio de x402 intentan corregir lo que prueba un recibo.
x402 liquida bien el dinero. El facilitator devuelve un hash de transacción y cualquiera puede consultarlo. Lo que pasa alrededor de esa transferencia está menos claro. ¿Qué condiciones ofreció el vendedor? ¿A qué pago se refiere un recibo? ¿Qué bytes volvieron? ¿Y puede un tercero comprobar algo de eso un mes después, sin confiar en los logs del vendedor?
Esta semana esas preguntas aparecieron todas juntas. Entre el 12 y el 17 de octubre, cinco pull requests sobre recibos tuvieron actividad en el repositorio de x402, junto con un reporte de bug en el SDK. Este post los lee contra la extensión que ya está mergeada y mide lo que esa extensión entrega hoy.
Qué leímos y qué corrimos, todo el 2026-10-17. El repositorio de x402 en el commit faa1c69: la extensión offer-receipt mergeada (spec v0.6), el issue #3824 y su fix #3826, y los PRs abiertos #3827, #3830, #3783, #3604 y #2666. Los paquetes @x402/* en 2.28.0, la versión actual en npm. Corrimos cinco pruebas:
- Un harness local con un vendedor y un comprador reales en 2.28.0 y un facilitator simulado.
- Un recálculo del ejemplo de commitment del recibo v2, más su vínculo en vivo en Base Sepolia.
- Un censo de una hora de settlements EIP-3009 sobre USDC en Base.
- Una auditoría offline de las 1.199 ofertas firmadas que guarda el catálogo del Bazaar, y una comprobación de si cada firmante está autorizado.
- Una verificación independiente de los vectores de prueba de
delivery-receipty del directorio de claves del único emisor en vivo.
La versión corta. Además de su número de versión, el recibo mergeado firma cinco campos: la red, la URL del recurso, el pagador, la hora de emisión y un hash de transacción opcional. No firma el monto, el beneficiario, el request ni la respuesta. El flag que agrega el hash de transacción no funciona en el SDK actual de TypeScript. Sin el hash, dos pagos en el mismo segundo reciben el mismo recibo. En Base, el 22% de los settlements EIP-3009 de una hora tuvo un gemelo en el mismo bloque, con el mismo pagador, beneficiario y monto. Las ofertas firmadas son raras: 32 de 2.452 hosts del Bazaar. Y 21 de los 22 firmantes EIP-712 no publican ninguno de los dos registros que la spec sugiere para probar que una clave habla por un dominio. Las propuestas lo corrigen por capas: un vínculo salado con el settlement, un recibo que hashea los bytes de la respuesta, una declaración de entrega firmada por el comprador y un join con un formato de recibo externo.
Qué firma la extensión mergeada
La extensión Offer and Receipt define dos artefactos. La cubrimos por primera vez en la auditoría de la capa de extensiones. Ambos artefactos se firman con EIP-712 o JWS. El dominio EIP-712 es fijo: nombre "x402 offer" o "x402 receipt", versión "1", chainId 1. La spec usa EIP-712 "purely as an off-chain signing format".
Una oferta firmada se compromete con una entrada de accepts[]. Cubre resourceUrl, scheme, network, asset, payTo, amount y un validUntil opcional. Viaja en el 402, en extensions["offer-receipt"].info.offers[].
El recibo viaja en la respuesta de settlement. Su payload tiene seis campos: version, network, resourceUrl, payer, issuedAt y un transaction opcional. La spec dice que un recibo confirma "that payment was received and service was delivered". Nada en el payload nombra un monto, un beneficiario, la oferta que liquida, el request ni la respuesta. Por defecto la transacción queda vacía. La spec llama a esto "privacy-minimal" y dice que reduce el riesgo de correlación.
Solo un SDK de referencia implementa la extensión. TypeScript tiene @x402/extensions/offer-receipt. Los directorios de extensiones de Python y Go en faa1c69 no tienen módulo de offer-receipt.
Dos pagos, un recibo
Armamos el montaje a partir del propio ejemplo de offer-receipt del repositorio. Es un vendedor en Express que usa paymentMiddleware, con un emisor EIP-712 que firma con una clave viem dedicada. El comprador usa x402Client con una cuenta EVM nueva y firma autorizaciones EIP-3009 reales. El facilitator es un mock local. Aprueba cada verify y responde cada settle con un hash de transacción aleatorio nuevo. No hay cadena de por medio, así que la corrida muestra lo que hace el SDK y nada más.
La primera ruta declaró declareOfferReceiptExtension({ includeTxHash: true }). El comprador pagó, el settlement devolvió 0x2cb2bd82… y el recibo volvió con "transaction": "". La oferta y el recibo se verificaron, y ambos recuperaron la clave del emisor. El header PAYMENT-RESPONSE midió 1.500 caracteres.
Es el issue #3824, abierto el 15 de octubre. Su autor corrigió el diagnóstico ese mismo día. La causa es aliasing. En createPaymentRequiredResponse, la línea 915 de x402ResourceServer.ts hace response.extensions = extensions. Es el mismo objeto que la capa HTTP le pasa después a settle. El hook de offer-receipt escribe entonces sus ofertas firmadas en la respuesta. La declaración de la ruta ya no existe cuando el hook del recibo lee includeTxHash. Parcheamos esa única línea en el build publicado de 2.28.0 para que copie el objeto. En la siguiente corrida, el recibo llevó el hash del settlement. El fix, el PR #3826, seguía abierto cuando lo revisamos.
La segunda ruta usó el valor por defecto, includeTxHash: false. El comprador la pagó dos veces seguidas, y ambos recibos cayeron en el mismo segundo de issuedAt. Los dos pagos usaron nonces EIP-3009 distintos, y el facilitator devolvió dos hashes distintos. Los recibos fueron idénticos:
// recibo #1 y recibo #2, tal como llegaron (mismo pagador, misma ruta, mismo segundo)
{ "format": "eip712",
"payload": { "version": 1, "network": "eip155:84532",
"resourceUrl": "http://127.0.0.1:4021/no-tx",
"payer": "0x031e…Cb8d", "issuedAt": 1792228045, "transaction": "" },
"signature": "0x05ee97f1…b126f61b" }
payloads identical: true
signatures identical: true // ECDSA determinista sobre bytes idénticos
Un recibo así es una afirmación sobre un pagador, una URL y un segundo. No es una afirmación sobre un pago. Un comprador con dos de ellos no puede demostrar que pagó dos veces. Quien cuente recibos contará uno. Los sistemas de reputación necesitan justamente esta primitiva. Cuando auditamos ERC-8004, un ancla de prueba de pago era exactamente lo que le faltaba a su capa de feedback.
¿Qué tan seguido se parecen los pagos?
Un recibo no necesita ser idéntico para ser ambiguo. Sin hash de transacción, un verificador solo puede emparejar un recibo con un pago on-chain a través del pagador. La oferta agrega el beneficiario y el monto, y issuedAt agrega una hora. Así que medimos con qué frecuencia esos campos no alcanzan para señalar un único pago.
Leímos una hora de Base mainnet: bloques 52.717.590 a 52.719.389, de 08:08:47 a 09:08:45 UTC del 17 de octubre. Recolectamos cada log AuthorizationUsed sobre USDC y unimos cada uno con el Transfer que le sigue. Ese es el riel que usa el esquema exact EVM de x402 para USDC. No toda transferencia EIP-3009 es un pago x402, así que estas cifras son a nivel del riel.
La hora tuvo 4.176 settlements, de 1.204 pagadores hacia 232 beneficiarios. En 2.921 de ellos (69,9%), el mismo pagador, beneficiario y monto apareció al menos una vez más dentro de la hora. En 935 (22,4%), el gemelo estaba en el mismo bloque de dos segundos. Esos 935 vinieron de 16 pagadores, en 263 grupos de hasta 11 pagos idénticos en un bloque. El pagador más activo liquidó 542 veces en la hora. La mediana liquidó una vez.
Un pagador que liquida cientos de veces por hora a un mismo precio es un cliente automatizado, justo el caso para el que se diseñó x402. Para esos pagos, el recibo más la oferta no alcanzan para señalar un settlement. Solo lo logra el hash de transacción, o el nonce de la autorización.
Recibo v2: un commitment salado al nonce
El PR #3827, abierto el 15 de octubre por babyblueviper1 con aportes de holistis, agrega la versión 2 del payload del recibo. Tiene dos campos firmados nuevos, authorizationMethod y authorizationCommitment. El commitment cubre el nonce EIP-3009. El vendedor tiene ese nonce antes de que corra el settlement, así que, a diferencia de transaction, no depende de que el resultado del settle llegue al firmante:
authorizationCommitment = "0x" || hex( SHA-256(
"x402-receipt-authorization/0" || 0x00 || method || 0x00
|| asset(20) || authorizer(20) || nonce(32) || salt(32) ) )
Recalculamos el ejemplo del PR solo a partir del texto. El preimage de 141 bytes dio 0x5d905bbf…6af23b, el valor que imprime la spec.
Toda la privacidad descansa en el salt, y el PR vuelve normativas sus reglas. Deben ser 32 bytes aleatorios por pago. Nunca puede derivarse del nonce, del pagador ni de ningún otro campo público. Nunca va dentro del recibo. El servidor lo devuelve al lado del recibo, en extensions["offer-receipt"].info.authorizationSalt. Reenviar un recibo, entonces, no reenvía el vínculo.
La razón es el log. AuthorizationUsed indexa al autorizador, y el recibo nombra al pagador. Una sola consulta lista cada nonce que ese pagador consumió. Un hash del nonce sin salt caería frente a esa lista. Para el pagador más activo de nuestra hora, eso son 542 hashes.
Volvimos a correr el verificador en vivo del autor del PR contra Base Sepolia. El 15 de octubre, el pagador de prueba tenía 12 nonces consumidos. Hoy Blockscout devolvió 15. Exactamente uno abrió el commitment, hacia el settlement 0x1345479a…, y authorizationState devolvió true.
El PR es cuidadoso con lo que esto prueba. Reclama "no new pinpointing", no unlinkability. Hasta que se abre, un commitment es solo una afirmación del firmante. Un vendedor podría firmar dos recibos con salts distintos para un mismo settlement. Por eso, quien cuente settlements "MUST count opened commitments only". El PR también deja una pregunta abierta: ¿la versión debe seleccionarse por el version del payload, como hace ahora, o subiendo la versión del dominio EIP-712?
El PR #3830 lleva la misma idea a Solana como svm-memo. Se compromete con el mint (32 bytes), el pagador (32 bytes) y el SHA-256 del memo. La unicidad viene del nonce aleatorio del memo en el esquema exact SVM. Un vendedor que fija extra.memo en un valor constante la rompe.
Cuánto valen las firmas: 32 hosts en el Bazaar
Volvimos al snapshot del Bazaar de CDP de nuestro censo del 16 de octubre. Es una lectura completa del catálogo: 43.338 recursos en 2.452 hosts. De ellos, 668 recursos (1,5%) en 32 hosts llevan una entrada offer-receipt. Esos recursos recibieron 4.773 de las 906.040 llamadas de los últimos 30 días, el 0,53%.
El catálogo guarda las ofertas firmadas que vio: 1.199, de las cuales 796 en EIP-712 y 403 en JWS. Las 796 firmas EIP-712 verifican. Ninguna se firmó con la clave de payTo. La spec permite firmar con payTo como el enfoque más simple, pero dice que en producción se "SHOULD use a dedicated signing key". Los 22 hosts EIP-712 usan 18 claves distintas. Nueve de esos hosts son subdominios de un mismo dominio, forgemesh.io.
Una firma válida es la mitad del chequeo. La sección 4.5.1 dice que los verificadores "MUST confirm that the signing key is authorized". De lo contrario, "an attacker can generate a valid key pair, sign an offer or receipt for any resourceUrl", y la firma verificará. La spec enumera las opciones: firmar con la clave de payTo, listar la clave en un documento did:web, publicar un registro DNS TXT _controllers o usar un registro externo.
Los artefactos EIP-712 no llevan kid, así que un verificador tiene que adivinar dónde mirar. Miramos donde apunta la spec. Para cada host y su dominio padre, consultamos registros TXT _controllers y pedimos /.well-known/did.json.
_controllers. De los 22 hosts EIP-712, uno lista su dirección de firma en un did.json. Para los otros 21, un tercero puede verificar la firma pero no la autoridad detrás de ella. Los 10 hosts JWS publican un documento did:web que resuelve su kid.
Encontramos dos problemas menores. Primero, las 1.199 ofertas guardadas ya habían pasado su validUntil antes de tomar el snapshot. Una copia de catálogo de una oferta es evidencia de condiciones pasadas, no una cotización viva. Segundo, 181 ofertas EIP-712 firman accepts en Solana (115), Algorand (33) y Stellar (33). La sección 3.2 fija chainId en 1 para que EIP-712 funcione "including non-EVM networks like Solana". La sección 3.1.1 todavía dice que la red de un artefacto EIP-712 "MUST be eip155:<chainId>". El SDK sigue a la 3.2.
El catálogo también muestra a vendedores inventando sus propias claves de recibo: otto-content-receipt en 63 recursos, tracefast-receipt en 20 y delivery en 18. Un recurso declara delivery-receipt.
delivery-receipt: atar los bytes
El PR #3783, abierto el 12 de octubre por johnInarti tras la discusión del issue #3758, es el más completo de los cinco. Agrega 2.278 líneas de spec y vectores de prueba. Su recibo ata lo que offer-receipt deja afuera. Eso incluye el localizador del settlement (network, transaction y el logIndex de la transferencia), las condiciones pagadas (scheme, asset, payTo, amount, payer), el request (resourceUrl, method), y el status y el content type de la respuesta. También ata el contenido mismo, la época y el digest del directorio de claves, y la hora de emisión.
El contenido se compromete de dos maneras. En modo body, el recibo lleva el SHA-256 del cuerpo exacto de la respuesta. En modo stream, encadena un digest sobre los frames de Server-Sent Events. La cadena arranca en d_0 = SHA-256("x402-delivery-stream/1"), y cada frame agrega su largo y sus datos. El recibo llega en un evento terminal llamado x402-delivery-receipt. Ese framing le calza a un completion de LLM en streaming.
El mensaje firmado mide siempre 88 octetos: "x402-delivery-receipt/1", un salto de línea y el SHA-256 en hex del payload canónico según RFC 8785. Cada recibo lleva dos firmas. Ed25519 sirve a la ruta dentro de la sesión. ML-DSA-65 (FIPS 204) sirve a la ruta de archivo, para que el recibo siga sirviendo como evidencia "without depending on a signature scheme that a cryptographically relevant quantum computer would break".
El tamaño define el transporte. Una firma ML-DSA-65 pesa 3.309 bytes. Un recibo inline lleva el header a unos 8 KB, así que por defecto el header lleva solo una referencia de 1.024 octetos como máximo. El recibo se sirve en una URL inmutable direccionada por su id. Las claves viven en un directorio firmado y encadenado por épocas en /.well-known/x402-receipt-keys.
La verificación tiene cinco niveles: integrity, authentic, trusted, settlement y delivery. Un recibo es válido para el cliente solo si pasan los cinco. Un recibo ausente o fallido "cannot undo a payment", y el cliente "MUST NOT retry the payment automatically". Esa última regla importa para los agentes. Un comprador que trate un recibo ausente como una llamada fallida va a pagar dos veces.
Verificamos los vectores con código propio. Canonicalizamos cada payload con una librería RFC 8785, recalculamos el id del recibo y reconstruimos el mensaje de 88 octetos. Ed25519 verificó con el OpenSSL de Node. ML-DSA-65 y ML-DSA-87 verificaron con @noble/post-quantum, la misma familia de librerías que usa la referencia, así que ese chequeo no es del todo independiente. Cambiar un bit del mensaje hizo fallar las tres. El vector de digest de stream coincidió paso a paso.
El único recurso del Bazaar que declara la extensión pertenece a FractalAI, en fractalai.net.co. Su directorio en vivo está en la época 1, publicada el 10 de octubre. Recalculamos la raíz, y las dos firmas de gobernanza verificaron. Pero la URL estable también sirve tres miembros sin firma, chain, supersedes y annotation. El último aclara que ninguno de los tres está cubierto por la firma. La sección 7.3 dice que un verificador debe comprobar que el documento tenga "exactly the members of §7.2". Un cliente estricto que pida la URL estable lo rechazaría. La copia inmutable en /epoch/1 tiene exactamente los miembros especificados y pasa.
Este recibo prueba con qué bytes se comprometió el emisor. No prueba qué modelo los produjo. Esa pregunta necesita la capa de atestación de nuestro post Pagaste por Opus. ¿Qué prueba que respondió Opus?
El lado del comprador, y el join
El PR #3604, Buyer Delivery Claim, invierte al firmante. El pagador firma delivered: yes | no | partial, un hash de evidencia y un settlementRef, con personal_sign EIP-191 sobre un id de claim que es un hash del contenido. La implementación de referencia publica los claims como atestaciones EAS en Base, Optimism y Ethereum mainnet, y puede reflejarlos en giveFeedback() de ERC-8004.
El PR declara sus puntos débiles sin vueltas. Un claim falso no cuesta nada, y el autor reporta haber probado y descartado nueve mitigaciones. settlementRef no está atado al resto del claim. Y assetType admite uno de cuatro valores: gpu-hours, storage, api-credits o bandwidth. Ninguno es inferencia.
El PR #2666, Settlement-Receipt Binding, está abierto desde el 19 de junio. Une un registro de settlement con un formato de recibo externo, vaara.receipt/v1. Ese formato es ahora un draft individual del IETF, draft-sirkkavaara-vaara-receipt, en la revisión 14 al 16 de octubre. La clave del join es un SHA-256 sobre seis campos de la acción, dejando afuera a propósito el monto y la cadena. El lado del settlement ata el cambio neto de balance hacia payTo, no un parseo de la transacción. Un reparto de comisión lleva un registro por destinatario. Su ejemplo, que el PR describe como un settlement real en Polygon, reparte 2 JPYC en 1 para un comercio y 1 para un receptor de comisión.
Alrededor hay tres hilos más. El issue #3379 sobre "proof-of-done" tiene 57 comentarios. El PR #3377 propone "durable evidence" cifrada del contenido entregado, y el PR #3291 propone un "resolution lineage" para disputas. Nuestra auditoría de los recibos de ACK le hizo la misma pregunta a otro stack: un recibo prueba lo que su firmante eligió atar, y nada más.
Quién firma qué
offer-receipt v1 (mergeado): el vendedor firma pagador, URL y hora. El monto solo aparece en la oferta. El settlement queda atado solo si transaction está presente, y el SDK hoy lo descarta. Sin contenido.
offer-receipt v2 (#3827, #3830): agrega un commitment salado al nonce EIP-3009 o al memo SVM, que solo abre quien tiene el salt. Sin contenido.
delivery-receipt (#3783): el vendedor firma condiciones, localizador del settlement, request y digest de la respuesta, con firma doble Ed25519 y ML-DSA-65 y un directorio de claves encadenado por épocas.
Buyer Delivery Claim (#3604): el pagador firma el resultado más un hash de evidencia. La referencia al settlement no está atada y mentir es gratis.
Settlement-Receipt Binding (#2666): un join direccionado por contenido entre un registro de settlement verificado por el facilitator y un formato de recibo externo.
Qué significa para LLM4Agents
Hoy nuestro gateway está en el escalón más bajo. Un request sin pago a /v1/chat/completions devuelve un 402 sin bloque extensions, y por lo tanto sin oferta firmada. Una respuesta pagada lleva X-Model-Used, X-Tokens-Input, X-Tokens-Output y X-Cost-Usd-Cents. Esos headers son exactos. Ninguno está firmado. Un agente comprador puede leerlos, pero no puede llevárselos a un tercero.
Para un gateway de inferencia, el recibo útil no es el que está mergeado. Offer-receipt v1 no ata ninguno de los campos con los que se cobra una llamada a un LLM. Un recibo útil ataría el modelo pedido y el modelo que respondió, el conteo de tokens, la base del precio, el settlement y un digest del completion. delivery-receipt cubre el settlement y los bytes, y su modo stream se escribió para SSE. No tiene campos para modelo ni para uso. El Buyer Delivery Claim no tiene un asset type para inferencia. El hueco propio de las llamadas a LLM sigue abierto.
También hay una amenaza. Las capas de reputación, como el feedback de ERC-8004, los claims en EAS y los puntajes de calidad del Bazaar, premian a los vendedores cuya evidencia se puede comprobar. Los headers de facturación sin firma van a pesar menos con el tiempo. Y adoptar offer-receipt v1 tal como sale hoy les daría a los compradores recibos que no se pueden contar.
Cómo mantenerse en la frontera
1. Firmar ofertas ya, y publicar quién las firma. Agregar ofertas offer-receipt a nuestro 402 con una clave EIP-712 dedicada, nunca la de payTo. Listar su dirección en /.well-known/did.json de api.llm4agents.com y en un registro TXT _controllers con DNSSEC. Las cotizaciones firmadas son baratas y convierten nuestro precio en evidencia. Publicar la clave es justo lo que omitieron 21 de 22 firmantes EIP-712.
2. No emitir recibos con la transacción vacía. Si emitimos recibos offer-receipt, activar includeTxHash y fijar una versión del SDK que incluya el fix #3826. Antes de confiar en esa versión, correr un test de regresión como nuestro harness. Cuando se mergee v2, generar un salt nuevo de 32 bytes por pago y devolverlo fuera del recibo.
3. Prototipar delivery-receipt para completions. Empezar por el modo body en llamadas sin streaming, con transporte por referencia y un directorio de claves en /.well-known/x402-receipt-keys. Después agregar el modo stream sobre nuestros frames SSE. Mantener la URL estable del directorio con exactamente los miembros especificados.
4. Proponer un perfil de inferencia upstream. Llevar los campos de LLM a #3758 y #3783: modelo pedido, modelo que respondió, tokens de entrada, de salida y de razonamiento, y la base del precio. Pedirle a #3604 un asset type inference. El gateway que publique primero estos campos es el que los define.
5. Darles un verificador a nuestros compradores. Nuestras herramientas MCP y snippets de cliente deberían verificar las ofertas y los recibos de otros vendedores, incluido el paso de autorización del firmante, y guardar los recibos junto con los salts. Deberían tratar un recibo ausente como evidencia, nunca como motivo para volver a pagar.
6. Repetir el censo cada mes. Seguir cuántos hosts del Bazaar firman ofertas, cuántos publican su clave y cuántos emiten delivery-receipt. Seguir también la tasa de gemelos en el mismo bloque en Base. Cuando estas cifras se muevan, los compradores van a empezar a esperar recibos.
La capa de recibos va a converger en algo. Hoy son cinco propuestas, una spec mergeada que firma un pagador y un timestamp, y un bug que esconde el único campo que distingue un pago de otro. Igual que en nuestra auditoría de finalidad, la palabra en la respuesta promete más que los bytes que la respaldan.
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