← Blog
October 19, 2026 · 17 min

x402 on Sui: effects-only verification and the success that repeats

A proposed rewrite of the x402 binding for Sui stops checking how a payment is built and checks only what it did. The design is clean. Its weak point is that, on Sui, a payment that already landed still reports success when you send it again.

In August, in our census of the fifteen x402 network bindings, we told readers to watch Sui's "Address Balances" feature. Sui's own binding said it could remove the interactive gas station and maybe enable EIP-3009-style payments. We quoted the appendix, which called the feature "in-development."

By then it was no longer in development. Address Balances had been live on Sui mainnet for more than ten weeks by then. Now a replacement binding built on it is open for review, with a full TypeScript implementation behind it.

We read both, ran the reference facilitator against Sui devnet, and simulated payments against mainnet without signing anything. Every number below comes from those runs or from protocol source, with dates.

What changed since August

The binding in the x402 repository today (scheme_exact_sui.md at faa1c69) predates Address Balances. The buyer pays with Coin<T> objects. A facilitator that wants to sponsor gas publishes a URL in extra.gasStation. The buyer sends that URL a partial transaction, gets gas fields back, and only then signs. Verification step 3 says to simulate the transaction "to ensure it would succeed and has not already been executed."

The protocol moved underneath it. We paged Sui mainnet's epoch history through the public GraphQL endpoint. Protocol version 124 took effect at epoch 1135, on 2026-05-21 at 20:28 UTC. Its changelog in sui-protocol-config reads: "Enable address balances, free tier (gasless), and coin reservations on mainnet," with a "$0.01 minimum transfer per stable." Today mainnet runs protocol 138, with enable_accumulators, enable_address_balance_gas_payments and enable_gasless all true.

Address Balances, specified in SIP-58, give each address one protocol-held balance per coin type. Deposits merge into it and withdrawals split from it. There are no objects to select, merge or lock. Gas can be paid from the SUI balance by leaving the gas payment empty.

Two rewrites of the x402 binding followed. Sceat opened #2615 and #2616 on June 12. On August 7, hayes-mysten, a regular committer to Mysten's TypeScript SDK, opened #3081 (spec) and #3082 (the @x402/sui package, about 4,700 added lines), presented as an alternative design. Both were last updated on October 15. All four are still open. #3081 and #3082 have no formal reviews yet, only comment threads from independent implementers. @x402/sui is not on npm.

