Wire protocol reference: the subetha-zerc20 scheme
This page restates the normative wire surface of SubEtha’s x402 scheme as
fixed by
docs/PROTOCOL.md
at the pinned product commit. The TypeScript types exported by
@subetha/x402-scheme
(packages/x402-scheme/src/types.ts)
— SubethaExtra, SubethaPermitPayload, SubethaSelfTransferPayload — are
the normative definitions; that document and this page mirror them. On any
divergence, the pinned product sources win.
Identifiers and envelope
Section titled “Identifiers and envelope”| item | value |
|---|---|
| Scheme identifier | subetha-zerc20 (exported as SUBETHA_SCHEME) |
| Network identifier | CAIP-2, eip155:<chainId> (e.g. eip155:31337 for the local stack) |
| Envelope and headers | unmodified x402 v2 — PAYMENT-REQUIRED (base64 JSON PaymentRequired) on the 402 response, PAYMENT-SIGNATURE (base64 JSON PaymentPayload) on the paying request, PAYMENT-RESPONSE (base64 JSON SettleResponse) on the served response |
| x402 v1 fallback | none — SubEtha does not provide an X-PAYMENT fallback on this surface |
Two-phase settlement: what “settle” means here
Section titled “Two-phase settlement: what “settle” means here”A zERC20 payment has two phases:
- accepted — the burn transfer to the one-time
payToaddress is confirmed on-chain (receipt success + IndexedTransfer + balance). The resource MAY be served: the funds are already burned, the payer cannot reclaim them. - finalized — the proof-gated mint to the real recipient lands (asynchronously, minutes later; never on the request path).
Mapping onto x402: /settle success == accepted. The mint stays
out-of-band. A SettleResponse therefore carries
extra: { phase: "accepted" }; transaction is the burn transaction hash
(the on-chain event a payer can audit), not the mint.
PaymentRequirements (the offer)
Section titled “PaymentRequirements (the offer)”| field | value |
|---|---|
scheme |
"subetha-zerc20" |
network |
eip155:<chainId> |
asset |
the zERC20 token contract address |
amount |
the quoted amount in base units (decimal string). Under payer-charged fees this is list price + fee |
payTo |
a one-time burn address, freshly derived per 402. It is an opaque hash — the real recipient never appears anywhere the payer sees (F1) |
maxTimeoutSeconds |
seconds until the challenge expires |
extra |
SubethaExtra (below) |
extra: { challengeNonce: Hex; // 32-byte server nonce binding the offer to one payment expiresAt: number; // unix seconds (mirrors maxTimeoutSeconds as an absolute time) helper?: Address; // PermitBurner address — present iff the gasless path is offered deadline?: number; // unix seconds bound for the permit + burn-bind signatures fee?: { amount: string; // facilitator fee already included in `amount` bps: number }; // present ONLY under payer-charged fees (never provider-charged)}Offers expire: a client must obtain a fresh 402 rather than reuse an old
offer. Every 402 carries a new payTo — burn addresses are never reused
across payments.
PaymentPayload (the payment)
Section titled “PaymentPayload (the payment)”The paying request carries PAYMENT-SIGNATURE: base64(JSON(PaymentPayload))
with the full x402 v2 envelope:
{ x402Version: 2, accepted: PaymentRequirements, // the offer received in the 402, echoed VERBATIM payload: { ... } } // one of the two shapes belowaccepted MUST be the exact requirements object received in the 402 —
unmodified keys and values, unknown fields preserved. The seller middleware
deep-equal matches the core fields and requires its own extra to be a subset
of the echoed one; any normalization (key reordering is fine — JSON objects
are unordered — but dropping or rewriting fields is not) makes the match fail
and the payment is refused. The optional resource field of the envelope may
be omitted.
PaymentPayload.payload takes exactly one of two shapes.
Gasless permit (preferred; requires extra.helper):
{ challengeNonce: Hex, payer: Address, // EOA only (EIP-2612 recovery) permit: { value: string, nonce: string, deadline: string, // decimal strings v: number, r: Hex, s: Hex }, // EIP-2612 permit signature burnAuthSig: Hex } // EIP-712 burn-bind signature (PermitBurner ADR-001)The payer only signs; the facilitator broadcasts the burn and pays its gas.
permit.value must equal the quoted amount exactly.
Self-transfer (fallback; payer pays gas):
{ challengeNonce: Hex, burnTxHash: Hex }The client MUST broadcast the ERC20 transfer of amount to payTo and wait
for a successful receipt before sending the payload (@subetha/x402-scheme
enforces this and throws on a reverted/unconfirmed burn).
Discrimination rule (server side). If any of permit / payer /
burnAuthSig is present, all three MUST be — a partial triple is rejected as
malformed and never falls through to the self-transfer interpretation.
Otherwise burnTxHash selects the self-transfer shape.
EIP-712 definitions (normative — the gasless permit path)
Section titled “EIP-712 definitions (normative — the gasless permit path)”Both signatures are standard EIP-712 typed-data signatures by the payer EOA,
bound to the offer’s deadline (extra.deadline, unix seconds).
Permit — the EIP-2612 approval that lets the PermitBurner helper move the funds:
| domain | { name: <token.name()>, version: "1", chainId: <from network>, verifyingContract: <asset> } |
| types | Permit(address owner, address spender, uint256 value, uint256 nonce, uint256 deadline) |
| message | owner = payer, spender = extra.helper, value = amount (exact), nonce = token.nonces(payer) read live, deadline = extra.deadline |
The wire permit object carries value/nonce/deadline as decimal
strings, v as a number, and r/s as 32-byte hex.
BurnAuthorization — SubEtha’s burn-bind (permit alone does not bind the
destination; ADR-001 in
contracts/README.md):
| domain | { name: "SubEthaPermitBurner", version: "1", chainId: <from network>, verifyingContract: <extra.helper> } |
| types | BurnAuthorization(address token, address payer, address burnAddress, uint256 value, bytes32 challengeNonce, uint256 deadline) |
| message | token = asset, payer, burnAddress = payTo, value = amount, challengeNonce = extra.challengeNonce, deadline = extra.deadline |
The wire burnAuthSig is the 65-byte r ‖ s ‖ v signature as 0x-hex. The
facilitator recovers both signatures and rejects the payment unless both
recover to payer.
Facilitator HTTP API
Section titled “Facilitator HTTP API”Standard x402 v2 facilitator surface plus one SubEtha extension:
| endpoint | role |
|---|---|
GET /supported |
advertises { kinds: [{ x402Version: 2, scheme: "subetha-zerc20", network: "eip155:<chainId>" }], extensions: [], signers: {} }. Resource servers using the official middleware require this at initialization |
POST /verify |
body { x402Version, paymentPayload, paymentRequirements } → VerifyResponse. Read-only substantive check (below) |
POST /settle |
same body → SettleResponse. Executes the payment: routes the payload to the burn-confirmation path |
POST /challenge |
SubEtha extension. Body { amount: "<base units>" } (the LIST price) → a fresh challenge offer { payTo, amount (quoted), token, chainId, challengeNonce, expiresAt, helper?, deadline?, fee? }. Exists because a payTo burn address can only be derived by the facilitator (seed holder) — a resource server cannot mint its own PaymentRequirements the way vanilla x402 sellers do. The scheme server plugin calls it per 402 |
Requests’ paymentRequirements are untrusted: both /verify and
/settle compare network, asset, payTo, amount and
extra.challengeNonce against the facilitator’s own challenge record and
reject any mismatch.
Verify semantics
Section titled “Verify semantics”/verify checks, without mutating anything: the challenge exists and is
unexpired; for self-transfer — the burn tx receipt exists, succeeded, carries
an IndexedTransfer of ≥ amount to payTo on the right token, and the burn
address holds the funds; for permit — both signatures recover to payer, the
token nonce is live, the deadline holds, the value matches, and the payer
balance covers the amount.
Verify is point-in-time, not a settlement guarantee (TOCTOU). The official middleware runs verify → handler → settle; a payer can consume its permit nonce or move its balance between verify and settle, in which case settle fails after the handler already ran. Providers of computationally expensive resources should apply ordinary rate limiting / abuse controls; the funds themselves are never at risk (nothing is served on a failed settle — the middleware replaces the buffered response with a 402).
Status and error semantics
Section titled “Status and error semantics”/settlesuccess == accepted.SettleResponse.extra.phaseis"accepted";transactionis the burn transaction hash.- Pending. Self-transfer clients wait for the burn receipt before paying,
so
/settlenormally answers definitively. On the permit path the facilitator broadcasts inside/settleand polls the receipt to a bound; if the burn is still unmined it answers{ success: false, errorReason: "pending_burn" }. Re-sending the same payload is idempotent and safe — a broadcast permit is reconciled via its on-chain replay ledger (the burn is never double-executed) — but automatic client retry is not part of this version. - Profile gating. The packaged facilitator daemon accepts payments only
under its local profile in this release. A non-local profile answers
403 { "error": "payment acceptance is disabled for profile …" }onPOST /challenge,{ "isValid": false, "invalidReason": "profile_unsupported" }onPOST /verify, and{ "success": false, "errorReason": "profile_unsupported", … }on the official-shapePOST /settle(seedocs/FACILITATOR.md).
Legacy surface
Section titled “Legacy surface”The pre-P0 JSON shapes (POST /challenge unchanged; POST /settle with a
direct { challengeNonce, burnTxHash } /
{ challengeNonce, payer, permit, burnAuthSig } body) remain supported for
existing integrations (the web demo). New integrations should use the
official-shape endpoints.
Compatibility policy
Section titled “Compatibility policy”- Additive-only. New optional fields may appear in
extra, payloads and responses; unknown keys MUST be ignored. - The scheme name, payload discrimination rule and the meaning of settle
(= accepted) are frozen for
x402Version: 2. - Anything SubEtha-specific lives in
extra/payload/SettleResponse.extra— the x402 envelope is never altered.
See Versions & compatibility for the protocol scope and package/registry facts.
Implementation sources (at the pinned commit)
Section titled “Implementation sources (at the pinned commit)”| surface | source |
|---|---|
| Normative wire spec | docs/PROTOCOL.md |
| Wire types + requirements validation | packages/x402-scheme/src/types.ts |
| Payer-side payload builder | packages/x402-scheme/src/client.ts |
| Fee parsing / offer ranking | packages/x402-scheme/src/fee.ts |
| Provider-side scheme server + validation | packages/provider/src/scheme-server.ts, packages/provider/src/validate.ts |
| Facilitator HTTP daemon | apps/facilitator/src/http.ts (source-only; not an npm package) |
| Python reference payer | python/src/subetha/wire.py |