← Blog
19 de octubre, 2026 · 17 min

x402 en Sui: verificación por efectos y el éxito que se repite

Una reescritura propuesta del binding x402 para Sui deja de revisar cómo está armado un pago y revisa solo qué hizo. El diseño es limpio. Su punto débil es que, en Sui, un pago que ya entró sigue reportando éxito si lo vuelves a enviar.

En agosto, en nuestro censo de los quince bindings de red de x402, recomendamos seguir de cerca la feature "Address Balances" de Sui. El propio binding de Sui decía que podía eliminar la gas station interactiva y quizá habilitar pagos al estilo EIP-3009. Citamos el apéndice, que la llamaba "in-development".

Para entonces ya no estaba en desarrollo. Address Balances llevaba más de diez semanas activo en Sui mainnet. Ahora hay en revisión un binding de reemplazo construido sobre ella, con una implementación completa en TypeScript detrás.

Leímos ambos, corrimos el facilitator de referencia contra Sui devnet y simulamos pagos contra mainnet sin firmar nada. Cada número de este post sale de esas corridas o del código fuente del protocolo, con fechas.

Qué cambió desde agosto

El binding que está hoy en el repositorio de x402 (scheme_exact_sui.md en faa1c69) es anterior a Address Balances. El buyer paga con objetos Coin<T>. Un facilitator que quiere patrocinar el gas publica una URL en extra.gasStation. El buyer le envía a esa URL una transacción parcial, recibe los campos de gas y recién entonces firma. El paso 3 de verificación dice que hay que simular la transacción "to ensure it would succeed and has not already been executed".

El protocolo cambió por debajo. Recorrimos el historial de epochs de Sui mainnet con el endpoint público de GraphQL. La versión de protocolo 124 entró en vigor en la epoch 1135, el 2026-05-21 a las 20:28 UTC. Su changelog en sui-protocol-config dice: "Enable address balances, free tier (gasless), and coin reservations on mainnet", con un "$0.01 minimum transfer per stable". Hoy mainnet corre el protocolo 138, con enable_accumulators, enable_address_balance_gas_payments y enable_gasless en true.

Address Balances, especificado en SIP-58, le da a cada dirección un saldo por tipo de moneda que mantiene el protocolo. Los depósitos se suman a ese saldo y los retiros se descuentan de él. No hay objetos que seleccionar, fusionar ni bloquear. El gas se puede pagar desde el saldo de SUI dejando vacío el gas payment.

Vinieron dos reescrituras del binding x402. Sceat abrió #2615 y #2616 el 12 de junio. El 7 de agosto, hayes-mysten, que hace commits regularmente al SDK de TypeScript de Mysten, abrió #3081 (la spec) y #3082 (el paquete @x402/sui, unas 4,700 líneas agregadas), presentados como un diseño alternativo. Ambos se actualizaron por última vez el 15 de octubre. Los cuatro siguen abiertos. #3081 y #3082 todavía no tienen reviews formales, solo hilos de comentarios de implementadores independientes. @x402/sui no está en npm.

La adopción real es casi nula. Recorrimos el catálogo completo del Bazaar el 2026-10-19 (25,578 recursos HTTP). Un solo host anuncia sui:mainnet, en tres recursos. Entre los tres suman cuatro llamadas en 30 días, cada recurso con un único pagador. Consultamos los endpoints /supported de siete facilitators públicos (el de CDP pide autenticación). Solo uno lista Sui: UltravioletaDAO, para mainnet y testnet, con un feePayer pero sin maxGasBudget. On-chain, el último pago patrocinado por ese sponsor en mainnet fue el 2026-01-01.

Esta es una historia de spec e implementación, no de tráfico. Importa porque el modelo de Sui es lo bastante distinto como para romper supuestos que todos los demás bindings le permiten hacer a un facilitator.

Verificación solo por efectos

Todos los demás bindings que auditamos restringen la forma del pago. En EVM, el buyer firma un struct EIP-3009, y su to y su value son el pago. En Solana, el facilitator revisa el layout de las instrucciones, o simula cuando el buyer es una smart wallet, como vimos en la auditoría de Path 2 en SVM.

