← Blog
25 de octubre, 2026 · 12 min

x402 en Canton: el pago es una transacción que el facilitator tiene que leer

En EVM, un facilitator verifica un pago chequeando una firma sobre unos pocos campos. En Canton, el buyer firma una transacción de ledger completa y la envía, comprimida, dentro del header PAYMENT-SIGNATURE. Verificar eso significa leer un grafo de transacción. Leímos la spec mergeada, la versión más larga del PR de implementación (aún abierto) y su review, y probamos las partes que se rompen: el framing gzip y el presupuesto de header.

Canton Network es un ledger orientado a la privacidad construido sobre contratos Daml. Su token nativo es Canton Coin (CC), y CIP-56 define el token standard que siguen otros activos. El trabajo de x402 viene en dos piezas. El PR solo de spec #2634 se mergeó el 2026-08-07: un archivo, specs/schemes/exact/scheme_exact_canton.md, 360 líneas. La implementación de referencia, #3242, se abrió el 2026-08-24 como un paquete TypeScript nuevo, @x402/canton, con 15.326 líneas añadidas. Al 2026-10-25 sigue abierto. Un maintainer lo aprobó el 2026-10-23 y pidió un arreglo de lint; el autor reportó CI en verde el 2026-10-24.

Un contribuidor dice en el hilo del PR que su implementación corre en Canton MainNet desde principios de junio, con más de 265.000 pagos, más de 3.000 agentes pagadores y más de 10 merchants. Es una cifra autorreportada y no la verificamos. La leemos como evidencia de que el diseño funciona, no como una medición.

Qué firma el buyer

El scheme es exact en canton:mainnet, canton:testnet o canton:devnet. El monto va en unidades atómicas, 1 CC = 1e10, así que "1000000000" son 0,1 CC. payTo es un party id de Canton con la forma nombre::huella.

El buyer prepara un TransferFactory_Transfer del token standard CIP-56: sender, receiver, monto, los holdings de entrada exactos que gasta y un deadline executeBefore. Firma con ed25519 el hash de esa transacción preparada y no la envía al ledger. La submission firmada completa, con sus contratos divulgados embebidos, va en el payload:

"payload": {
  "assetTransferMethod": "transfer-factory",
  "preparedTransaction": "<base64(gzip(prepared tx))>",
  "preparedTxHash": "<hash hex que firmó el payer>",
  "signature": "<base64 ed25519 sobre el hash>",
  "hashingSchemeVersion": "HASHING_SCHEME_VERSION_V2"
}

Como la transacción es autocontenida, cualquier facilitator puede retransmitirla. El buyer no necesita relación con el nodo del facilitator. El facilitator envía la transacción, paga el fee de tráfico de red (nombrado en extra.feePayer) y no firma nada por el buyer. Los fondos salen de los holdings propios del buyer.

El merchant debe tener un TransferPreapproval vigente. Con él, la transferencia se resuelve de forma directa en una sola transacción. Sin él, queda como una instrucción pendiente de dos pasos que el merchant tendría que aceptar después, y el facilitator debe rechazarla antes de retransmitir.

Fees: dos tipos, uno en cero

Un reviewer preguntó temprano por los fees, y la respuesta aclara el modelo de dinero. Hay fees de tráfico, que paga el facilitator como relayer, y fees de transferencia de Amulet, que salen de los inputs del payer. La respuesta del autor apunta a CIP-0078 (estado Final, creado el 2025-09-08, aprobado el 2025-09-15). Fija en 0.0 los fees de transferencia, creación y lock-holder de Canton Coin y elimina los holding fees cuando una moneda se usa como input de transferencia. Así, en MainNet la suma de inputs es igual a la suma de outputs y el merchant recibe exactamente amount.

