← Blog
31 de octubre, 2026 · 14 min

x402 batch-settlement: quién guarda el voucher cuando lo guarda el facilitator

Un canal de pago tiene una pregunta que importa más que el esquema de firma: quién guarda el último voucher. x402 batch-settlement acaba de darle ese trabajo al facilitator. Leímos la spec integrada, ejecutamos el código del propio pull request y contamos lo que el contrato ha hecho en Ethereum.

Cubrimos el scheme en el deep dive original de batch-settlement. En esa versión el resource server guarda los vouchers y el facilitator solo verifica y envía transacciones. La spec v1.1 agrega un segundo modo donde los roles se intercambian.

Este post trata de lo que mueve ese intercambio. Se mueven tres cosas: quién consiente un refund, quién es dueño del total acumulado y quién puede demostrar después cuántos pagos cubrió un claim.

Qué salió y qué no

Las fechas importan, porque el lado TypeScript va detrás del lado Go.

También desempaquetamos @x402/evm 2.29.0, la última versión en npm cuando revisamos el 2026-10-31. Contiene el scheme batch-settlement. La cadena voucherManager no aparece en ninguna parte de su carpeta dist. Hoy el modo gestionado existe en la spec y en Go. Un seller en TypeScript no puede activarlo desde un paquete publicado.

Dos modos de custodia, un campo

La spec describe la diferencia como una tabla de quién guarda qué. En modo self-managed el resource server es el almacén autoritativo. Guarda el último voucher firmado por el cliente y el total cobrado por canal, que la spec llama watermark (chargedCumulativeAmount). También decide cuándo hacer claim y settle.

En modo facilitator-managed el server pasa a ser un pass-through. Llama a /verify y luego a /settle por cada payload, incluido el nuevo tipo voucher. El facilitator persiste el voucher, el watermark y un contador de cobros, y corre el calendario de claims.

El modo es un campo en el 402. Un facilitator lista lo que soporta en /supported. Omitir el campo significa ["server"]. El server elige un valor y copia receiverAuthorizer y withdrawDelay del facilitator. La spec prohíbe que el server sobrescriba withdrawDelay o elija un modo que el facilitator no anunció.

// accepts[0].extra en un 402 facilitator-managed (campos de la spec v1.1)
{
  "voucherManager": "facilitator",
  "receiverAuthorizer": "0x...anunciado por el facilitator",
  "withdrawDelay": 900,
  // solo si el server firma refunds con su propia llave:
  "refundAuthorizer": "0x...EOA del server"
}

Una consecuencia es fácil de pasar por alto. El cliente repite voucherManager y lo lee para una sola decisión: si empaquetar una dirección en el salt del canal. Eso nos lleva a lo primero que se mueve.

Quién puede decir sí a un refund

Un refund cooperativo devuelve el escrow de inmediato y se salta el retraso de retiro. Por eso necesita el consentimiento del lado receptor. En modo self-managed el consentimiento es la firma del propio server, o la autenticación del llamador por parte del facilitator cuando el server delegó receiverAuthorizer.

En modo gestionado el facilitator es siempre el receiver authorizer. Firma él mismo el refund onchain. Por tanto necesita una prueba aparte de que el server quiere ese refund. El 402 elige una de dos pruebas:

La spec agrega una regla para impedir la sustitución. Con un refund authorizer presente, el cliente debe fijar salt = bytes12(entropy) || bytes20(refundAuthorizer). La dirección pasa a formar parte de la config del canal y, por tanto, de channelId. Un llamador de /settle no puede cambiar el authorizer sin cambiar el canal.

El /verify gestionado tiene una guarda relacionada. Debe rechazar un 402 que omita refundAuthorizer cuando el facilitator no honra refunds por identidad del llamador, porque entonces no existe ninguna de las dos vías de consentimiento y el escrow solo podría salir por el retraso de retiro.

Qué le hace el empaquetado al salt

Copiamos packRefundAuthorizerSalt tal cual del PR y lo ejecutamos con viem. Las entradas fueron un salt por índice, el primer salt aleatorio de 32 bytes que vimos onchain y dos salts adversariales.

idx0   0x000000000000000000000000 aAAabbBb...CcccDDdd
idx1   0x000000000000000000000001 aAAabbBb...CcccDDdd
random 0x908d39dd614ff4c099ab2b4e aAAabbBb...CcccDDdd
mismos 12 bytes iniciales, cola distinta -> salt idéntico: true
índice 1 vs bytes32 que empieza 0x..000001 -> salt idéntico: true

La regla es: si los 12 bytes altos son cero, se toman los 96 bits bajos como entropía. Si no, se conservan los primeros 12 bytes. Un cliente que aporta un bytes32 aleatorio completo conserva 96 de sus 256 bits. Dos salts distintos que coinciden en los primeros 12 bytes abren el mismo canal.