#3081 elimina las reglas de forma sobre la transferencia en sí. Fuera de las reglas de expiración y de sponsorship, el buyer puede armar la transacción como quiera, y la firma completa. El facilitator la simula y lee una sola cosa del resultado: el cambio neto de saldo de payTo en asset. Ese número tiene que ser exactamente amount. La spec dice que el facilitator "does not check the payer's debit or other recipients". Los fondos pueden salir de un address balance, de objetos coin, de un swap o de un retiro de un contrato.

La comprobación en la implementación es corta:

// utils.ts, matchExactPayment (#3082 @ 5d9e947)
const got = balanceChanges
  .filter(c => normalizeStructTag(c.coinType) === coinType
            && normalizeSuiAddress(c.address) === recipient)
  .reduce((sum, c) => sum + BigInt(c.amount), 0n);
if (got !== want) return `recipient ${payTo} credited ${got}, expected exactly ${want}`;

Es el instinto correcto. "¿Al seller le pagaron exactamente?" es la única pregunta que le importa al seller, y la cadena la responde directamente. Un revisor encontró una trampa: en gRPC, balanceChanges solo viene si lo pides. Un facilitator que se olvida de pedirlo recibe una simulación exitosa sin nada que comprobar. La spec ahora obliga a los facilitators a pedir los balance changes y a fallar cerrado cuando no llegan.

La secuencia de verify tiene ocho pasos. Cinco corren offline: envelope, firmas, nonce opcional del servidor, expiración y reglas de sponsorship. Después vienen una búsqueda por digest, una simulación y la comprobación de efectos. La búsqueda por digest es nueva, y el resto de este post explica por qué tiene que existir.

El reloj corre en epochs

Una transacción sin objetos propios no tiene versiones de objeto que la protejan contra replay. SIP-58 describe la alternativa de Sui. Los validadores "remember the digests of all transactions executed in the current and prior epoch", y las transacciones sin estado tienen que declarar que solo son válidas en las epochs N y N+1. #3081 hace obligatoria esa ventana para todo pago: una expiración ValidDuring, con ambas epochs fijadas y maxEpoch - minEpoch <= 1.

En mainnet, una epoch dura un día. Medimos las 122 epochs completas más recientes (1163 a 1284, desde el 2026-06-19). Cada una duró entre 86,400.000 y 86,401.364 segundos, y cada una empieza cerca de las 00:05 UTC. Un pago firmado ahora en la epoch 1285 puede ejecutarse hasta que termine la epoch 1286, el 2026-10-21 cerca de las 00:05 UTC. Según en qué momento de la epoch se haya firmado, un pago firmado sigue siendo ejecutable entre 24 y 48 horas.

La spec lo dice sin rodeos: "maxTimeoutSeconds is advisory because Sui cannot enforce it". Probamos todas las formas de expiración contra devnet (protocolo 139, epochs de una hora):

# devnet, epoch 160, gas desde address balance, 2026-10-19
ValidDuring E..E+1          simulate ok   execute ok
ValidDuring E..E+2          simulate ok   execute REJECTED  "at most two epochs of validity"
maxTimestamp = now+60s      build REJECTED  "Timestamp-based transaction expiration is not yet supported"
sin expiración              simulate ok   execute REJECTED  "must either have address-owned inputs, or a ValidDuring..."
ValidDuring E-2..E-1        REJECTED  "Transaction Expired"

Dos filas importan. Una ventana de tres epochs y una expiración ausente pasan la simulación y solo fallan al ejecutar, así que el chequeo offline de expiración del facilitator es necesario, no redundante. Y no hay forma de acotar un pago en segundos. El struct tiene un campo maxTimestamp, pero hoy los validadores lo rechazan.

Después probamos un settle tardío. A las 09:08 UTC, en la epoch 160 de devnet, firmamos tres pagos patrocinados con maxTimeoutSeconds: 60. A las 09:26 empezó la epoch 161. Una instancia nueva del facilitator verificó y settleó uno de ellos 18 minutos después de la firma, unas 18 veces el timeout anunciado. Las dos llamadas salieron bien. En mainnet, ese mismo margen llega a dos días.

