← Blog
21 de agosto, 2026 · 13 min

Auditoría de ACK: qué prueba de verdad el recibo de un agente

Todo protocolo de pagos para agentes termina enfrentando la misma pregunta: cuando el cliente vuelve y dice "ya pagué", ¿qué verifica el servidor? Auditamos el Agent Commerce Kit para ver qué ata de verdad su recibo.

El Agent Commerce Kit (ACK) es la mitad open source de Catena Labs, la empresa que fundó Sean Neville, cofundador de Circle, para construir un banco para agentes de IA. Catena levantó $18M liderados por a16z crypto y publicó ACK bajo MIT junto con el anuncio. No es una cadena, ni un facilitator, ni una wallet. Son dos patrones: ACK-ID para identidad de agentes y ACK-Pay para payment requests y recibos, ambos expresados como Verifiable Credentials del W3C sobre Decentralized Identifiers.

Ese enfoque es lo bastante inusual como para medirlo. Casi todo el stack de pagos que auditamos este año mueve valor y después te entrega un hash de transacción. ACK hace lo contrario: se mantiene agnóstico del rail y mueve la prueba a una firma. Esta auditoría trata sobre qué cubre esa firma.

El censo

Los números de abajo se midieron el 2026-08-21 sobre un clon nuevo y los paquetes publicados.

El repositorio se creó el 2025-05-19 y su primer commit es del mismo día. Suma 94 commits, 153 stars, 127 forks y 46 ítems abiertos. HEAD es 0b8fdaa, con el tag [email protected] del 2026-08-04. El workspace tiene ocho paquetes — ack-id, ack-pay, vc, did, jwt, keys, caip y un meta-paquete agentcommercekit — más cinco demos y tres ejemplos.

La actividad es irregular. Cuarenta y siete commits en los primeros cinco meses de 2025, dos en septiembre, dos en octubre, y después nada hasta febrero de 2026. La forma de 2026: 10 commits en febrero, 2 en marzo, 26 en junio, 5 en julio, 4 en agosto. Ese pico de junio es casi todo toolchain, tests y seguridad, no protocolo.

La especificación vive como documentación, no como RFC ni como registro de schemas: 6.081 palabras en docs/ack-pay, 3.283 en docs/ack-id, 2.869 en el overview. No hay un wire spec versionado aparte del SDK. La versión del paquete es la versión del protocolo.

La adopción es chica y no lo disimula. agentcommercekit registró 442 descargas de npm en los 30 días terminados el 2026-08-19; @agentcommercekit/ack-pay, 427 en la misma ventana. Veintiuna versiones publicadas desde el 2025-05-16.

ACK-Pay en una pantalla

El flujo server-initiated es el conocido. La documentación dice que el binding idiomático sobre HTTP es un 402 Payment Required con la Payment Request en el body, y es explícita en que el payload es agnóstico del transporte: puede viajar sobre mensajes A2A o WebSockets igual de bien. Esa es la primera divergencia con x402, que vive en headers y está diseñado con forma de HTTP.

El body lleva dos cosas: un objeto paymentRequest y un paymentRequestToken, un JWT firmado por el servidor sobre ese objeto. El objeto es una lista de paymentOptions, cada una una forma de pagar:

{
  "id": "usdc-base",
  "amount": 10000,
  "decimals": 6,
  "currency": "USDC",
  "network": "eip155:8453",
  "recipient": "eip155:8453:0x…",
  "paymentService": "https://payments.example.com/base",
  "receiptService": "https://receipts.example.com/base"
}

La intención de diseño es clara y, en un aspecto, mejor que las alternativas: un solo 402 puede ofrecer USDC en Base, un cargo con tarjeta vía Stripe y USDC en Solana lado a lado, y el cliente elige. x402 también tiene múltiples ofertas, pero todas son ofertas on-chain. ACK-Pay trata los rails fiat como ciudadanos de primera desde el schema.

El costo de esa generalidad se ve en los tipos. En packages/ack-pay/src/schemas/zod.ts, network es opcional y un string pelado. El ejemplo de la documentación pone "stripe" y "eip155:8453" en el mismo campo, sin ningún registro que diga qué vocabulario aplica. recipient también es string pelado, y la documentación admite que el "formato puede variar según la red" — el ejemplo usa un account id CAIP-10 para Base y una dirección cruda para Solana. La tabla de la documentación declara amount como entero; el schema acepta z.union([z.number().int().positive(), z.string()]). Hay un paquete caip en el mismo workspace que parsea CAIP-2 y CAIP-19, y ack-pay no lo importa.

