Menu

Explorer & Settings

Tempo Explorer Submit Project
Back to all projects
S

SubEtha

by peaceandwhisky · Updated Jul 13, 2026

Private x402 payments on zERC20 — hide who paid whom, not the price tag.

12
Stars
1
Forks
TypeScript
Language
Jun 14, 2026
Created

In the AI payments ecosystem

SubEtha is an early-stage TypeScript project in the AI payments / x402 ecosystem, focused on ai-agents, ethereum, privacy, x402. It currently has 12 GitHub stars and 1 forks, and sits alongside related tools like tdm-integration-kit, gold-402, internet-court-skill.

README.md View on GitHub →

SubEtha — confidential machine payments for AI agents

In Douglas Adams's science-fiction novel The Hitchhiker's Guide to the Galaxy, the Sub-Etha is the invisible galaxy-wide network that carries signals across the stars. This project borrows the name for money: a payment rail for AI agents where the payment goes through but the link between payer and payee doesn't.

🌐 日本語版: README.ja.md

SubEtha extends the x402-style HTTP payment flow with zERC20 so that when an AI agent pays an API provider, no on-chain trail leads back to who paid.

Background

An AI agent calls an API every time it searches, reasons, fetches data, or runs an external tool. Until now the usual arrangement was that a human creates an account, issues an API key, and pays through a monthly contract or a prepaid balance. In an agentic economy, what matters instead is a flow where the agent itself finds the API it needs, pays per request, and receives the result.

Machine-payment designs like x402 and MPP fit this flow. Using HTTP 402 Payment Required, the provider answers "this is what this API costs", and the client — the AI agent — pays on the spot and receives the resource as an extension of the same request. API calls and payments live inside one protocol, with no API keys or human-oriented checkout in between.

But if payments settle on-chain, the payment history also lands on a public ledger. The transparency a human could tolerate for occasional payments becomes, in a world where AI agents combine APIs at high frequency, a public log of the agent's behavior and of the provider's revenue.

The problem

Standard x402/MPP settlement is an ordinary on-chain ERC-20 transfer. That transfer is public and permanent, and it leaks two things:

  1. What the AI agent is doing can be inferred from its payment history. Keep paying over x402 from the same wallet, and the public record shows which API providers were paid, when, how much, and in what order. Combine that with the wallet's other on-chain activity (funding, DeFi, payments to other services) and an observer can infer which external services the agent depends on, what workflows it runs, and what it is trying to do. The payment history doubles as the agent's behavior log and a window into its cost structure.
  2. The API provider's revenue is visible at its receiving address. If the standard x402 payTo is a fixed receiving address, anyone can watch the transfers into it and estimate the provider's revenue, usage trends, and dependence on key customers. With prices and API endpoints public, on-chain inflows reveal business growth and revenue structure to competitors, customers, and outside observers.

This is not just a personal-privacy concern. For an AI agent, leaking its execution strategy, external dependencies, and cost structure exposes competitive advantages and operational weak points. For an API provider, public revenue and customer composition give outsiders material they can use directly for competitive analysis, price negotiation, and sales strategy. The more machine-to-machine payments there are, the more the public payment history becomes the transaction log of the machine economy — and the greater both its analytical value and its leakage risk.

The approach

SubEtha keeps the x402-style HTTP flow built on 402 Payment Required, and replaces settlement with zERC20 burn / mint. The payment is never recorded on-chain as "a transfer from the AI agent to the API provider". Below, the paying AI agent is the payer, the receiving API provider is the provider, and the provider's actual receiving wallet is the treasury.

In ordinary ERC-20 settlement, the payer transfers directly to the provider's payTo. In SubEtha, the payTo carries not the treasury but a burn address from which the receiver cannot be derived (a fresh address is derived per payment — on the single path and on the mint-several-together path alike). On the main path — permit mode — the payer only signs the payment, and the facilitator uses that signature to send the zERC20 to the burn address. The payer never learns the treasury, and no payer → treasury transfer exists on-chain.

The provider returns the API result as soon as the burn is confirmed on-chain (accepted). Afterwards, the facilitator mints to the treasury through a mechanism that proves only the right to receive, without revealing which burn that right corresponds to (finalized). All that remains on-chain are two facts — a burn and a mint — and which payer's burn corresponds to which provider's mint cannot be read from them.