El costo real para el facilitator se queda: el tráfico. Por eso la spec del PR añade una sección de Merchant Policy. Un facilitator puede retransmitir solo para merchants de una allowlist, por ejemplo aquellos cuyo preapproval él provee, y rechazar a los demás con invalid_exact_canton_merchant_not_registered. Es un operador defendiendo su propio presupuesto. Pocos otros rails lo necesitan, porque en EVM el facilitator paga gas por cada settlement pero puede incluirlo en el precio.

La spec creció 113 líneas en el review

Comparamos la spec mergeada con la versión dentro de #3242 en el head 0f419ac: 360 líneas pasaron a 473. Los cambios no son cosméticos. Son el historial del review escrito de vuelta en el texto normativo.

Verify ahora prueba el factory. La Regla 3 mergeada decía que la transacción debía contener exactamente un TransferFactory_Transfer con firma válida. La versión del PR añade que el exercise debe apuntar al factory real, e indica cómo probarlo desde los input contracts de la transacción firmada: para Canton Coin, un ExternalPartyAmuletRules cuyo único signatory es el party DSO. Termina con una frase que explica el incidente: "Matching a template by name alone, or trusting a factory id read from a Scan or registry, is not sufficient."

La suficiencia de inputs dejó de leer Scan. La Regla 13 originalmente le decía a un facilitator que aloja al payer que chequeara que los inputs estén activos. El texto nuevo exige que cada input declarado sea un input contract de la transacción, propiedad del sender probado, y declara el límite sin rodeos: si un input sigue sin gastar solo lo ven sus stakeholders, así que verify no lo establece. El ledger lo hace cumplir de forma atómica al ejecutar. Un input gastado hace fallar la transferencia sin mover fondos. El riesgo residual en el flujo authorization es trabajo desperdiciado del handler, "never of unpaid delivery". El flujo upfront, que el PR ahora también soporta, lo elimina.

Settle ganó una tercera respuesta. El texto mergeado tenía éxito o fallo. El nuevo añade settlement_pending, explicado abajo.

Tres hallazgos de review que en realidad son hallazgos de diseño

El review de #3242 corrió del 2026-08-31 al 2026-10-23, sobre todo entre el maintainer erikreppel, la cuenta de reviewer automatizado PhilBot402 (sus comentarios llevan la divulgación "Automated by @phdargen") y el autor. Tres hallazgos se generalizan más allá de Canton.

Los nombres de template no son identidades

El review del 2026-09-29 encontró que el root exercise se comparaba solo por nombre de choice y que el código descartaba el package id al comparar templates. El escenario: un payer vettea un paquete parecido cuyo template tiene los mismos nombres de módulo y entidad y una choice llamada TransferFactory_Transfer, pero cuyo cuerpo hace otra cosa. Verify pasa, settle reporta éxito, el merchant no recibe nada. El reviewer notó que la explotabilidad depende del vetting de paquetes en el participant que retransmite, y que de todos modos el facilitator no estaba probando que corriera el factory real.

El arreglo, según la respuesta del autor del 2026-10-14, prueba el factory desde la propia transacción firmada, sin consulta a Scan y sin pin de package id. El factory de Canton Coin debe ser un contrato firmado solo por el DSO, que el payer no pudo haber creado, y los participants que confirman autentican cada input contract contra su id al ejecutar. Este es el patrón que vale copiar. Cuando el objeto firmado nombra contratos, revisa quién firmó esos contratos, no cómo se llaman.

Resultado desconocido no es fallo

Dos hallazgos separados, del 2026-08-31 y el 2026-09-29, eran el mismo bug en lugares distintos. Un settle cuyo request de execute expiró, o cuya completion no se pudo leer, se reportaba como invalid_exact_canton_execute_failed, que la spec marca como reintentable desde inputs nuevos. Si la transacción en realidad se había confirmado, el buyer vuelve a firmar con inputs nuevos y paga dos veces.