Esas son las costuras donde un protocolo de pagos se vuelve un acuerdo por integración en vez de un contrato de wire. Marcamos la misma clase de hueco en la capa de extensiones de x402, donde un chainId estaba hardcodeado en una spec y sin tipar en otra.

El recibo es una atestación, no una prueba de settlement

Este es el claim completo del recibo, desde create-payment-receipt.ts:

const attestation: Record<string, unknown> = {
  paymentRequestToken,
  paymentOptionId
}

// metadata se agrega solo si el caller la pasa
return createCredential({
  type: "PaymentReceiptCredential",
  issuer, subject: payerDid, expirationDate, attestation
})

Dos campos. La request firmada que responde, y qué opción se usó. Sin monto. Sin red. Sin hash de transacción. Sin timestamp de settlement. Un Receipt Service firma una Verifiable Credential que dice "este pagador satisfizo esta request bajo la opción X", y todo el peso probatorio descansa en que el verificador acepte confiar en el DID de ese emisor.

La documentación es honesta sobre el resto. credentialSubject.metadata es "un punto de extensión para evidencia de pago específica del verificador" y sus campos son "no normativos". El ejemplo incluye settlementNetwork: "eip155:8453" y settlementReference: "0xabc123" — el hash de transacción existe, como clave opcional en un mapa libre que la librería nunca inspecciona.

Compara con lo que hace el resto del stack. El settle response de x402 lleva el hash de transacción y la red como campos de protocolo. La extensión offer-receipt firma ofertas y recibos con EIP-712 o JWS y especifica la forma. ERC-8004 tiene un agujero de proofOfPayment en su registration file que el ecosistema discute desde hace meses. ACK-Pay resuelve esa tensión declarándola fuera de alcance: la verificación es confianza en el emisor, el settlement es problema de otro.

Es una arquitectura defendible. Es la misma arquitectura que el mensaje de autorización de una red de tarjetas. Pero significa que un despliegue de ACK vale lo que vale su lista de Receipt Services, y esa lista es un array de configuración, no un protocolo.

Qué corrimos

Instalamos [email protected] desde npm y manejamos el loop completo con llaves Ed25519 nuevas e identificadores did:key: el servidor emite una request, el receipt service emite un recibo, el servidor lo verifica. Cuatro resultados valen la pena.

Una payment request vencida verifica. Armamos una request con expiresAt: "2025-01-01T00:00:00Z" — diecinueve meses en el pasado — y llamamos a verifyPaymentRequestToken(token, { resolver, verifyExpiry: true }). Devolvió la request parseada en 12 ms. La razón está a la vista en el código: createPaymentRequestToken vuelca la request dentro del payload del JWT y agrega solo iat y sub. Nunca deriva un claim exp del JWT a partir de expiresAt, y verifyExpiry solo gobierna el claim del JWT. El campo del schema es decorativo salvo que la aplicación lo lea. Es un hueco conocido: el PR #158, "fix(ack-pay): enforce payment request expiresAt", está abierto desde el 2026-08-14.

Un recibo por un pago que nunca ocurrió verifica en 22 ms, completamente offline. No tocamos ninguna cadena, ningún facilitator, ningún payment service. Generamos una llave de receipt service, firmamos un PaymentReceiptCredential y verifyPaymentReceipt lo aceptó. No es un bug: es el diseño, dicho corriéndolo. Lo único que prueba la verificación es que un DID que elegimos confiar firmó una afirmación.

El recibo no se ata a la request que se está sirviendo. Emitimos una segunda request, req-002, y presentamos contra ella el recibo de req-001. La verificación pasó y devolvió req-001. Es el comportamiento correcto — la función no tiene parámetro para "la request que estoy sirviendo ahora" — pero implica que la prevención de replay entre requests queda entera en la aplicación. Las opciones de verificación son exactamente cuatro: resolver, trustedReceiptIssuers, paymentRequestIssuer, verifyPaymentRequestTokenJwt. Ninguna es un id de request esperado.

Nada compara el emisor del recibo contra la opción que lo nombró. Nuestra payment option declaraba receiptService: "did:web:receipts.example.com". El recibo lo emitió un did:key sin relación. La verificación pasó, porque habíamos puesto ese did:key en trustedReceiptIssuers. El vínculo entre "el servicio por el que te dije que pagaras" y "el servicio que firmó tu prueba" nunca lo aplica la librería.