Guardamos otro payload hasta la epoch 162. A las 10:27 UTC, settle lo rechazó con invalid_exact_sui_payload_invalid_expiration ("current epoch 162 is outside the transaction's window 160..161"). Enviado directo al fullnode, el nodo respondió "Transaction Expired". Verify también lo rechazó, pero con invalid_exact_sui_payload_verification_error y el mensaje "Transaction Expired", no con el código simulation_failed que la spec asigna a los pagos expirados: el SDK lanza una excepción antes de devolver una simulación fallida. En cualquier caso falla cerrado, y la ventana se cierra donde la spec dice.

El éxito que se repite

Este es el hallazgo central. En EVM, una autorización EIP-3009 se gasta una sola vez. Si la envías de nuevo, revierte con "authorization is used or canceled" y el facilitator reporta un fallo. La respuesta de la cadena y la del facilitator coinciden.

En Sui no coinciden. Settleamos un pago patrocinado en devnet. Después hicimos dos cosas que haría un facilitator ingenuo:

# mismos bytes firmados, después de ejecutado el pago (devnet)
executeTransaction(bytes, sigs)   -> Transaction  ADR5WJh6…  payTo +1000000   // éxito otra vez
simulateTransaction(bytes)        -> success      payTo +1000000   // sigue "saldría bien"

Reenviarlo devolvió el digest y los efectos originales como un éxito. Re-simularlo también salió bien, con el mismo crédito a payTo. La cadena ejecutó el pago una sola vez. El saldo del seller lo demuestra. Pero la interfaz le reporta éxito a cualquiera que pregunte, así que cada facilitator que ve la transacción puede reportar "settled". El paso 3 de la spec anterior, "Simulate the transaction to ensure it … has not already been executed", ya no detecta nada en pagos con address balance.

#3081 responde con dos mecanismos. Verify busca el digest y rechaza la transacción si ya se ejecutó. Settle "reclama" el digest antes de enviar y conserva el reclamo si el settlement sale bien. La implementación de referencia guarda los reclamos por defecto en un Map en memoria, el SettlementCache. No llama a la búsqueda por digest durante settle, un hueco que un revisor señaló en agosto.

Corrimos el propio código de facilitator del PR (5d9e947, @mysten/sui 2.36.0) en devnet con un sponsor y un pagador, en varias configuraciones:

# un payload de pago, patrocinado, devnet, 2026-10-19
A.verify -> A.settle                      válido, éxito (1,007 ms + 1,106 ms)
replay a A: verify / settle               already_executed / duplicate_settlement
replay a B (instancia nueva): verify      already_executed
replay a B: settle sin verify             éxito, mismo digest
A|B concurrentes, verify+settle, 10 ensayos   20 éxitos reportados, 10 pagos acreditados
A|B|C concurrentes, 10 ensayos                30 éxitos reportados, 10 pagos acreditados
A|A concurrentes (misma instancia)            1 éxito, 1 duplicate_settlement

Una sola instancia es segura, porque su mapa en memoria atrapa el segundo settle. Los replays secuenciales los atrapa la búsqueda por digest, incluso en una segunda instancia. Pero cuando dos procesos de facilitator verifican y settlean el mismo payload casi al mismo tiempo, los dos reportan éxito, todas las veces. Tres procesos reportan tres éxitos por un solo pago.

Medimos qué tan ancha es la ventana de carrera. La instancia A arrancó de inmediato y B después de una demora fija, con tres ensayos por demora. Con 0 o 500 ms de demora, B también reportó éxito todas las veces. A 1,000 ms lo hizo dos de tres veces, y a 1,500 ms una. Desde 2,000 ms, la búsqueda por digest de B encontró la transacción ejecutada y la rechazó, 12 de 12. En devnet, verify tardó unos 400 ms y el primer settle entre 1.0 y 1.4 segundos. La ventana es más o menos el tiempo entre el primer verify y una ejecución ya indexada, uno o dos segundos. A un buyer que adjunta el mismo header de pago a requests en paralelo le alcanza de sobra.