La spec actual dice que un facilitator "MUST NOT report failure" cuando el participant puede haber aceptado la transacción. Responde settlement_pending, un estado no terminal, con transaction igual al hash de la transacción preparada porque no existe un updateId antes del commit. En un reintento del mismo payload debe buscar la submission por completion o por el command id firmado, y solo un participant que aloja al payer puede convertir "no encontrado" en fallo, y solo cuando la transacción ya no puede confirmarse. Un facilitator que no ve al payer "never turns a pending settlement into a failure".

El 2026-10-14 phdargen también rechazó una decisión anterior de devolver éxito cuando el execute se confirmó pero la lectura de funds-moved fue inconclusa: settle solo debe devolver éxito cuando la transacción está confirmada, como en otras redes. El autor ahora devuelve éxito solo al commit. Esto coincide con lo que encontramos en Base en nuestra auditoría de settlement finality: la palabra "success" necesita un significado declarado por rail, y los resultados de tres valores son la forma honesta.

Una última ronda el 2026-10-21 encontró un hueco de replay en la propia reescritura: un pago ya liquidado podía responderse de nuevo desde un cache. El arreglo del autor cachea solo resultados pending, responde una vez a uno confirmado y lo descarta, y deja que un replay retransmita de nuevo para que el ledger lo rechace. El texto de la spec pasó de SHOULD NOT a MUST NOT: un facilitator "MUST NOT answer a settle from a remembered commit". Es el mismo punto de nuestra auditoría de la recovery extension. El replay gratis solo es seguro después del settlement propio de la operación, y un cache es una forma de equivocarse.

El input comprimido es superficie de ataque

La spec exige un decoder gzip endurecido: exactamente un member, sin bytes finales, sin members concatenados, sin campos de nombre, comentario o extra en el header, descompresión incremental, un tope comprimido de unos 8 KiB y uno descomprimido de unos 64 KiB. El hallazgo del reviewer del 2026-09-29 fue que la primera implementación tomaba los últimos 8 bytes como trailer y aplicaba raw inflate al resto. Raw inflate se detiene al final del stream deflate e ignora lo que sigue. Así, basura puesta entre el stream y el trailer seguía decodificando a los bytes originales y pasaba el chequeo de CRC y tamaño.

Reproducimos la familia de bugs con Node 22.22.2 y un payload de prueba de 27,6 KB:

member limpio                         decodifica
8 bytes cero al final (gunzip)        decodifica   (zlib trata los ceros como padding)
"JUNKJUNK" al final (gunzip)          Z_DATA_ERROR
segundo member válido añadido         decodifica   (concatenación aceptada)
basura entre fin de deflate y trailer
  decoder ingenuo basado en trailer   decodifica a los mismos bytes
  + exigir que inflate consuma el body  rechazado

De aquí salen dos cosas. Primero, una llamada estándar a gunzip no es el decoder estricto que pide la spec: aceptó un member concatenado y padding de ceros. Segundo, la variante forjada es un string base64 distinto que decodifica a bytes preparados idénticos con una firma válida. Un solo pago firmado tiene muchas codificaciones válidas en el cable. Todo lo que use como clave el header crudo, como un store de idempotencia, un cache por hash de payload o un ledger de recibos, las ve como pagos distintos. El arreglo del PR es el que elegiríamos: preguntarle a zlib cuántos bytes de entrada consumió (info: true) y exigir que sea igual al largo del body. Comprobamos que esto rechaza nuestra variante forjada.

El presupuesto de header

La spec dice que base64(gzip(preparedTransaction)) suele ser de 6 a 7 KiB, que el payload completo ya codificado comúnmente excede 8 KiB, y que los servers deberían aceptar al menos 16 KiB en PAYMENT-SIGNATURE. Un comentario en el codec reporta 22 a 27 KB crudos y 4,4 a 5,4 KB comprimidos, medidos en mainnet por los autores. No teníamos fixtures de mainnet, así que armamos el envelope nosotros con bytes sintéticos incompresibles y party ids realistas:

tamaño del member gzip       valor del header (base64 del JSON)
~6,1 KB (típico)             12.148 bytes
8.157 B (en el tope 8 KiB)   15.764 bytes   (límite en discusión: 16.384)

El número que importa es el segundo. Un payload en el tope comprimido recomendado cabe bajo 16 KiB con solo unos 600 bytes de margen, y eso antes de cualquier dato de extensiones. El payload pasa por base64 dos veces, una para los bytes gzip y otra para el envelope JSON, así que el overhead se compone. En nuestra auditoría de tamaño de header encontramos clientes y servers con límites de 8.190 bytes y de 16 KiB. Frente a ellos, el pago típico de Canton ya falla en el menor. El consejo de la propia spec es usar MCP, que no lleva el payload en un header HTTP, donde un deployment no pueda subir el límite.

Qué significa para LLM4Agents

No soportamos Canton ni tenemos plan de hacerlo. Nuestros rails son los caminos EIP-3009 y Solana en las cadenas donde liquidamos hoy. La relevancia está en lo que Canton muestra sobre el modelo de verificación.

Primero, es el ejemplo más fuerte hasta ahora de un scheme donde el objeto firmado lleva lógica. Una autorización EIP-3009 son unos pocos campos con un solo efecto posible. Una transacción preparada de Canton es un grafo de contratos, y verify tiene que probar que ese grafo hace una sola cosa. El review muestra cuántas formas hay de equivocarse al intentarlo desde nombres y consultas a Scan. Cualquier rail futuro de este tipo, incluidas las propuestas abiertas de Nano y BSV, cargará la misma tarea.

Segundo, settlement_pending es el modelo correcto y esperamos que se extienda. Nuestro gateway convierte un pago 402 en una llamada de modelo pagada. Un timeout entre "el facilitator aceptó" y "el facilitator confirmó" no debe volverse ni una completion gratis ni un doble cobro. Si un rail devuelve pending, necesitamos un estado de espera definido para el request, no una suposición.

Tercero, la codificación canónica. Si el mismo pago puede llegar en varias formas de bytes, cualquier deduplicación de nuestro lado tiene que usar como clave lo que se firmó, no el string del header.

Cómo mantenerse en la frontera

En orden:

Primero, no construir un rail de Canton ahora. Vigilar #3242 por el merge y por un release etiquetado de @x402/canton, y ver si aparece un segundo facilitator independiente. Un solo operador corriéndolo es un deployment, todavía no un ecosistema.

Segundo, adoptar el resultado de settle de tres valores en nuestra interfaz de facilitator: confirmed, pending, failed. Definir qué hace nuestro gateway con un pending: retener la respuesta, reintentar settle una vez con el mismo payload, y liberar o fallar según la respuesta. Nunca reintentar con una firma nueva por nuestra cuenta.

Tercero, usar como clave de toda deduplicación de pagos el objeto firmado ya verificado y decodificado (payer, nonce o conjunto de inputs, monto) y nunca los bytes crudos del header. Agregar un test que envíe dos codificaciones distintas en bytes de un mismo pago y espere un solo cobro.

Cuarto, llevar la lección del gzip estricto a nuestro propio manejo de headers. Aceptamos cuerpos comprimidos en otros lugares; confirmar que los decoders rechazan members concatenados y bytes finales, y que limitan la salida de forma incremental.

Quinto, mantener un chequeo de tamaño de header en el harness de buyer: construir el payload más grande que cada rail soportado puede producir y verificar que pasa nuestro edge y los defaults comunes de servers. La aritmética de Canton de arriba, 15.764 de 16.384 bytes en el tope, es la plantilla.

Sexto, registrar para cada rail qué significa "verificado": solo firma, firma más lectura de balance, o firma más prueba estructural del efecto. Canton es el primer rail donde se exige la tercera, y la tabla es como se lo explicamos a los buyers.

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