← Blog
October 31, 2026 · 14 min

x402 batch-settlement: who holds the voucher when the facilitator does

A payment channel has one question that matters more than the signature scheme: who keeps the latest voucher. x402 batch-settlement just gave that job to the facilitator. We read the merged spec, ran the pull request's own code and counted what the contract has done on Ethereum.

We covered the scheme itself in the original batch-settlement deep dive. In that version the resource server holds the vouchers and the facilitator only verifies and submits transactions. Spec v1.1 adds a second mode where the roles swap.

This post is about what that swap moves. Three things move: who consents to a refund, who owns the running total, and who can prove afterwards how many payments a claim covered.

What shipped, and what did not

The dates matter here, because the TypeScript side is behind the Go side.

We also unpacked @x402/evm 2.29.0, the latest version on npm when we checked on 2026-10-31. It contains the batch-settlement scheme. The string voucherManager appears nowhere in its dist folder. So today the managed mode exists in the spec and in Go. A TypeScript seller cannot opt in from a published package.

Two custody modes, one field

The spec describes the difference as a table of who holds what. In self-managed mode the resource server is the authoritative store. It keeps the latest client-signed voucher and the per-channel charge total, which the spec calls the watermark (chargedCumulativeAmount). It also decides when to claim and settle.

In facilitator-managed mode the server becomes a pass-through. It calls /verify and then /settle for every payload, including the new voucher type. The facilitator persists the voucher, the watermark and a charge counter, and runs the claim schedule.

The mode is one field on the 402. A facilitator lists what it supports in /supported. Omitting the field means ["server"]. The server picks one value and copies receiverAuthorizer and withdrawDelay from the facilitator. The spec forbids the server from overriding withdrawDelay or choosing a mode the facilitator did not advertise.

// accepts[0].extra on a facilitator-managed 402 (fields from the v1.1 spec)
{
  "voucherManager": "facilitator",
  "receiverAuthorizer": "0x...facilitator-advertised",
  "withdrawDelay": 900,
  // present only if the server signs refunds with its own key:
  "refundAuthorizer": "0x...server-owned EOA"
}

One consequence is easy to miss. The client echoes voucherManager and reads it for exactly one decision: whether to pack an address into the channel salt. That brings us to the first thing that moves.

Who may say yes to a refund

A cooperative refund returns escrow immediately and bypasses the withdrawal delay. So it needs consent from the receiving side. In self-managed mode the consent is the server's own signature, or the facilitator's authentication of the caller when the server delegated receiverAuthorizer.

In managed mode the facilitator is always the receiver authorizer. It signs the onchain refund itself. It therefore needs separate proof that the server wants this refund. The 402 selects one of two proofs:

The spec adds a rule to stop substitution. With a refund authorizer present, the client must set salt = bytes12(entropy) || bytes20(refundAuthorizer). The address becomes part of the channel config, so it becomes part of channelId. A /settle caller cannot swap in a different authorizer without changing the channel.

Managed /verify has a related guard. It must reject a 402 that omits refundAuthorizer when the facilitator does not honor caller-identity refunds, because then neither consent path exists and the escrow could only leave through the withdrawal delay.

What the packing does to the salt

We copied packRefundAuthorizerSalt verbatim from the PR and ran it with viem. The inputs were an index salt, the first random 32-byte salt we saw onchain, and two adversarial salts.

idx0   0x000000000000000000000000 aAAabbBb...CcccDDdd
idx1   0x000000000000000000000001 aAAabbBb...CcccDDdd
random 0x908d39dd614ff4c099ab2b4e aAAabbBb...CcccDDdd
same first 12 bytes, different tail      -> identical salt: true
index 1 vs bytes32 starting 0x..000001   -> identical salt: true

The rule is: if the high 12 bytes are zero, take the low 96 bits as entropy. Otherwise keep the first 12 bytes. A client that supplies a full random bytes32 keeps 96 of its 256 bits. Two different salts that agree on the first 12 bytes open the same channel.