So what SubEtha hides is not the payment itself but the correspondence between the AI agent (payer) and the API provider (provider). On the agent side, the mix and order of the APIs it uses become hard to read; on the provider side, revenue and customer composition can no longer be tracked just by watching inflows to a payTo. The construction uses the official zERC20 toolchain: SubEtha does not issue a new token or fork zERC20 — it is the bridge that combines the x402-style payment flow with official zERC20 settlement. How and why that correspondence is severed is made precise in Architecture and Privacy below.

This PoC therefore does not use standard x402 settlement as-is. It keeps the HTTP 402-based request / payment-required / retry shape, but puts a burn address — not the provider's receiving address — in payTo, and settles through zERC20's burn → proof-gated mint.

The current implementation is a local, non-production PoC. The whole flow runs end-to-end on the official @zerc20/sdk and the official zERC20 contracts. A normal paid API call goes 402 → pay → accepted (resource served) → finalized (proof-gated mint), and a single payment is minted to the treasury by the official Verifier.singleTeleport. In addition, the provider can mint several API calls together later in one go. Here too every payment uses a fresh burn address, so — exactly as on the single path — the payments do not link to each other on-chain. See Running the demo to try it, and License for what non-production means and the constraints on commercial / production use.

PoC scope:

  • Standard settlement. The standard path of the CLI / web demos is single-payment settlement: one burn, minted to the treasury via the official zERC20 Verifier.singleTeleport.
  • Permit mode (the main path). In the UX SubEtha assumes, the payer only signs and the PermitBurner helper broadcasts the burn. zERC20 lacks EIP-3009 (x402's usual gasless path), so EIP-2612 permit + burn-bind achieves the same goal. For verification, the CLI also keeps a self-transfer path where the payer pays its own gas for the burn (see What SubEtha keeps / defers).
  • Minting several payments together (optional / experimental). The default mints once per call. Optionally, the provider can mint several API calls together later in one go. This is a feature for settlement efficiency and provider-side operational freedom; it uses a fresh burn address per payment just like the single path — minting together does not sacrifice unlinkability.

Architecture

AI Agent (payer)
  │
  │ 1. API request
  ▼
API Provider
  │
  │ 2. 402 Payment Required
  │    payTo = burnAddress, not treasury
  ▼
AI Agent (payer)
  │
  │ 3. Payment
  │    permit mode: payer signs & calls the API again (0 gas)
  │    CLI fallback: payer sends the burn tx itself (pays gas)
  ▼
API Provider + Facilitator
  │
  │ 4. accepted
  │    permit mode: facilitator broadcasts the burn on-chain (zERC20)
  │    burn confirmed -> resource served to payer
  ▼
Facilitator + official zERC20 stack
  │
  │ 5. finalized
  │    default: mint one payment
  │    optional: mint several payments together
  ▼
Treasury
  • The payer never sees the receiver. The 402's payTo carries only a burn address from which the receiver cannot be derived — not the provider's treasury. The payer merely "sends the tokens for this payment to the designated burn address", so it never learns the actual receiver.
  • The payer normally only signs. The assumed main path is permit mode: the payer signs the payment, and the facilitator uses that signature to send the burn. For verification, the CLI also keeps a fallback where the payer sends the burn transfer itself.
  • Serving the API is separated from final settlement. The payment becomes accepted the moment the burn is confirmed on-chain, and the provider returns the resource. In the background the flow then waits for the zERC20 proof, and becomes finalized once the mint to the treasury lands. By default, serving does not wait for the zERC20 stack's indexer to finish ingesting — see docs/SETTLEMENT-TIMING.md (acceptOnReceipt).
  • The provider can mint several payments together later. The default is the single-payment path that mints to the treasury per API payment. Optionally (experimental), several API payments can be accepted together and minted in one go at a time convenient for the provider (the batch path). Batch, too, uses a fresh burn address per payment — the same as the single path — so an observer cannot tie the N payments together as one group on-chain; the only thing they share is a recipient value that appears nowhere in the burns and, without the secret, ties to none of them. That burns and mints don't link is also no different from the single-payment case. The remaining difference is on the mint side only: batch mints the total in one transaction, so amount / timing coincidences leave more room to guess the grouping than N single mints would (a property that remains as the price of that efficiency). Fresh addresses also do not add traffic: shared address or not, SubEtha needs a server-issued challenge per payment (the burn-bind nonce), so one payment is one 402 challenge, one set of signatures (permit + burn-bind) and one burn transfer either way — reusing an address would not let a client skip that exchange. The only additions are on the provider's side, off-chain — deriving each address, plus a settle-time lookup per burn address — invisible to the payer and lightweight.

Privacy (why the burn and the mint don't link)

This section makes the approach's claim — that which payer's burn corresponds to which provider's mint cannot be read on-chain — precise at the level of what actually remains there. The burn address is derived as:

burnAddress = truncate160(Poseidon("burn", recipientFr, secret)), recipientFr = SHA256(chainId ‖ recipient ‖ tweak).

Here recipient is the receiving (treasury) address; tweak and secret are values derived only on the provider side. The secret never appears on-chain — it is what breaks the link. The tweak never appears on the burn side; it does appear, together with recipient, in the calldata of the mint-time Verifier call, but an observer who doesn't know the secret cannot reconstruct any burn address from it. On both paths the burn address is fresh for every payment: the single path varies the tweak (and the secret) each time, while the mint-several-together batch path keeps the tweak fixed and varies the secret each time.

  • The receiver cannot be derived from the burn. On-chain, the burn is recorded as an event of the same shape as an ordinary transfer (IndexedTransfer(payer → burnAddress)). The burn address is an opaque hash: an observer who doesn't know the secret cannot work back to the treasury. Because the derivation inputs change per payment (see above), burn addresses don't link to each other either — on the single path and the batch path alike.
  • The mint doesn't reveal which burn. The mint-time ZK proof's public signals are [transferRoot, recipient(hash), totalValue]; which burn / leaf was spent is a private witness. The Verifier call's calldata additionally carries the receiver information (chainId, recipient, tweak) used to verify the recipient hash — but nothing that points at a burn. So a specific payer's burn and a specific treasury's mint cannot be linked on-chain.
  • What's hidden is the correspondence, not the receiver. The treasury address does appear on-chain at mint time. What the mechanism guarantees is that "the payer is not shown the receiver in advance, and no sender↔receiver link remains on-chain" — not that "the receiver stays hidden forever".
  • Anonymity-set caveat. While payments are few, timing / amount coincidences can still suggest which burn fed which mint. Unlinkability only becomes strong when many payments mix.

This unlinkability is independent of the gasless choice (permit mode or self-transfer); neither choice weakens it.

Layout

packages/
  facilitator/      SettlementBackend interface (the pluggable settlement boundary)
  zerc20-backend/   OfficialSettlementBackend — wraps @zerc20/sdk (createChallenge/collect/finalize),
                    proof-gated mint (default: Verifier.singleTeleport; optional / experimental: batch (Nova) teleport);
                    confines the SDK + Node/wasm specifics
apps/
  demo-official/    in-process x402-style e2e on the official stack (CLI)
  web/              Next.js UI → API proxy → zk-service (tsx) → demo-official backend; Guided 4-step UX
contracts/          SubEtha's own PermitBurner.sol (the gasless helper for permit mode, Apache-2.0);
                    includes the Foundry tests and ADR-001 (the design decision)
tests/              vitest unit/integration suite
docs/               design notes (SETTLEMENT-TIMING, RUNNING-THE-STACK, …)

@zerc20/sdk is referenced as link:../../../zerc20-work/sdk, pointing at the SDK inside a local checkout of the official toolchain (anvil + contracts + indexer + decider). The SDK source is not included in this repository (no vendoring, no submodule) — see License for why.

Running the demo

Prerequisites: the official zERC20 stack must be running locally at ../zerc20-work (a sibling of this repo: anvil on :8545 + indexer on :8080 + decider on :8081 + the deployed official contracts, with the payer pre-funded with zERC20). To build that stack from scratch, see docs/BUILDING-THE-STACK.md; for starting / restarting the services and deploying SubEtha's PermitBurner, see docs/RUNNING-THE-STACK.md.

One-shot: let Claude Code bring the demo up

Bringing the official stack up by hand takes a few steps (several services to start or restart). Open this repo in Claude Code and paste the following — Claude Code does the rest:

Bring up the SubEtha demo environment and run the end-to-end demo to completion in gasless
permit mode. Read docs/RUNNING-THE-STACK.md as the source of truth, then: check the official
zERC20 stack (anvil :8545 / indexer :8080 / decider :8081 / Postgres ×2 at ../zerc20-work) —
if any service is down, restart it with the start commands in that doc; if the stack
checkout itself is missing, build it first per docs/BUILDING-THE-STACK.md. Run `pnpm install && pnpm -r build` and
`pnpm --filter @subetha/demo-official run deploy-check` to confirm prerequisites. Then deploy
the gasless helper as in RUNNING-THE-STACK.md:
`cd contracts && forge soldeer install` (first time), then `forge script
script/DeployPermitBurner.s.sol --rpc-url http://127.0.0.1:8545 --broadcast --sig 'run()'`
(with PRIVATE_KEY=acct#0 and RELAYER_ADDRESS=acct#1) and note the deployed address. Drive one
payment with `PERMIT_MODE=true PERMIT_BURNER_ADDRESS=0x<deployed> pnpm --filter
@subetha/demo-official run run` (the transfer-root prove takes ~1-2 min; success = "✅
END-TO-END SUCCESS", "✓ gasless: payer native balance unchanged", and treasury balance +1234).
Optionally also start the browser demo with `PERMIT_BURNER_ADDRESS=0x<deployed> pnpm --filter
@subetha/web run demo` (http://127.0.0.1:3000 — the web demo is permit-only and errors on
connect if the address is unset). First press "Connect local stack" in the Environment zone (a
prerequisite check — it doesn't deploy or move funds), then the UI is a Guided 4-step journey
where you press one button per step and each is labeled with its actor: ① Call API [Client] → 402, ② Sign payload
[Client] (permit + burn-bind, 0 gas — no resource yet), ③ Broadcast burn & serve [Provider/
facilitator] (the resource is served only after the burn is confirmed), ④ Settle / mint
[Provider] (proof-gated mint; press once and it auto-completes when the ~40–90s proof lands).
Below the steps, a "who paid the treasury?" payoff (treasury grows while traceable inflows stay 0).
Note: if `../zerc20-work` and anvil are still up from a prior run, PermitBurner is usually
already deployed (e.g. `0x851356ae760d987e095750cceb3bc6014560891c`) — reuse that address
instead of redeploying. This is a local, non-production PoC — @zerc20/sdk is a local
link reference (BUSL-1.1) and PermitBurner is SubEtha's own Apache-2.0 contract.
Publishing this SubEtha module as source was acknowledged as OK by the zERC20 team,
but a public hosted demo and any commercial / production use still require a
separate grant from the zERC20 licensor (see the License section). If anything
fails, use the deploy-check output to diagnose.

Instead of hard-coding everything here, the prompt points Claude Code at docs/RUNNING-THE-STACK.md (service start commands, ports, the PermitBurner deploy steps, latency tuning) as the source of truth, so it stays correct as the environment evolves.

Manual: run the steps yourself

pnpm install
pnpm -r build

# Deploy the gasless helper (SubEtha's own PermitBurner, Apache-2.0) to the local anvil.
# One-time. Details in docs/RUNNING-THE-STACK.md. PRIVATE_KEY=acct#0, RELAYER_ADDRESS=acct#1.
cd contracts && forge soldeer install                  # first time only (OZ + forge-std)
PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
RELAYER_ADDRESS=0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
  forge script script/DeployPermitBurner.s.sol --rpc-url http://127.0.0.1:8545 --broadcast --sig 'run()'
cd ..   # note the "PermitBurner deployed at 0x…" address → use it below

# CLI: one private x402-style payment, end-to-end and gasless (the root prove takes ~1–2 min)
pnpm --filter @subetha/demo-official run deploy-check   # fail-fast check of the prerequisites
PERMIT_MODE=true PERMIT_BURNER_ADDRESS=0x<deployed> \
  pnpm --filter @subetha/demo-official run run

# Browser demo (same flow, gasless). Guided 4-step UI (Call/Sign are [Client],
# Broadcast&Serve/Settle are [Provider]) plus a "who paid the treasury?" visualization. Permit-only — needs the address.
PERMIT_BURNER_ADDRESS=0x<deployed> pnpm --filter @subetha/web run demo   # → http://127.0.0.1:3000

CLI fallback: to verify without the helper, the self-transfer path — the payer pays its own gas — is also available: run pnpm --filter @subetha/demo-official run run without PERMIT_MODE. The web demo is permit-only.

Batch (Nova) path: to try the optional / experimental path that mints several payments in one teleport, you need the heavier withdraw_local decider setup (≈8 GB RAM). See docs/RUNNING-THE-STACK.md for the steps.

Successful CLI output (excerpt, gasless mode):

✓ F1: treasury / fullBurnAddress / generalRecipient absent from payer-facing 402
✓ gasless: payer native balance unchanged (…) — facilitator paid gas
✓ finalized: minted 1234 to treasury, tx=0x…
treasury balance: 0 → 1234 (delta=1234)
✅ END-TO-END SUCCESS — private x402 payment settled via official @zerc20/sdk.

For the full flow, the privacy details, and the deferred items, see apps/demo-official/README.md.

Tests

pnpm test     # builds the packages, then runs the vitest suite

What the suite covers:

  • The official backend state machine — challenge reservation / no double-accept, collect tx-integrity and indexer-ingest guards, finalize idempotency / no double-mint / timeout→failed, and serve timing (acceptOnReceipt).
  • Permit mode (gasless) — signature verification for permit + burn-bind, including rejection of amount mismatches, contract-wallet payers, and stale nonces.
  • Batch (Nova) — unit tests for collectBatch / proveBatch, plus the batch-session lifecycle (N collects → one teleport, settle guards and idempotency, journal recovery after a crash).
  • Web integration — the pay → accepted → finalized flow, stripping of receiver-revealing fields, finalize only on a matching burn address, and the Guided 4-step step states.
  • The rest — config env resolution, SettlementBackend interface conformance, and the demo HTTP services' resilience to bad input.

What SubEtha keeps from / defers vs. the official zERC20 design

  • Kept (proven on the official toolchain): proof-gated mint (singleTeleport), sender-initiated payment advice (the provider derives the advice; the payer transfers to the burn address), anti-double-spend via the verifier's cumulative totalTeleported, and sender↔receiver unlinkability.
  • Permit mode (the main path): the official token has no EIP-3009 (x402's usual gasless path), so SubEtha uses EIP-2612 permit + an EIP-712 burn-bind authorization. The payer only signs (no tx, 0 gas) and SubEtha's own PermitBurner helper broadcasts the burn. The CLI also keeps a self-transfer fallback that runs without the helper. See apps/demo-official.
  • Deferred: wrap/LiquidityManager (not deployed locally; the payer uses pre-minted test tokens, and there is no faucet / mintForDemo), and MPP for the official path.
  • Experimental (proven end-to-end): batch (Nova) redeem — collectBatch/ proveBatch/submitTeleport are implemented and unit-tested, and verified live against the official Verifier.teleport: N gasless permit calls — each at its own, mutually unlinkable burn address sharing one recipient fr — settle in one teleport, treasury delta == N×amount — confirmed via both the CLI run-batch-session and the web demo's batch session panel. proveBatch drives the wasm Nova prover directly to bypass two upstream @zerc20/sdk bugs — the verifyTeleportProofs leaf-hash arity bug (poseidon2(to, value) vs the circuit's poseidon3(from, to, value)) and the appendDummySteps bug that pads local-scope dummy steps to the global tree height (46 vs 40). It stays experimental because it needs the heavier withdraw_local decider setup (≈8 GB) — the only thing the circuit requires a batch to share is the recipient fr; the burn address can change per payment. The default settlement path remains single-teleport. For the direct-transfer demo without a session, see apps/demo-official/src/run-batch.ts.

For how SubEtha maps onto the official zERC20 architecture, see docs/SPEC-COMPARISON.md; for the big picture (the what and the why), see docs/OVERVIEW.md; for a hands-on tutorial, see docs/TUTORIAL.md; for the optional facilitator fee (who pays, and how it is collected safely), see docs/FEES.md; for paying from an AI agent (the MCP server + CLI, with a spending policy), see docs/AGENT-TOOLS.md; for running a facilitator as a service (config-file daemon with an automatic finalize loop), see docs/FACILITATOR.md; for paying from Python (the subetha package), see python/README.md.

License

SubEtha's own code is Apache-2.0 — it covers the SubEtha-authored sources under apps/, packages/, and contracts/ (including PermitBurner.sol). See LICENSE / NOTICE.

SubEtha depends on @zerc20/sdk (BUSL-1.1 / Business Source License 1.1), and that SDK is not Apache-2.0. The dependency is referenced as a local link; the SDK source is not vendored, copied, or redistributed in this repository.

Commercial / production use requires a separate grant from the zERC20 licensor. Publishing this SubEtha module as source was acknowledged as OK by the zERC20 team, but that is not a production or commercial grant — and a public hosted demo is neither publishing-as-source nor a local PoC, so it also needs a separate grant. SubEtha's Apache-2.0 license covers SubEtha's own code only, and grants no right to use @zerc20/sdk or the zERC20 toolchain in production or commercially — BUSL-1.1's Additional Use Grant is None, so production / commercial use of the zERC20 dependency needs a commercial license obtained directly from the zERC20 licensor. Copying SubEtha does not change this: the zERC20 dependency still requires that grant.

Name / official-status notice. The name "SubEtha", and any claim of official partnership, certification, or "official Facilitator" status, are not granted by this license. Do not represent a fork or derivative as the official SubEtha or as officially endorsed by the zERC20 team.

This is a summary, not legal advice. For commercial terms, consult the zERC20 licensor and your own counsel.