Es por diseño y los comentarios del PR lo dicen ("full bytes32 / random-salt compat"). 96 bits sobran contra una colisión accidental. El punto práctico es más estrecho: un cliente que deriva salts de un contador en los bytes altos, o que reutiliza un prefijo entre canales, reutilizará un canal en silencio. En modo gestionado trata el salt como un índice de 96 bits y usa la forma por índice.

La rotación también tiene costo. La spec dice que cambiar refundAuthorizer cambia el salt y por tanto exige un canal nuevo. Rotar esa llave significa cerrar y reabrir todos los canales gestionados que cubre.

El watermark es el producto

En modo gestionado el facilitator guarda el número que decide lo que debe un cliente. La spec dice que /settle solo puede confirmar un cobro cuando chargedCumulativeAmount es igual a voucher.maxClaimableAmount - payload.accepted.amount. Un settle repetido tras una confirmación "no es idempotente": debe dejar el watermark sin cambios y devolver invalid_batch_settlement_evm_cumulative_amount_mismatch. Quien llama no debe reintentar POST /settle automáticamente.

Ejecutamos el commitVoucherCharge del PR contra un store simulado con watermark 100, un tope de voucher de 110 y un precio de 10:

primer commit   committed            charged 100 -> 110
reintento ciego watermark_mismatch   charged sigue en 110
sin watermark   cap_exceeded         (110 + 10 = 120 > 110)
tope excedido   cap_exceeded         (110 + 50 = 160 > 110)
charged final   110

El caso del reintento es el útil. Un seller que envuelve /settle en un helper genérico de reintento por timeout recibirá un error duro en el segundo intento en vez de un doble cobro. Esa es la dirección segura. También significa que un timeout es ambiguo: el primer intento pudo haberse confirmado. Para eso existe el 402 correctivo. Ante un mismatch el facilitator devuelve channelState y voucherState (el último voucher firmado), y el cliente verifica esa firma antes de adoptar el watermark.

La concurrencia se maneja con dos mecanismos. Los locks de admisión usan un pendingId que /verify devuelve y /settle repite. Y los settles gestionados de deposit y refund mantienen el lock del canal desde antes del envío onchain hasta que el resultado queda persistido, de modo que un voucher no pueda confirmarse contra un escrow que está por moverse. Un flag de cancelación libera la reserva sin cobrar cuando el handler falla.

La spec también es explícita sobre el límite de este diseño: pendingId debe correr sobre un canal autenticado con el facilitator, y un facilitator que expone /settle a llamadores no confiables no debe tratar pendingId como autorización.

El PR también marca un cambio incompatible para quien tenga storage propio. ChannelStorage pasa a ser solo get, list y update con compare-and-swap. El campo pendingRequest sale del registro del canal hacia un nuevo ChannelLockStorage. Los stores en memoria, archivo y Redis implementan ambos. Si construiste un adaptador de Postgres contra la interfaz anterior, deja de compilar cuando esto se integre.

Un rastro de auditoría en el calldata

Cuando el facilitator decide cuándo hacer claim, el seller pierde el conocimiento directo de cuántas requests pagadas cubre cada claim. La spec agrega una atestación para eso. Cada transacción de claim puede llevar, en un sufijo ERC-8021 Schema 2, el mapa {"m": {"x402ChargeCounts": [c0, c1, ...]}}. Una entrada por fila de claim, en orden de llamada. El conteo es el número de commits offchain pagados desde el último claim confirmado.

El contrato ignora el calldata sobrante, así que nada onchain hace cumplir esto. Las propias reglas de indexador de la spec dejan clara la debilidad:

El costo es de unos 22 bytes más uno a tres bytes por fila. El formato es el mismo portador que los builder codes de x402, así que un código de facilitator y los conteos pueden compartir un sufijo.

Esto es una ayuda de conciliación, no una prueba. Le dice al seller "el facilitator dice que 41 commits pagados entraron en este claim", y el seller puede contrastarlo con su propio log de requests. No prueba que el facilitator no haya descartado commits.

Qué muestra la cadena hoy

El contrato de referencia está en 0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003, determinista entre cadenas. Consultamos ocho cadenas EVM por RPC público (Ethereum, Base, Optimism, Arbitrum, BNB Chain, Avalanche, World Chain, Celo). Las ocho devolvieron los mismos 11.175 bytes de código runtime. El RPC de Polygon falló, así que no decimos nada sobre ella.

Contar la actividad es más difícil. Los RPC públicos rechazan eth_getLogs de archivo o lo limitan a pocos bloques, y la API de Blockscout de Base estaba detrás de un desafío de navegador desde nuestro host. Ethereum funcionó: Routescan devolvió 84 transacciones al contrato, igual que el contador de Blockscout.

Este es el historial completo, del 2026-05-22 al 2026-10-08:

deposit            11
claimWithSignature 32   (todos de una sola fila)
settle             32
initiateWithdraw    5
finalizeWithdraw    4
refund              0
fallidas            0     (isError = 0 en las 84)
sufijo ERC-8021     0     (ningún input termina en el marcador de 16 bytes 0x8021)
salt empaquetado    0     (ningún salt de depósito termina en una dirección de la config)