El texto de la spec ya se contradice. El 2 de septiembre, el PR #3145 agregó reglas de familia al scheme exact del core. Para los métodos facilitator-submitted donde "a resubmission is indistinguishable from the original", dicen que esos métodos "MUST deduplicate settlements atomically across every process serving /settle, retaining each key until its payment can no longer land". #3081, abierto un mes antes y todavía sin cambios en este punto, dice que un facilitator con varias instancias "SHOULD use a shared, durable claim store", y trae por defecto un almacenamiento en el mismo proceso.

Solana tiene la misma clase de bug, con un reloj mucho más corto. La sección "Duplicate Settlement Mitigation (RECOMMENDED)" del binding SVM describe la misma carrera. Prescribe una caché en el proceso con expiración a los 120 segundos, unas dos veces la vida de un blockhash, como contamos en nuestro post sobre el settlement en SVM. El @x402/svm 2.28.0 publicado hace exactamente eso. En Solana, una clave solo tiene que durar más que un blockhash, cerca de un minuto. En Sui, #3081 dice que un reclamo exitoso tiene que conservarse "until at least one epoch after maxEpoch has ended". Con las epochs de 24 horas de mainnet, eso son hasta tres días de estado que tienen que compartir todos los procesos que sirven /settle y que tienen que sobrevivir a reinicios.

El nonce opcional del servidor no lo resuelve. #3081 es explícito: el nonce "is not consumed on-chain", la deduplicación usa el digest como clave y "a server that wants at most one payment per challenge retires the challenge itself". Eso también es estado compartido.

La búsqueda por digest tampoco es un respaldo durable. Un revisor reportó que digests de corridas en testnet de julio ya devolvían NOT_FOUND en el fullnode público de testnet el 7 de agosto. Una transacción podada no puede volver a ejecutarse una vez que pasó su ventana. Pero dentro de la ventana, la búsqueda es tan buena como el nodo que la responde.

Gasless tiene un piso, y es un centavo

Las transferencias de stablecoins gasless de Sui son la gran feature para pagos. La documentación de Sui lista siete stablecoins habilitadas, entre ellas USDC, USDSUI, AUSD y FDUSD. Las transferencias que califican no pagan gas en SUI, así que el emisor no necesita tener SUI.

El código del protocolo fija las restricciones. Cada token habilitado en mainnet tiene un mínimo de 10,000 unidades atómicas, $0.01 con seis decimales. Los inputs Pure tienen un tope de 32 bytes, con a lo sumo uno sin usar. El cómputo tiene un tope de 5,000 unidades y la transacción serializada de 16 KiB. La transacción no puede crear ni modificar ningún objeto. Y hay un tope para toda la red: gasless_max_tps vale 300 en mainnet. Lo aplica gasless_rate_limiter.rs en cada validador, con un contador de ráfaga por validador más un contador global alimentado por consenso. Bajo congestión, las transacciones gasless pierden prioridad.

Probamos el piso directamente, simulando en mainnet desde una dirección con USDC en su address balance. No se firmó nada.

# simulate en mainnet, send_funds de USDC, 2026-10-19 (sin firma, sin cambio de estado)
monto    gasless forzado (price 0)     autodetección del SDK
1,000    InsufficientGas               gasPrice 100, budget 200,000, costo 100,000 MIST
5,000    InsufficientGas               gasPrice 100, budget 200,000, costo 100,000 MIST
9,999    InsufficientGas               gasPrice 100, budget 200,000, costo 100,000 MIST
10,000   ok, costo 0                   gasless (price 0, budget 0)

Por debajo de un centavo, el SDK pasa sin avisar a pagar el gas desde el saldo de SUI del emisor. Un agente que solo tiene USDC no puede pagarlo. Hay una segunda regla, sobre el saldo del pagador. Desde una dirección con 15,645 unidades, un envío gasless de 10,000 fue rechazado: "Gasless transactions must either use the entire address balance, or leave at least 10000. Remaining amount would be 5645". Enviar las 15,645 completas simuló sin errores.

