Skip to content

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.

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 v2PAYMENT-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 payTo address 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.

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.

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 below

accepted 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.

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 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).

  • /settle success == accepted. SettleResponse.extra.phase is "accepted"; transaction is the burn transaction hash.
  • Pending. Self-transfer clients wait for the burn receipt before paying, so /settle normally answers definitively. On the permit path the facilitator broadcasts inside /settle and 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 …" } on POST /challenge, { "isValid": false, "invalidReason": "profile_unsupported" } on POST /verify, and { "success": false, "errorReason": "profile_unsupported", … } on the official-shape POST /settle (see docs/FACILITATOR.md).

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.

  • 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