This is by design and the PR comments say so ("full bytes32 / random-salt compat"). 96 bits is plenty against accidental collision. The practical point is narrower: a client that derives salts from a counter in the high bytes, or that reuses a prefix across channels, will silently reuse a channel. Treat the salt as a 96-bit index in managed mode, and use the index form.

Rotation has a cost too. The spec says changing refundAuthorizer changes the salt and therefore needs a new channel. Rotating that key means closing and reopening every managed channel it covers.

The watermark is the product

In managed mode the facilitator holds the number that decides what a client owes. The spec says /settle may commit a charge only when chargedCumulativeAmount equals voucher.maxClaimableAmount - payload.accepted.amount. A repeated settle after a commit "is not idempotent": it must leave the watermark unchanged and return invalid_batch_settlement_evm_cumulative_amount_mismatch. Callers must not auto-retry POST /settle.

We ran the PR's commitVoucherCharge against a stub store seeded at a watermark of 100, with a voucher cap of 110 and a price of 10:

first commit    committed            charged 100 -> 110
blind retry     watermark_mismatch   charged stays 110
no watermark    cap_exceeded         (110 + 10 = 120 > 110)
cap exceeded    cap_exceeded         (110 + 50 = 160 > 110)
stored charged  110

The retry case is the useful one. A seller that wraps /settle in a generic retry-on-timeout helper will get a hard error on the second attempt instead of a double charge. That is the safe direction. It also means a timeout is ambiguous: the first attempt may have committed. The corrective 402 exists for this. On a mismatch the facilitator returns channelState and voucherState (the last signed voucher), and the client verifies that signature before adopting the watermark.

Concurrency is handled by two mechanisms. Admission locks use a pendingId that /verify returns and /settle echoes. And managed deposit and refund settles hold the channel lock from before the onchain submission until the result is persisted, so a voucher cannot commit against escrow that is about to move. A cancel flag releases the reservation without charging when the handler fails.

The spec is also explicit about the limit of this design: pendingId should run over an authenticated facilitator channel, and a facilitator that exposes /settle to untrusted callers must not treat it as authorization.

The PR also flags a breaking change for anyone with custom storage. ChannelStorage becomes get, list and compare-and-swap update only. The pendingRequest field moves off the channel record onto a new ChannelLockStorage. In-memory, file and Redis stores implement both. If you built a Postgres adapter against the old interface, it stops compiling when this lands.

An audit trail in the calldata

When the facilitator decides when to claim, the seller loses direct knowledge of how many paid requests each claim covers. The spec adds an attestation for that. Each claim transaction can carry, in an ERC-8021 Schema 2 suffix, the map {"m": {"x402ChargeCounts": [c0, c1, ...]}}. One entry per claim row, in call order. The count is the number of paid offchain commits since the last confirmed claim.

The contract ignores trailing calldata, so nothing onchain enforces any of this. The spec's own indexer rules make the weakness explicit:

The cost is about 22 bytes plus one to three bytes per row. The format is the same carrier as x402 builder codes, so a facilitator code and the counts can share one suffix.

This is a reconciliation aid, not a proof. It tells a seller "the facilitator says 41 paid commits went into this claim", and the seller can check that against its own request log. It does not prove the facilitator did not drop commits.

What the chain shows today

The reference contract lives at 0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003, deterministic across chains. We queried eight EVM chains over public RPC (Ethereum, Base, Optimism, Arbitrum, BNB Chain, Avalanche, World Chain, Celo). All eight returned the same 11,175 bytes of runtime code. Polygon's RPC failed, so we say nothing about it.

Counting activity is harder. Public RPCs refuse archive eth_getLogs or cap it at a few blocks, and the Base Blockscout API sat behind a browser challenge from our host. Ethereum worked: Routescan returned 84 transactions to the contract, matching the Blockscout counter.

Here is that complete history, from 2026-05-22 to 2026-10-08:

deposit            11
claimWithSignature 32   (every one a single-row claim)
settle             32
initiateWithdraw    5
finalizeWithdraw    4
refund              0
failed              0     (isError = 0 on all 84)
ERC-8021 suffix     0     (no input ends in the 16-byte 0x8021 marker)
packed refund salt  0     (no deposit salt ends in a config address)

channels            9     (2 zero salts, 7 random bytes32)
deposited           10.0005 USDC + 0.0005 USDT
senders             5 addresses; 3 sent every deposit, claim and settle

That is test traffic. Deposits are 500, 1,000,000 or 2,000,000 base units on 6-decimal tokens, so between five hundredths of a cent and two dollars. Five addresses sent all 84 transactions. Two things stand out for this topic.

First, the managed-mode features have no onchain footprint yet: no packed salts, no attestation suffix. That fits the dates. The Go implementation merged four days before this census.

Second, the unilateral exit has been used and the cooperative one has not. Five timed withdrawals were initiated and four finalized. No refund transaction exists. On Ethereum, at least, the cooperative path is still untested by real flows. Since cooperative refund is exactly what managed mode reworks, that is worth knowing before depending on it.

We did not count Base, where most x402 volume lives. Treat these numbers as Ethereum only.

Where custody moves the risk

The spec bounds the damage a facilitator can do. The payer's exposure is capped by the signed maxClaimableAmount, and the receiver authorizer picks the actual claim within that bound. The facilitator cannot take more than the payer signed. What it controls is timing, completeness and the ledger.

These are our readings of the spec, not claims it makes:

None of these is a flaw. They are the price of not running a claim scheduler and a durable store yourself. The question for a seller is which of the two costs is cheaper for their traffic.

What it means for LLM4Agents

An LLM gateway is close to the ideal channel customer: many small calls from the same agent, each priced at a fraction of a cent, with a total that is unknown until the stream ends. Per-request on-chain settlement does not fit that shape. Our spend-controls audit showed that the client-side cap is the only hard limit most SDKs ship. A channel adds a second, structural cap: the deposit.

Managed custody changes what we would have to build. We would not need our own claim scheduler or a durable voucher store with compare-and-swap semantics. We would need to trust a facilitator with the ledger, and to accept that its outage is our revenue gap. The two-phase gap audit already showed how costly a settlement window between delivery and payment can be. In a channel, that window is the unclaimed watermark.

The watermark behavior is the right one for a metered product. A duplicate settle fails loudly. And the charge counter gives us a number to reconcile against our own usage log, which matters when usage is billed by tokens that arrive after the quote.

The threat side is smaller than it looks today. Nothing in production uses the managed salt or the suffix, and the TypeScript package that most sellers would use does not ship it.

Staying on the frontier

In order, and sized to what exists today.

  1. Pin the facts. Track PR #4061 and the next @x402/evm release. Re-run the string check for voucherManager in dist before assuming a TypeScript seller can use managed mode.
  2. Build the reconciliation first. Before choosing a mode, log every paid request with its channel and running total. Both modes need it, and the charge-count attestation is only useful if there is something to compare it with. The finality audit covers when a settlement is safe to count.
  3. Run a Go facilitator and one Go seller on a testnet, in both modes, and kill the facilitator mid-stream. Measure what the corrective 402 gives the client and what the seller's ledger looks like afterwards.
  4. Decide the refund proof deliberately. A server-owned refundAuthorizer costs a key and a rotation plan. delegatedRefund costs trust in a vendor's caller authentication. Write the choice down.
  5. Use index salts. If any client code generates salts, switch to small indexes and treat them as 96-bit values in managed mode.
  6. Add an indexer rule. If we ever read x402ChargeCounts, accept it only from named facilitator senders and join by (channelId, newTotalClaimed).
  7. Re-run the census on Base with an archive-capable node. Our Ethereum numbers show test traffic. Where the volume is, the managed share will show whether the mode is real.

Pay per call, without running the settlement stack

LLM4Agents gives autonomous agents an OpenAI-compatible gateway that bills in stablecoins over x402.

Register your agent