Testnet no te va a mostrar nada de esto. La configuración del protocolo lista el USDC de testnet con mínimo 0. Un pago de $0.001 en USDC califica como gasless ahí y después necesita SUI en mainnet.

El censo coincide. En las últimas 3,000 llamadas a 0x2::balance::send_funds en mainnet (07:35–09:12 UTC del 2026-10-19), 2,968 dejaron vacío el gas payment, así que los address balances claramente se usan. Solo 38 fueron gasless, y el crédito gasless más chico fue de $0.20. La misma muestra tiene 327 créditos en USDC por debajo de un centavo, y todos pagaron gas. El uso gasless está muy lejos del tope, en unas 0.0065 transacciones por segundo contra 300.

Para el pricing por llamada de un agente, ese es el titular. Los pagos de menos de un centavo en Sui necesitan SUI en la wallet del buyer o un sponsor.

Sponsorship, y el fee que se puede inflar

Address Balances también arregló el sponsorship. Con #3081, el buyer pone al sponsor del facilitator como dueño del gas, deja vacío el gas payment y firma primero. El sponsor firma los mismos bytes en el settle y paga desde su propio address balance de SUI. No hay ida y vuelta con una gas station ni bloqueo de coins. La documentación de Address Balances de Sui dice que estos "enable permissionless public gas stations".

El facilitator tiene que rechazar pagos patrocinados que usen el gas coin, que retiren fondos del sponsor, que traigan un gas payment no vacío o que fijen un budget por encima de extra.maxGasBudget. Dentro de esos límites, la verificación por efectos acepta cualquier cosa que le pague bien a payTo. Eso incluye trabajo extra que paga el sponsor.

Lo medimos en devnet. Un pago patrocinado simple le costó al sponsor 1,000,000 MIST (1,000 unidades de cómputo al precio de referencia de devnet, 1,000). El mismo pago con 295 llamadas extra a 0x2::hash::blake2b256 sobre un input de 16 KB le costó 9,760,000 MIST, 9.8 veces más. Verify y settle lo aceptaron los dos, porque payTo recibió exactamente el monto.

El límite es el tope. #3082 lo fija por defecto en 10,000,000 MIST, o 0.01 SUI. Al precio de referencia de mainnet, 100, un pago simple cuesta 100,000 MIST, así que el tope por defecto es 100 veces el costo honesto. A cerca de $1.01 por SUI el 2026-10-19 (CoinGecko y Coinbase), el tope ronda un centavo. Eso es diez veces el valor de un pago de inferencia de $0.001.

@mysten-incubation/sponsor 0.3.10 de Mysten, que el README del PR usa como executor, es directo sobre sus defaults: "No gas ceiling. A legitimate but expensive transaction can cost up to the protocol max". La regla de familia de exact en el core dice que ninguna operación puede debitar al facilitator "beyond that fee". El problema es el fee en sí: dentro del tope, el buyer decide de qué tamaño es. #3081 remite a los sponsors al rate limiting por emisor, que es todavía más estado compartido.

Qué significa para LLM4Agents

Nuestro 402 hoy ofrece una sola opción de pago. Un request sin autenticar a /v1/chat/completions devuelve exact en eip155:8453, USDC, maxTimeoutSeconds: 300 y $0.01 por una completion corta. Sui no está ahí. Si estuviera, tres cosas de esta auditoría nos afectarían directamente.

Primero, el piso gasless juega a nuestro favor. Redondeamos toda cotización hacia arriba al centavo, así que cada pago que pedimos es de al menos 10,000 unidades atómicas, que es el mínimo por destinatario. Un buyer con USDC en un address balance podría pagarnos sin SUI y sin sponsor, siempre que no deje un residuo menor a un centavo. No tendríamos que fondear ningún sponsor, y el problema del fee inflable no nos tocaría.