canales             9     (2 salts en cero, 7 bytes32 aleatorios)
depositado          10,0005 USDC + 0,0005 USDT
remitentes          5 direcciones; 3 enviaron todos los deposit, claim y settle

Es tráfico de prueba. Los depósitos son 500, 1.000.000 o 2.000.000 unidades base en tokens de 6 decimales, es decir entre cinco centésimas de centavo y dos dólares. Cinco direcciones enviaron las 84 transacciones. Dos cosas destacan para este tema.

Primero, las funciones del modo gestionado no tienen huella onchain todavía: ni salts empaquetados ni sufijo de atestación. Encaja con las fechas. La implementación en Go se integró cuatro días antes de este censo.

Segundo, la salida unilateral se ha usado y la cooperativa no. Se iniciaron cinco retiros temporizados y cuatro se finalizaron. No existe ninguna transacción de refund. Al menos en Ethereum, la vía cooperativa sigue sin probarse con flujos reales. Como el refund cooperativo es justo lo que el modo gestionado rehace, conviene saberlo antes de depender de él.

No contamos Base, donde vive la mayor parte del volumen de x402. Toma estas cifras como solo Ethereum.

Dónde la custodia mueve el riesgo

La spec acota el daño que puede hacer un facilitator. La exposición del pagador está limitada por el maxClaimableAmount firmado, y el receiver authorizer elige el claim real dentro de ese límite. El facilitator no puede tomar más de lo que el pagador firmó. Lo que controla es el momento, la completitud y el libro.

Estas son lecturas nuestras de la spec, no afirmaciones suyas:

Nada de esto es un defecto. Es el precio de no operar tú mismo un scheduler de claims y un store durable. La pregunta para un seller es cuál de los dos costos sale más barato para su tráfico.

Qué significa para LLM4Agents

Un gateway de LLM es casi el cliente ideal de un canal: muchas llamadas pequeñas del mismo agente, cada una con precio de una fracción de centavo, con un total que se desconoce hasta que termina el stream. La liquidación onchain por request no encaja en esa forma. Nuestra auditoría de spend controls mostró que el tope del lado cliente es el único límite duro que traen la mayoría de los SDKs. Un canal agrega un segundo tope estructural: el depósito.

La custodia gestionada cambia lo que tendríamos que construir. No necesitaríamos un scheduler de claims propio ni un store durable de vouchers con semántica compare-and-swap. Necesitaríamos confiarle el libro a un facilitator y aceptar que su caída es nuestro hueco de ingresos. La auditoría de la brecha de dos fases ya mostró lo caro que es una ventana entre la entrega y el pago. En un canal, esa ventana es el watermark sin reclamar.

El comportamiento del watermark es el correcto para un producto medido. Un settle duplicado falla con claridad. Y el contador de cobros nos da un número para conciliar con nuestro propio log de uso, lo que importa cuando el uso se factura en tokens que llegan después de la cotización.

El lado de la amenaza es menor de lo que parece hoy. Nada en producción usa el salt gestionado ni el sufijo, y el paquete TypeScript que usarían la mayoría de los sellers no lo incluye.

Cómo mantenerse en la frontera

En orden, y dimensionado a lo que existe hoy.

  1. Fijar los hechos. Seguir el PR #4061 y el próximo release de @x402/evm. Repetir la búsqueda de voucherManager en dist antes de asumir que un seller en TypeScript puede usar el modo gestionado.
  2. Construir primero la conciliación. Antes de elegir un modo, registrar cada request pagada con su canal y su total acumulado. Ambos modos lo necesitan, y la atestación de conteos solo sirve si hay con qué compararla. La auditoría de finality cubre cuándo es seguro contar un settlement.
  3. Correr un facilitator Go y un seller Go en testnet, en ambos modos, y matar el facilitator a mitad de un stream. Medir qué le da el 402 correctivo al cliente y cómo queda el libro del seller después.
  4. Decidir la prueba de refund con deliberación. Un refundAuthorizer propio cuesta una llave y un plan de rotación. delegatedRefund cuesta confianza en la autenticación del llamador de un proveedor. Dejar la decisión por escrito.
  5. Usar salts por índice. Si algún código cliente genera salts, pasar a índices pequeños y tratarlos como valores de 96 bits en modo gestionado.
  6. Agregar una regla de indexador. Si alguna vez leemos x402ChargeCounts, aceptarlo solo de remitentes de facilitator nombrados y unir por (channelId, newTotalClaimed).
  7. Repetir el censo en Base con un nodo de archivo. Nuestras cifras de Ethereum muestran tráfico de prueba. Donde está el volumen, la proporción de modo gestionado dirá si el modo es real.

Paga por llamada, sin operar el stack de settlement

LLM4Agents le da a los agentes autónomos un gateway compatible con OpenAI que cobra en stablecoins sobre x402.

Registra tu agente