La lectura operativa — la verificación de ACK-Pay responde "¿es esta una credencial bien formada de un emisor en el que confío?". No responde "¿me pagaron por esta request?". Cuatro chequeos — id de request, monto, frescura y emisor-igual-a-la-opción — viven en tu handler o no existen.

Tres fixes de seguridad, todos en 2026

La capa de credenciales tuvo una falla seria y la arregló en abierto, lo que vale más que un historial impecable.

El commit 81c68bf, mergeado el 2026-06-20, se titula "bind credential verification to the verified proof (credential forgery)". El mensaje lo describe como crítico: verifyParsedCredential verificaba el JWT en proof.jwt pero después tomaba cada decisión de confianza — expiración, revocación, emisor confiable, verificación de claims — desde el objeto externo provisto por el caller, que no está atado a esa prueba. Un atacante podía tomar cualquier credencial legítimamente firmada, mutar issuer o credentialSubject en el objeto manteniendo la prueba válida, y pasar la verificación. El archivo de verificación data del primer commit del repositorio, el 2025-05-19, así que esa ruta de entrada por objeto arrastró ese comportamiento unos trece meses.

Reprodujimos el comportamiento post-fix en 0.11.0: mutar credentialSubject.paymentOptionId a stripe-usd y el issuer a did:web:attacker.example.com sobre una credencial parseada, y después verificar, devuelve la credencial decodificada desde la prueba. Los campos falsificados se ignoran. El fix funciona.

Otros dos llegaron el 2026-08-04 y salieron en 0.11.0. Uno ata el issuer de una credencial al DID que efectivamente la firmó (etiquetado CWE-290). El otro hace que la revocación falle cerrada (CWE-299): isRevoked trataba cada falla — error de DNS, timeout, 4xx, 5xx, body no-JSON — como "no revocada", así que cualquiera capaz de interrumpir la accesibilidad de una status list, o simplemente presentar una credencial mientras el endpoint de estado estaba caído, podía usar una credencial revocada indefinidamente. El changeset también anota que la status list obtenida se confiaba solo por su forma, sin verificar nunca su prueba.

Los tres juntos dan una imagen consistente: los patrones de identidad y pago son sólidos, y la plomería de verificación debajo hacía menos de lo que sus callers asumían. Si pinneaste cualquier versión por debajo de 0.11.0, estás corriendo la plomería vieja.

ACK-ID es la mitad más fuerte

El lado de identidad aguanta mejor la lectura. Una ControllerCredential afirma que un DID de agente está controlado por un DID humano u organizacional. El verificador no lo toma como acto de fe: verifyAgentControllerClaim resuelve el documento DID del agente, lee su controller y rechaza la credencial salvo que sea igual al controller declarado. El claim es bidireccional — la credencial dice "este es mi agente" y el documento DID tiene que estar de acuerdo.

Ancla eso en did:web y la raíz de confianza pasa a ser el control del dominio, exactamente el ancla que Web Bot Auth usa desde el otro lado. El repo cuenta 173 apariciones de did:web contra 102 de did:pkh y 35 de did:key en código y docs. Catena además registró su propio método, did:jwks, que convierte un endpoint JWKS de OAuth2/OIDC en un DID; aparece como registered entre los 268 archivos de métodos del registro W3C DID Extensions, apuntando a catena-labs/did-jwks.

También hay un módulo A2A funcional — createSignedA2AMessage firma el mensaje sin su metadata dentro de un JWT y devuelve la firma a metadata.sig, con un handshake que transporta una credencial. Y hay una demo skyfire-kya que convierte tokens KYA de Skyfire en credenciales W3C, que es el instinto correcto: un token KYA ya es un JWT con claims de identidad, así que la conversión es sobre todo un cambio de forma.

Señales de governance

La señal más fuerte no está en el código. ack-lab.com devuelve una sola página: "The ACK-Lab developer preview has ended". El repositorio catena-labs/ack-lab-sdk está archivado, con último push el 2025-09-22. El sitio de Catena hoy vende una plataforma de governance y banca para agentes — identidad verificable, políticas determinísticas, audit trails — no un protocolo.

El repo del protocolo, mientras tanto, tiene 35 pull requests abiertos de 22 autores distintos, 28 de ellos abiertos en agosto de 2026, contra un último merge del 2026-08-04. Los issues abiertos son 11. Varios de esos PRs son exactamente los fixes que escribiría un auditor — aplicar expiresAt, validar montos en string, rechazar campos JWK inválidos, emitir un Multikey real en publicKeyMultibase — y ninguno está mergeado. El contexto JSON-LD que lleva el ejemplo de recibo, agentcommercekit.org/contexts/payment/v1, devuelve 404; la documentación lo etiqueta como "Example ACK context", lo cual es honesto, pero significa que el recibo canónico de muestra no resuelve.