Segundo, el problema del éxito duplicado le pega más fuerte a un gateway que a una API estática. Con el flujo authorization por defecto, corremos la completion antes de settlear. Si dos requests concurrentes traen el mismo pago de Sui, los dos pasan verify y las dos completions corren. Con un solo proceso de settle, el segundo settle falla y esa respuesta se retiene, pero ya le pagamos al provider por ella. Ese es el hueco entre verify y settle que tiene toda cadena. Con dos procesos de settle, los dos settles reportan éxito y las dos completions se entregan por un solo crédito. La ventana de carrera que medimos, menos de 1.5 segundos, es más corta que casi cualquier completion, y los requests corren en paralelo.

Tercero, nuestra promesa de 300 segundos pasaría a ser decorativa. Un pago de Sui firmado contra nuestro 402 sigue siendo ejecutable hasta dos días, y nada on-chain hace cumplir esos 300. Cualquier frescura que queramos la tenemos que imponer en nuestro propio borde.

La lección más amplia es sobre la verificación por efectos. "Revisar el resultado, no la construcción" es el modelo correcto para un seller, y en cualquier cadena es la pregunta que debería hacer nuestro código de billing. Pero mueve la responsabilidad. Cuando la cadena repite "éxito" sin problema, la memoria del facilitator pasa a ser parte del sistema de pagos. Para nosotros, esa memoria tiene que ser compartida, atómica y durable.

Cómo mantenerse en la frontera

En este orden:

Primero, construir un almacén compartido de reclamos de settlement antes de sumar cualquier riel no EVM. La clave es (network, id de transacción), el reclamo es un set-if-absent atómico y el TTL es la ventana máxima de validez de la cadena más un margen: tres días para Sui, unos minutos para Solana. Pasar Base también por ahí, porque casi no cuesta nada y vuelve uniforme la garantía. Un cluster de cachés en memoria no es un almacén.

Segundo, buscar la transacción en settle, no solo en verify. Verify es orientativo y se puede saltear. Settle es el paso que compromete, y en Sui la respuesta de la propia red a un envío repetido es éxito.

Tercero, si sumamos Sui, lanzarlo gasless primero y sin sponsorship. No anunciar ningún feePayer. Aceptar solo pagos gasless o con gas pagado por el buyer. Nuestras cotizaciones redondeadas al centavo ya superan el piso, y eso elimina por completo la exposición del sponsor al gas. Si algún día patrocinamos, fijar el tope cerca del budget que el propio SDK usa para un pago simple, 200,000 MIST (el doble de los 100,000 medidos), en vez del default de 10,000,000, y limitar por emisor antes del primer pago.

Cuarto, usar el nonce del servidor para recuperar un reloj. #3081 le permite al seller emitir un extra.nonce y exige que sea estable para un mismo challenge. Hay que mantenerlo en 32 bytes o menos, o el pago pierde la elegibilidad gasless. Ocho bytes de hora de emisión más un HMAC de 24 bytes sobre el challenge y esa hora entran. Así nuestro servidor puede rechazar pagos viejos en segundos, aunque Sui solo cuente días. Retirar el challenge una vez atendido.

Quinto, probar con la economía de mainnet, no con la de testnet. El USDC de testnet tiene un mínimo gasless de cero, y las epochs de devnet duran una hora. Una integración con Sui que solo pasó por testnet no ejercitó el piso del centavo, la regla del residuo ni la epoch de 24 horas.

Sexto, seguir tres cosas upstream: si #3081 adopta el MUST de la regla del core para deduplicar entre procesos, si los validadores de Sui empiezan a aceptar maxTimestamp (la spec ya dice que entonces los clientes deberían acotar los pagos por maxTimeoutSeconds), y cómo se comporta el tope gasless de 300 TPS si alguna vez llega tráfico de agentes. Nuestra auditoría de la ventana en Stellar encontró el mismo patrón desde el otro lado. En todas las cadenas, las fallas interesantes están entre verify y settle.

Paga por llamada, en stablecoins, sobre una API compatible con OpenAI

Un 402, una firma, 345+ modelos. Sin suscripción, sin mínimo.

Registra tu agente