x402 on Canton: the payment is a transaction the facilitator has to read
On EVM, a facilitator verifies a payment by checking a signature over a few fields. On Canton, the buyer signs a whole ledger transaction and ships it, compressed, inside the PAYMENT-SIGNATURE header. Verifying that means reading a transaction graph. We read the merged spec, the longer version in the open reference-implementation PR and its review, and tested the parts that break: the gzip framing and the header budget.
Canton Network is a privacy-oriented ledger built on Daml smart contracts. Its native token is Canton Coin (CC), and CIP-56 defines the token standard other assets follow. The x402 work comes in two pieces. The spec-only PR #2634 was merged on 2026-08-07: one file, specs/schemes/exact/scheme_exact_canton.md, 360 lines. The reference implementation, #3242, was opened on 2026-08-24 as a new TypeScript package, @x402/canton, with 15,326 added lines. As of 2026-10-25 it is still open. A maintainer approved it on 2026-10-23 and asked for a lint fix; the author reported CI green on 2026-10-24.
One contributor says in the PR thread that their implementation has been live on Canton MainNet since early June, with 265,000+ payments, 3,000+ paying agents and 10+ merchants. That is a self-reported figure and we did not verify it. We read it as evidence that the design runs, not as a measurement.
What the buyer signs
The scheme is exact on canton:mainnet, canton:testnet or canton:devnet. The amount is in atomic units, 1 CC = 1e10, so "1000000000" is 0.1 CC. payTo is a Canton party id of the form name::fingerprint.
The buyer prepares a TransferFactory_Transfer from the CIP-56 token standard: sender, receiver, amount, the exact input holdings spent, and an executeBefore deadline. It signs the hash of that prepared transaction with ed25519 and does not submit it. The whole signed submission, with its disclosed contracts embedded, goes into the payload:
"payload": {
"assetTransferMethod": "transfer-factory",
"preparedTransaction": "<base64(gzip(prepared tx))>",
"preparedTxHash": "<hex hash the payer signed>",
"signature": "<base64 ed25519 over the hash>",
"hashingSchemeVersion": "HASHING_SCHEME_VERSION_V2"
}
Because the transaction is self-contained, any facilitator can relay it. The buyer needs no relationship with the facilitator's node. The facilitator submits the transaction, pays the network traffic fee (named in extra.feePayer) and signs nothing for the buyer. Funds move from the buyer's own holdings.
The merchant must hold a live TransferPreapproval. With it, the transfer resolves directly in one transaction. Without it, the transfer becomes a pending two-step instruction the merchant has to accept later, which the facilitator must refuse before relaying.
Fees: two kinds, one of them zero
A reviewer asked early about fees, and the answer clarifies the money model. There are traffic fees, which the facilitator pays as relayer, and Amulet transfer fees, which come out of the payer's inputs. The author's reply points to CIP-0078 (status Final, created 2025-09-08, approved 2025-09-15). It sets the Canton Coin transfer, create and lock-holder fees to 0.0 and removes holding fees when a coin is used as a transfer input. So on MainNet the sum of inputs equals the sum of outputs, and the merchant gets exactly amount.
The cost to the facilitator is real and stays: traffic. The PR's spec therefore adds a Merchant Policy section. A facilitator may relay only for merchants it allowlists, for example those whose preapproval it provides, and reject others with invalid_exact_canton_merchant_not_registered. That is an operator defending its own budget. Few other rails need this, because on EVM the facilitator pays gas for every settlement but can price it in.
The spec grew by 113 lines in review
We diffed the merged spec against the version inside #3242 at head 0f419ac: 360 lines became 473. The changes are not cosmetic. They are the review history written back into the normative text.
Verify now proves the factory. The merged Rule 3 said the transaction must contain exactly one TransferFactory_Transfer, with a valid signature. The PR version adds that the exercise must target the real factory, and says how to prove it from the signed transaction's input contracts: for Canton Coin, an ExternalPartyAmuletRules whose only signatory is the DSO party. It ends with a sentence that explains the incident: "Matching a template by name alone, or trusting a factory id read from a Scan or registry, is not sufficient."
Input sufficiency stopped reading Scan. Rule 13 originally told a facilitator that hosts the payer to check that inputs are active. The new text requires each declared input to be an input contract of the transaction, owned by the proven sender, and states the limit plainly: whether an input is still unspent is visible only to its stakeholders, so verification does not establish it. The ledger enforces it atomically at execute. A spent input fails the transfer with no funds moved. The residual risk in the authorization flow is wasted handler work, "never of unpaid delivery". The upfront flow, which the PR now also supports, removes it.
Settle gained a third answer. The merged text had success or failure. The new text adds settlement_pending, covered below.
Three review findings that are really design findings
The review on #3242 ran from 2026-08-31 to 2026-10-23, mostly between maintainer erikreppel, the automated reviewer account PhilBot402 (its comments carry the disclosure "Automated by @phdargen") and the author. Three findings generalize beyond Canton.
Template names are not identities
The 2026-09-29 review found that the root exercise was matched by choice name only, and that the code dropped the package id when comparing templates. The scenario: a payer vets a look-alike package whose template has the same module and entity names and a choice called TransferFactory_Transfer, but whose body does something else. Verify passes, settle reports success, the merchant gets nothing. The reviewer noted that exploitability depends on package vetting on the relaying participant, and that the facilitator was not proving it ran the real factory either way.
The fix, per the author's 2026-10-14 reply, proves the factory from the signed transaction itself, with no Scan lookup and no package-id pin. The Canton Coin factory must be a contract signed only by the DSO, which the payer cannot have created, and the confirming participants authenticate each input contract against its id at execution. This is the pattern worth copying. When the signed object names contracts, check who signed those contracts, not what they are called.
Unknown outcome is not failure
Two separate findings, on 2026-08-31 and 2026-09-29, were the same bug in different places. A settle call whose execute request timed out, or whose completion could not be read, was reported as invalid_exact_canton_execute_failed, which the spec marks as retryable from fresh inputs. If the transaction had in fact committed, the buyer re-signs with new inputs and pays twice.
The current spec says a facilitator "MUST NOT report failure" when the participant may have accepted the transaction. It answers settlement_pending, a non-terminal state, with transaction set to the prepared transaction hash because no updateId exists before commit. On a retry of the same payload it must look the submission up by completion or by the signed command id, and only a participant that hosts the payer may turn "not found" into a failure, and only once the transaction can no longer commit. A facilitator that cannot see the payer "never turns a pending settlement into a failure".
On 2026-10-14 phdargen also rejected an earlier choice to return success when the execute committed but the funds-moved read was inconclusive: settle should return success only when the transaction is confirmed, as on other networks. The author now returns success only on commit. This matches what we found on Base in our settlement finality audit: the word "success" needs a stated meaning per rail, and three-valued results are the honest shape.
A last round on 2026-10-21 caught a replay hole in the rewrite itself: a settled payment could be answered again from a cache. The author's fix caches only pending results, answers a committed one once and drops it, and lets a replay relay again so the ledger refuses it. The spec text went from SHOULD NOT to MUST NOT: a facilitator "MUST NOT answer a settle from a remembered commit". This is the same point as in our recovery extension audit. Free replay is only safe after the operation's own settlement, and a cache is a way to get it wrong.
Compressed input is an attack surface
The spec mandates a hardened gzip decoder: exactly one member, no trailing bytes, no concatenated members, no filename, comment or extra header fields, incremental decompression, a compressed cap of about 8 KiB and a decompressed cap of about 64 KiB. The reviewer's 2026-09-29 finding was that the first implementation took the last 8 bytes as the trailer and used raw inflate on the rest. Raw inflate stops at the end of the deflate stream and ignores whatever follows. So junk placed between the stream and the trailer still decoded to the original bytes and passed the CRC and size check.
We reproduced the family of bugs with Node 22.22.2 and a 27.6 KB test payload:
clean member decodes
8 trailing zero bytes (gunzip) decodes (zlib treats zeros as padding)
trailing "JUNKJUNK" (gunzip) Z_DATA_ERROR
valid second member appended decodes (concatenation accepted)
junk between deflate end and trailer
naive trailer-based decoder decodes to the same bytes
+ require inflate to consume body rejected
Two things follow. First, a stock gunzip call is not the strict decoder the spec asks for: it accepted a concatenated member and zero padding. Second, the forged variant is a different base64 string that decodes to identical prepared bytes with a valid signature. One signed payment gets many valid wire encodings. Anything that keys on the raw header, such as an idempotency store, a payload-hash cache or a receipt ledger, sees them as different payments. The fix in the PR is the one we would pick: ask zlib how many input bytes it consumed (info: true) and require that to equal the body length. We checked that this rejects our forged variant.
The header budget
The spec says base64(gzip(preparedTransaction)) is typically 6 to 7 KiB, that the complete payload commonly exceeds 8 KiB once encoded, and that servers should accept at least 16 KiB in PAYMENT-SIGNATURE. A comment in the codec reports 22 to 27 KB raw and 4.4 to 5.4 KB gzipped, measured on mainnet by the authors. We did not have mainnet fixtures, so we built the envelope ourselves with synthetic incompressible bytes and realistic party ids:
gzip member size header value (base64 of the JSON payload)
~6.1 KB (typical) 12,148 bytes
8,157 B (at the 8 KiB cap) 15,764 bytes (limit discussed: 16,384)
The number to notice is the second. A payload at the recommended compressed cap fits under 16 KiB with only about 600 bytes to spare, and that is before any extension data. The payload is base64 twice, once for the gzip bytes and once for the JSON envelope, so the overhead compounds. In our header-size audit we found clients and servers with 8,190-byte and 16 KiB limits. Against those, the typical Canton payment already fails on the smaller one. The spec's own advice is to use MCP, which does not carry the payload in an HTTP header, where a deployment cannot raise the limit.
What it means for LLM4Agents
We do not support Canton and have no plan to. Our rails are the EIP-3009 and Solana paths on the chains we settle on today. The relevance is what Canton shows about the verification model.
First, it is the strongest example so far of a scheme where the signed object carries logic. An EIP-3009 authorization is a handful of fields with one possible effect. A Canton prepared transaction is a graph of contracts, and verify has to prove that graph does one thing. The review shows how many ways that goes wrong when attempted from names and Scan lookups. Any future rail of this kind, including the open Nano and BSV proposals, will carry the same burden.
Second, settlement_pending is the right model and we should expect it to spread. Our gateway turns a 402 payment into a paid model call. A timeout between "facilitator accepted" and "facilitator confirmed" must not become either a free completion or a double charge. If a rail returns pending, we need a defined hold state for the request, not a guess.
Third, canonical encoding. If the same payment can arrive in several byte forms, any deduplication on our side has to key on the thing that was signed, not on the header string.
Staying on the frontier
In order:
First, do not build a Canton rail now. Watch #3242 for merge and for a tagged @x402/canton release, and look at whether a second, independent facilitator appears. One operator running it is a deployment, not yet an ecosystem.
Second, adopt the three-valued settle result in our facilitator interface: confirmed, pending, failed. Define what our gateway does with a pending result: hold the response, retry settle once with the same payload, and release or fail on the answer. Never retry with a fresh signature on our own.
Third, key all payment deduplication on the verified, decoded signed object (payer, nonce or input set, amount) and never on the raw header bytes. Add a test that sends two byte-different encodings of one payment and expects one charge.
Fourth, put the strict-gzip lesson into our own header handling. We accept compressed bodies elsewhere; confirm that decoders reject concatenated members and trailing bytes, and cap output incrementally.
Fifth, keep a header-size check in the buyer harness: build the largest payload each supported rail can produce and assert it passes our edge and the common server defaults. The Canton arithmetic above, 15,764 of 16,384 bytes at the cap, is the template.
Sixth, record, for each rail, what "verified" means: signature only, signature plus balance read, or signature plus structural proof of the effect. Canton is the first rail where the third is required, and the table is how we explain the difference to buyers.
Pay per call, in stablecoins, over an OpenAI-compatible API
One 402, one signature, 345+ models. No subscription, no minimum.
Register your agent