Otro dato: x402 aparece ocho veces en toda la documentación y cero veces en el código. El roadmap lista "soporte completo e incorporación del framework x402 de Coinbase" como investigación planificada. Dos protocolos que responden HTTP 402, sin un puente implementado entre ellos.

Qué significa para LLM4Agents

ACK-Pay no compite con el rail sobre el que liquidamos. Es un formato de recibo, y los formatos de recibo son donde nuestro gateway está más flojo.

Cuando un agente nos paga vía x402 y EIP-3009, el artefacto con el que se va es un hash de transacción y lo que devuelva nuestra API. Eso es criptográficamente fuerte y organizacionalmente inútil: un sistema contable no puede reconciliar un hash contra una descripción de servicio, y un auditor no puede saber desde un explorador de bloques qué llamada a modelo compró. Un PaymentReceiptCredential — firmado por nosotros, nombrando la request, con monto, modelo y referencia de settlement en metadata — es el artefacto que falta. Podemos emitirlo sin adoptar nada más de ACK.

La amenaza es más angosta de lo que parece y vale nombrarla igual. Si los compradores enterprise estandarizan en recibos con forma de VC y una lista de emisores confiables, entonces estar ausente de esa lista es un bloqueo de compras, por buena que sea la liquidación. Ser emisor nos cuesta una llave de firma y un documento did:web.

La cautela es lo que midió esta auditoría. No tratar un recibo ACK de terceros como prueba de pago por nada que entreguemos. Prueba que algún DID firmó una afirmación. Si alguna vez aceptamos recibos como evidencia de pago — para créditos, reembolsos o flujos de reventa — los chequeos que importan son los cuatro que la librería deja a la aplicación: coincidencia del id de request, coincidencia de monto y moneda contra la request que emitimos, frescura contra nuestro propio reloj, y emisor igual al receipt service que nombramos. Y el recibo no dice nada sobre nuestra propia entrega, que es la otra mitad del ledger.

Cómo mantenerse en la frontera

Concreto, en orden.

Uno. Emitir recibos. Publicar un documento did:web:llm4agents.com con una llave de firma Ed25519, y hacer que cada pago x402 liquidado emita un PaymentReceiptCredential como JWT, disponible desde la API de billing y opcionalmente en un header de respuesta. Poner el hash de transacción, la red como CAIP-2, el monto en unidades atómicas, el id de modelo y el id de request en metadata. Costo: una llave y una ruta.

Dos. Verificar a la defensiva, si es que verificamos. Cualquier ruta de código que acepte un recibo ACK entrante lleva un wrapper que exija que el id de request coincida con la request que emitimos, que monto y moneda coincidan, que el recibo sea más nuevo que nuestra ventana de expiración, y que el emisor sea igual al receiptService que nombramos. Pinnear @agentcommercekit/vc en 0.11.0 o superior, nunca por debajo. Tratar expiresAt como algo que nos toca aplicar a nosotros.

Tres. Adoptar el patrón de controller para identidad de agentes, no el de pagos. El chequeo bidireccional de ControllerCredential — la credencial declara un controller, el documento DID lo confirma — es el binding humano-a-agente más barato que vimos este año, y compone con el registro de ERC-8004 y con tokens estilo KYA en vez de reemplazarlos.

Cuatro. Publicar la costura que nadie construyó. Un facilitator de x402 que además emita un recibo ACK al liquidar son unas cien líneas: tomar el settle response, firmar una credencial, devolver las dos cosas. El roadmap de ACK la quiere desde 2025 y el código no existe. Construirla convierte a nuestro gateway en la implementación de referencia de un puente que dos ecosistemas siguen señalando.

Cinco. Mirar la cola de merges, no las stars. Treinta y cinco PRs abiertos contra un último merge del 2026-08-04 es el número que predice si ACK sigue siendo una spec viva o se vuelve un documento bien escrito del que una empresa siguió de largo. Volver a medir en noventa días antes de apostar algo estructural.

Paga por llamada, guarda el recibo

Un gateway compatible con OpenAI donde los agentes liquidan en stablecoins y cada llamada queda contabilizada.

Registra tu agente