Live adoption is close to zero. We paged the whole Bazaar catalog on 2026-10-19 (25,578 HTTP resources). One host advertises sui:mainnet, across three resources. Together they show four calls in 30 days, each resource with a single unique payer. We queried the /supported endpoints of seven public facilitators (CDP's requires auth). Only one lists Sui: UltravioletaDAO, for mainnet and testnet, with a feePayer but no maxGasBudget. On-chain, that mainnet sponsor's last sponsored payment was on 2026-01-01.

This is a spec-and-implementation story, not a traffic story. It matters because Sui's model is different enough to break assumptions every other binding lets facilitators make.

Effects-only verification

Every other binding we have audited constrains the shape of the payment. On EVM, the buyer signs an EIP-3009 struct, and its to and value are the payment. On Solana, the facilitator checks instruction layout, or simulates when the buyer is a smart wallet, which we covered in the SVM Path 2 audit.

#3081 drops shape checks on the transfer itself. Apart from the expiration and sponsorship rules, the buyer can build the transaction however it likes, and signs it in full. The facilitator simulates it and reads one thing from the result: the net balance change for payTo in asset. That number must equal amount exactly. The spec says the facilitator "does not check the payer's debit or other recipients." The funds can come from an address balance, coin objects, a swap or a contract withdrawal.

The implementation's check is short:

// 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}`;

This is the right instinct. "Did the seller get paid exactly?" is the only question the seller cares about, and the chain answers it directly. A reviewer found one trap: on gRPC, balanceChanges is only returned if you ask for it. A facilitator that forgets to ask gets a successful simulation with nothing to check. The spec now requires facilitators to request balance changes and to fail closed when they are missing.

The verify sequence has eight steps. Five run offline: envelope, signatures, optional server nonce, expiration, and sponsorship rules. Then come a digest lookup, a simulation, and the effects check. The digest lookup is new, and the rest of this post is about why it has to exist.

The clock runs in epochs

A transaction with no owned objects has no object versions to protect it from replay. SIP-58 describes Sui's alternative. Validators "remember the digests of all transactions executed in the current and prior epoch," and stateless transactions must declare that they are valid only in epochs N and N+1. #3081 makes that window mandatory for every payment: a ValidDuring expiration, both epochs set, and maxEpoch - minEpoch <= 1.

On mainnet, an epoch is a day. We measured the 122 most recent complete epochs (1163 to 1284, since 2026-06-19). Each lasted between 86,400.000 and 86,401.364 seconds, and each starts at about 00:05 UTC. A payment signed now in epoch 1285 can execute until epoch 1286 ends, on 2026-10-21 around 00:05 UTC. Depending on when in the epoch it was signed, a signed payment stays executable for 24 to 48 hours.

The spec says so plainly: "maxTimeoutSeconds is advisory because Sui cannot enforce it." We tested every expiration shape against devnet (protocol 139, one-hour epochs):

# devnet, epoch 160, gas from 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"
no expiration               simulate ok   execute REJECTED  "must either have address-owned inputs, or a ValidDuring..."
ValidDuring E-2..E-1        REJECTED  "Transaction Expired"

Two rows matter. A three-epoch window and a missing expiration both pass simulation and fail only at execution, so the facilitator's offline expiration check is necessary, not redundant. And there is no way to bound a payment in seconds. The struct has a maxTimestamp field, but validators reject it today.

Then we tested a late settle. At 09:08 UTC, in devnet epoch 160, we signed three sponsored payments with maxTimeoutSeconds: 60. At 09:26, epoch 161 began. A fresh facilitator instance verified and settled one of them 18 minutes after signing, about 18 times the advertised timeout. Both calls succeeded. On mainnet the same margin is up to two days.

We held another payload until epoch 162. At 10:27 UTC, settle rejected it with invalid_exact_sui_payload_invalid_expiration ("current epoch 162 is outside the transaction's window 160..161"). Submitted directly to the fullnode, the node answered "Transaction Expired." Verify rejected it too, but with invalid_exact_sui_payload_verification_error and the message "Transaction Expired", not the simulation_failed code the spec assigns to expired payments: the SDK throws before it returns a failed simulation. Either way it fails closed, and the window closes where the spec says it does.

The success that repeats

This is the core finding. On EVM, an EIP-3009 authorization is spent once. Submitting it again reverts with "authorization is used or canceled," and the facilitator reports failure. The chain's answer and the facilitator's answer match.

On Sui they do not. We settled a sponsored payment on devnet. Then we did two things a naive facilitator would do:

# same signed bytes, after the payment executed (devnet)
executeTransaction(bytes, sigs)   -> Transaction  ADR5WJh6…  payTo +1000000   // success again
simulateTransaction(bytes)        -> success      payTo +1000000   // still "would succeed"

Submitting it again returned the original digest and effects as a success. Simulating it again also succeeded, with the same credit to payTo. The chain executed the payment once. The seller's balance proves that. But the interface reports success to anyone who asks, so every facilitator that sees the transaction can report "settled." The old spec's step 3, "Simulate the transaction to ensure it … has not already been executed," no longer detects anything for address-balance payments.

#3081 answers this with two mechanisms. Verify looks the digest up and rejects it if it has already executed. Settle "claims" the digest before submitting and keeps the claim if settlement succeeds. The reference implementation keeps claims in an in-memory Map by default, the SettlementCache. It does not call the digest lookup during settle, a gap one reviewer flagged in August.

We ran the PR's own facilitator code (5d9e947, @mysten/sui 2.36.0) on devnet with a sponsor and a payer, in several configurations:

# one payment payload, sponsored, devnet, 2026-10-19
A.verify -> A.settle                      valid, success (1,007 ms + 1,106 ms)
replay to A: verify / settle              already_executed / duplicate_settlement
replay to B (new instance): verify        already_executed
replay to B: settle without verify        success, same digest
concurrent A|B, verify+settle, 10 trials   20 success reports, 10 payments credited
concurrent A|B|C, 10 trials                30 success reports, 10 payments credited
concurrent A|A (same instance)             1 success, 1 duplicate_settlement

One instance is safe, because its in-memory map catches the second settle. Sequential replays are caught by the digest lookup, even on a second instance. But when two facilitator processes each verify and settle the same payload at about the same time, both report success, every time. Three processes report three successes for one payment.

We measured how wide the race window is. Instance A started immediately, and B started after a fixed delay, three trials per delay. With a delay of 0 or 500 ms, B also reported success every time. At 1,000 ms it did so two times out of three, and at 1,500 ms once. From 2,000 ms on, B's digest lookup found the executed transaction and rejected it, 12 out of 12. On devnet, verify took about 400 ms and the first settle 1.0 to 1.4 seconds. The window is roughly the time from first verify to an indexed execution, a second or two. A buyer attaching the same payment header to parallel requests fits inside that easily.

The spec text already disagrees with itself. On September 2, PR #3145 added family rules to the core exact scheme. For facilitator-submitted methods where "a resubmission is indistinguishable from the original," they say those methods "MUST deduplicate settlements atomically across every process serving /settle, retaining each key until its payment can no longer land." #3081, opened a month earlier and still unchanged on this point, says a facilitator that runs several instances "SHOULD use a shared, durable claim store," and ships an in-process default.

Solana has the same class of bug, with a much shorter clock. The SVM binding's "Duplicate Settlement Mitigation (RECOMMENDED)" describes the same race. It prescribes an in-process cache with 120-second eviction, about twice the blockhash lifetime, as we described in our SVM settlement post. The shipped @x402/svm 2.28.0 does exactly that. On Solana, a key only has to outlive a blockhash, about a minute. On Sui, #3081 says a successful claim must be kept "until at least one epoch after maxEpoch has ended." With mainnet's 24-hour epochs, that is up to three days of state that must be shared by every process serving /settle and must survive restarts.

The optional server nonce does not close this. #3081 is explicit: the nonce "is not consumed on-chain," deduplication is keyed on the digest, and "a server that wants at most one payment per challenge retires the challenge itself." That, too, is shared state.

Digest lookups are not a durable backstop either. One reviewer reported that digests from July testnet runs returned NOT_FOUND on the public testnet fullnode by August 7. A pruned transaction cannot execute again once its window has passed. But inside the window, the lookup is only as good as the node answering it.

Gasless has a floor, and it is a cent

Sui's gasless stablecoin transfers are the headline feature for payments. The Sui docs list seven allowlisted stablecoins, including USDC, USDSUI, AUSD and FDUSD. Eligible transfers pay no gas in SUI, so the sender needs no SUI at all.

The protocol source sets the constraints. Every allowlisted mainnet token has a minimum of 10,000 atomic units, $0.01 at six decimals. Pure inputs are capped at 32 bytes, with at most one unused. Computation is capped at 5,000 units and the serialized transaction at 16 KiB. The transaction may not create or mutate any object. And there is a network-wide cap: gasless_max_tps is 300 on mainnet, enforced in the validator's gasless_rate_limiter.rs by a per-validator burst counter plus a consensus-fed global counter. Under congestion, gasless transactions are deprioritized.

We probed the floor directly, by simulating on mainnet from an address holding USDC in its address balance. Nothing was signed.

# mainnet simulate, USDC send_funds, 2026-10-19 (no signature, no state change)
amount   forced gasless (price 0)      SDK auto-detect
1,000    InsufficientGas               gasPrice 100, budget 200,000, cost 100,000 MIST
5,000    InsufficientGas               gasPrice 100, budget 200,000, cost 100,000 MIST
9,999    InsufficientGas               gasPrice 100, budget 200,000, cost 100,000 MIST
10,000   ok, cost 0                    gasless (price 0, budget 0)

Below one cent, the SDK quietly falls back to paying gas from the sender's SUI balance. An agent holding only USDC cannot pay that. A second rule applies to the payer's balance. From an address holding 15,645 units, a 10,000-unit gasless send was rejected: "Gasless transactions must either use the entire address balance, or leave at least 10000. Remaining amount would be 5645." Sending all 15,645 simulated cleanly.

Testnet will not show you any of this. The protocol config lists testnet USDC with a minimum of 0. A $0.001 USDC payment qualifies for gasless there, then needs SUI on mainnet.

The census agrees. Across the last 3,000 mainnet calls to 0x2::balance::send_funds (07:35–09:12 UTC on 2026-10-19), 2,968 left the gas payment empty, so address balances are clearly in use. Only 38 were gasless, and the smallest gasless credit was $0.20. The same sample has 327 USDC credits under one cent, and every one of them paid gas. Gasless use is far below the cap, at about 0.0065 transactions per second against 300.

For per-call agent pricing, that is the headline. Sub-cent payments on Sui need either SUI in the buyer's wallet or a sponsor.

Sponsorship, and the fee you can inflate

Address Balances also fixed sponsorship. Under #3081, the buyer names the facilitator's sponsor as gas owner, leaves the gas payment empty, and signs first. The sponsor co-signs the same bytes at settle and pays from its own SUI address balance. There is no gas station round trip and no coin locking. Sui's Address Balances documentation says they "enable permissionless public gas stations."

The facilitator must reject sponsored payments that use the gas coin, withdraw from the sponsor, carry a non-empty gas payment, or set a budget above extra.maxGasBudget. Within those limits, effects-only verification accepts anything that pays payTo correctly. That includes extra work the sponsor pays for.

We measured it on devnet. A plain sponsored payment cost the sponsor 1,000,000 MIST (1,000 computation units at devnet's reference price of 1,000). The same payment with 295 extra 0x2::hash::blake2b256 calls over a 16 KB input cost the sponsor 9,760,000 MIST, 9.8 times as much. Verify and settle both accepted it, because payTo was credited exactly.

The bound is the cap. #3082 defaults it to 10,000,000 MIST, or 0.01 SUI. At mainnet's reference price of 100, a plain payment costs 100,000 MIST, so the default cap is 100 times the honest cost. At about $1.01 per SUI on 2026-10-19 (CoinGecko and Coinbase), the cap is roughly a cent. That is ten times the value of a $0.001 inference payment.

Mysten's @mysten-incubation/sponsor 0.3.10, which the PR's README uses as the executor, is blunt about its defaults: "No gas ceiling. A legitimate but expensive transaction can cost up to the protocol max." The core exact family rule says no operation may debit the facilitator "beyond that fee." The trouble is the fee itself: within the cap, the buyer decides how big it is. #3081 points sponsors to per-sender rate limiting, which is still more shared state.

What it means for LLM4Agents

Our 402 today offers one payment option. An unauthenticated request to /v1/chat/completions returns exact on eip155:8453, USDC, maxTimeoutSeconds: 300, and $0.01 for a short completion. Sui is not on it. If it were, three things in this audit would apply to us directly.

First, the gasless floor works in our favor. We round every quote up to the cent, so every payment we request is at least 10,000 atomic units, which is the per-recipient minimum. A buyer holding USDC in an address balance could pay us with no SUI and no sponsor, as long as they do not leave dust under a cent. We would not need to fund a sponsor at all, and the inflatable-fee problem would not apply to us.

Second, the duplicate-success problem hits a gateway harder than a static API. Under the default authorization flow, we run the completion before we settle. If two concurrent requests carry the same Sui payment, both pass verify and both completions run. With one settle process, the second settle fails and that response is withheld, but we have already paid the upstream provider for it. That is the verify-to-settle gap every chain has. With two settle processes, both settles report success and both completions are delivered for one credit. The race window we measured, under 1.5 seconds, is shorter than almost any completion, and the requests run in parallel.

Third, our 300-second promise would become decorative. A Sui payment signed against our 402 stays executable for up to two days, and nothing on-chain enforces the 300. Any freshness we want, we have to enforce at our own boundary.

The broader lesson is about effects-only verification. "Check the outcome, not the construction" is the correct model for a seller, and on any chain it is the question our billing code should ask. But it moves responsibility. Once the chain will happily repeat "success," the facilitator's memory becomes part of the payment system. For us that memory has to be shared, atomic and durable.

Staying on the frontier

In order:

First, build a shared settlement claim store before adding any non-EVM rail. The key is (network, transaction id), the claim is an atomic set-if-absent, and the TTL is the chain's maximum validity window plus a margin: three days for Sui, a few minutes for Solana. Put Base through it too, because it costs almost nothing and makes the guarantee uniform. A cluster of in-memory caches is not a store.

Second, look the transaction up in settle, not only in verify. Verify is advisory and can be skipped. Settle is the step that commits, and on Sui the network's own answer to a repeated submission is success.

Third, if we add Sui, launch it gasless-first and without sponsorship. Advertise no feePayer. Accept gasless or self-paid payments only. Our cent-rounded quotes already clear the floor, and that removes the sponsor's gas exposure entirely. If we ever sponsor, set the cap near the SDK's own budget for a plain payment, 200,000 MIST (twice the measured 100,000), rather than the 10,000,000 default, and rate-limit per sender before the first payment.

Fourth, use the server nonce to restore a clock. #3081 lets the seller issue an extra.nonce and requires it to be stable for a given challenge. Keep it to 32 bytes or fewer, or the payment loses gasless eligibility. Eight bytes of issue time plus a 24-byte HMAC over the challenge and that time fits. Our server can then reject stale payments in seconds, even though Sui only counts days. Retire the challenge once it has been served.

Fifth, test on mainnet economics, not testnet economics. Testnet USDC has a gasless minimum of zero, and devnet epochs last an hour. A Sui integration that only passed testnet has not exercised the cent floor, the dust rule or the 24-hour epoch.

Sixth, track three things upstream: whether #3081 adopts the core rule's MUST for cross-process deduplication, whether Sui validators start accepting maxTimestamp (the spec already says clients should then bound payments by maxTimeoutSeconds), and how the 300 TPS gasless cap behaves if agent traffic ever arrives. Our Stellar window audit found the same pattern from the other side. On every chain, the interesting failures sit between verify and settle.

Pay per call, in stablecoins, over an OpenAI-compatible API

One 402, one signature, 345+ models. No subscription, no minimum.

Register your agent