Overview#
An agent asks for a paid resource and gets 402 Payment Required with a Zcash option in accepts[]. It pays the merchant's shielded address with the challenge's nonce in the encrypted memo, then retries with X-PAYMENT naming the txid. The chain shows neither the amount, the memo nor either party.
Client agent or any wallet
- holds
- its own spending key, the spendguard policy and ledger
- never
- the merchant's keys
Resource server x402z-server
- holds
- the merchant's shielded UA, the receipt public key, a SQLite challenge book
- never
- any Zcash key
Facilitator x402z-facilitator
- holds
- the merchant's unified incoming viewing key (UIVK), an ed25519 receipt key
- never
- a spending key or a full viewing key
lightwalletd testnet.zec.rocks
- holds
- the public chain
- never
- anything shielded
Prior art. coinbase/x402 v1 defines the 402 body, X-PAYMENT and the facilitator endpoints for EVM payments; Kage keeps that wire format and adds a zcash-shielded scheme. A Zcash Community Grants application, "Shielded x402" (forum topic 57826, posted 2026-09-25 by the forum user baby-zack-agent), proposed private machine payments over x402 and was rejected on 2026-09-28. That application is another applicant's: Kage is a separate build by Nivesh Gajengi and shares none of its code. Kage adds a facilitator that holds only the incoming viewing key, spendguard before the wallet, Kage Tab blind-RSA batching, and ten live testnet payments anyone can check.
Quickstart#
A Rust workspace pinned to toolchain 1.89.0. Run the tests one crate at a time; the Zcash crates are large.
cargo test -p spendguard -p x402z-protocol -p x402z-server # 16 + 14 + 42 cargo test -p x402z-agent # 32 cargo test -p x402z-mcp # 7, drives the binary over stdio cargo test -p x402z-facilitator # 17: 6 build Halo2 proofs, 3 hit testnet cd packages/x402-zcash && npm test # 11: TypeScript, 2 hit the binaries and testnet
Nothing is mocked. Without a key, a wallet or funds, each binary stops with a readable error and a non-zero exit code:
$ target/debug/x402z-facilitator ZCASH_LWD_URL not set; using https://testnet.zec.rocks:443 error: X402Z_MERCHANT_UIVK not configured (see .env.example) $ echo $? 2 $ X402Z_AGENT_DAILY_CAP_ZAT=50000 target/debug/x402z-agent 402 challenge: pay 100000 zat to utest100fnpp4zd4lp0v… memo x402z-00fc4d5bdee5d4ba0609bd04 (expires in 900s) error: spendguard refused the payment: daily cap exceeded: spent 0 + requested 100000 > cap 50000 zat. No transaction was built or broadcast. $ echo $? 3
After a cargo build, cd e2e && npm ci && npm run stack runs the whole stack locally with the published demo UIVK and no wallet (dashboard on 127.0.0.1:4120; npm run test:e2e then drives it in Google Chrome), then pay the QR from any testnet wallet. A paid agent run needs a funded testnet wallet for the agent, a merchant UA and UIVK, a receipt key pair from x402z-facilitator keygen and a shared X402Z_FACILITATOR_TOKEN. The README walks through it; x402z-server serves the paid API on 127.0.0.1:4020 and the dashboard on a separate loopback port, 127.0.0.1:4029.
The 402#
The body follows coinbase/x402 v1: {x402Version: 1, accepts: [PaymentRequirements], error}. The Zcash entry:
| Field | Value | Notes |
|---|---|---|
scheme / network | zcash-shielded / zcash-testnet | Mainnet challenges are refused by the protocol crate. |
maxAmountRequired | zatoshi, as a string | 1 ZEC = 108 zat. The demo price is 100,000 zat. |
payTo | unified address | Shielded receivers only. A UA with a transparent receiver is refused. |
extra.memoNonce | x402z-<24 hex> | Single use. Goes into the payment's encrypted memo as text. |
extra.expiresAt | Unix seconds | maxTimeoutSeconds is 900. Persisted in SQLite, so a restart keeps it. |
extra.paymentUri | ZIP 321 URI | For any wallet; see ZIP 321. |
extra.tab | optional | The Kage Tab offer. |
Each client IP gets a token bucket of X402Z_CHALLENGE_RATE_PER_MIN challenges and redeem attempts (default 30). Over it: 429 with Retry-After.
spendguard#
The agent checks every payment before it touches the wallet. Rules run in order, and the first denial wins:
- Host allowlist. Deny by default. Exact host or
*.suffix;*.data.testmatchesx.data.test, notevildata.test. An entry with a port (127.0.0.1:4020) or a scheme (https://…) allows only that. Checked before the agent's first request, with plain HTTP to a non-loopback host refused. - Amount > 0.
- Daily cap per agent and UTC day, inclusive, with checked arithmetic: an overflow is a denial.
- Approval threshold, inclusive. The CLI asks the operator; the MCP server always declines.
A denied payment builds no transaction. Two agent runs at once take turns on a file lock, so they can't both pass the cap: a test runs six at once against room for one payment, and exactly one pays.
X-PAYMENT#
The client retries with X-PAYMENT: base64(json). The payload names the challenge as well as the txid, because a shielded transaction gives the server nothing to look up. The facilitator then has to find that same nonce inside the encrypted memo, so a payment can't be pointed at another challenge.
{ "x402Version": 1, "scheme": "zcash-shielded", "network": "zcash-testnet",
"payload": { "txid": "<64 hex>", "memoNonce": "x402z-…" } }Wallets that can't set headers use POST /api/redeem {memoNonce, txid}. It runs the same checks.
Facilitator#
GET /supported, POST /verify (read-only), POST /settle and GET /health. For each payment it:
- fetches the transaction with lightwalletd
GetTransactionand checks its txid; - parses it at the consensus branch of its mined height;
- trial-decrypts every Sapling, Orchard and Ironwood output with the merchant UIVK;
- sums the outputs whose memo is the nonce and whose recipient is the matching
payToreceiver; - reads the mined block's header time and checks it against
expiresAtplus a 1,200 s grace.
It refuses: no matching output, a wrong or sibling diversified recipient, underpaid, mined after expiry plus grace, a foreign payTo, a payTo with a transparent receiver, and a nonce already bound to another txid (kept in SQLite across restarts). It then signs an ed25519 receipt: base64url(claims) "." base64url(sig) with minedAt from the block header.
Settlement#
The server checks the receipt against X402Z_RECEIPT_PUBKEY and the stored challenge, burns the nonce once in SQLite, and answers:
| Outcome | Status | X-PAYMENT-RESPONSE |
|---|---|---|
| Paid | 200 | {success: true, transaction, network, payer: ""} |
| Not confirmed yet | 402 | settlement_pending, the same challenge, nothing burned |
| Refused | 402 | invalid_payment, payment_expired, nonce_already_used, unknown_challenge, invalid_payload, invalid_network, invalid_scheme; a fresh challenge |
| Facilitator down | 502 | facilitator_error, nothing burned |
Testnet blocks come about every 75 s, sometimes much slower. The default is one confirmation, and the agent retries every 15 s for up to 900 s.
Any wallet (ZIP 321)#
extra.paymentUri is a ZIP 321 URI. librustzcash's own parser reads it back to the same address, amount and memo. The dashboard's 402 card renders its QR locally at /api/challenge/{nonce}/qr.svg, with copy, Open in wallet and a paste txid box that answers paid, pending (re-checked every 15 s) or refused.
zcash:utest100fnpp4…crzs?amount=0.001&memo=eDQwMnotNjk2YzU4YWZkNTA5Y2MwZjg3NTNmM2Iw
Run live on 2026-10-21: zingo-cli paid the QR's address, amount and memo, the txid was pasted, and /api/redeem answered paid; a second redeem answered nonce_already_used (tx ccb4214c…0ed3).
MCP server#
x402z-mcp speaks MCP over stdio (JSON-RPC 2.0, protocol 2025-06-18) with the agent's own configuration.
| Tool | Does |
|---|---|
paid_fetch {url} | The agent's x402 flow. Returns settlement_pending with the txid right after the broadcast (a block takes about 75 s; MCP clients time out at about 60 s); calling it again for the same URL never pays twice. Refusals, wallet errors, rejected payments and missing config come back as isError results. |
redeem_payment {url, txid, memoNonce} | Presents an earlier payment once with X-PAYMENT. Never pays. |
tab_fetch {url, buy?} | Spends one Kage Tab token. With none left, it buys a tab of buy tokens from the agent's wallet inside spendguard first, and collects it on a later call without paying again. |
spend_status {} | Spent today against the cap, the allowlist and the threshold. |
ledger_tail {n} | The last n spendguard decisions. |
[2] kage_paid_fetch {"url": "http://127.0.0.1:4220/api/quote"} "status": "settlement_pending", "txid": "f037d5978fec…d4e22a", [3] kage_redeem_payment {"url": …, "txid": "f037d5978fec…", "memoNonce": "x402z-ca140754…"} "settlement": {"network": "zcash-testnet", "payer": "", "success": true, …}, "status": 200 [4] kage_tab_fetch {"buy": 3, "url": …} "status": "tab_pending", "txid": "b75b2415…4b91" … four retries collect the same paid tab, none pays again … [8] kage_tab_fetch {"url": …} "status": 200, "remainingTokens": 2
Lines excerpted from the full transcript, where … marks a cut. The model chose every call; the agent paid from its own wallet (txs f037d597…e22a and b75b2415…4b91). An earlier LLM run shows a spendguard refusal (refusal transcript).
Kage Tab#
One shielded payment buys N tokens, each spent on one later request with X-PAYMENT scheme zcash-shielded-tab. The server can't link a token to the payment or to its siblings: RFC 9474 blind RSA (RSABSSA-SHA384-PSS-Randomized, RSA-2048), with a SQLite spent set.
The agent side: x402z-agent tab-buy <n> pays from the agent's wallet, or tab-buy <n> --external prints a ZIP 321 request for any wallet followed by tab-redeem <txid>. Then tab-fetch spends one token per request; the MCP tool tab_fetch does the same, and an LLM bought and spent a tab with it on Oct 22. Spendguard checks the whole tab price before anything is paid, and the agent refuses a different key from the same server within one key epoch.
$ x402z-agent tab-buy 5 --external spendguard: allowed 500000 zat (0 of 1000000 zat already spent today) # zingo-cli pays the ZIP 321 request: tx fa011720…c3ad, block 4,526,095 $ x402z-agent tab-redeem fa01172015c63ce47a0285f7fae130a3efe5f821b938a8befa1ca547d00ac3ad {"tab":{"amountZat":500000,…,"keyId":"d3e83a274b8e8fc7",…,"tokens":5,…}} $ x402z-agent tab-fetch # ×5 X-PAYMENT-RESPONSE: success=true transaction=tab:5ae308b4e401aac9 served 200 in 0 ms with a tab token; 4 token(s) left … served 200 in 0 ms with a tab token; 0 token(s) left # a copy of an already spent token, sent again error: server refused the tab token (402 Payment Required, nonce_already_used): tab token already spent
Lines excerpted from the full transcript, where … marks a cut. Each token call was a localhost round trip under 1 ms, about 18 ms per CLI process, with no new transaction. A second tab of 3 (tx d3bf0b49…d00d) was spent through MCP tab_fetch by a Python stdio script. The fourth call found no token left, and spendguard refused a new tab over the daily cap.
--external; the third (Oct 22) from the agent's own wallet. Tokens are bearer secrets, sealed on disk with the ledger key.What still leaks#
- The client's IP reaches the merchant. The paid request is ordinary HTTP; use Tor or a proxy if it matters.
- lightwalletd sees which txid is looked up, and when.
X-PAYMENTis a bearer credential: whoever presents a paid (nonce, txid) first gets the content, and the server serves the same pair again for 10 minutes so a lost 200 is not a refusal. The server's feed lives on a loopback admin port that answers only to its own Host, and shows only a nonce prefix next to a txid.- Kage Tab unlinkability assumes one key per epoch for every buyer; the agent pins the key it saw, but can't see what other buyers got.
- The agent ledger and tab tokens are sealed at rest (XChaCha20-Poly1305); anyone with the ledger key or the agent seed can read them.
- Timing. The 402 and the next block can be linked by time, even though the transaction reveals nothing.
Also: testnet only, pre-release librustzcash crates (NU7 is live on testnet), not audited.
Live evidence#
Ten payments went end to end against testnet.zec.rocks, each an Ironwood note to the same demo merchant: seven single payments of 100,000 zat and three Kage Tabs. From Oct 21 on, the runs used x402 v1, a facilitator holding only the published UIVK, and SQLite state. The two Oct 22 payments came from the agent's own wallet, chosen by an LLM over MCP.
| Txid | Block | Date | Path |
|---|---|---|---|
| 030c3cbf…8351 ↗ | 4,521,112 | Oct 20 | Agent's own wallet, first paid run (earlier receipt header) |
| cd64980a…916d ↗ | 4,521,440 | Oct 20 | Agent's own wallet (earlier receipt header) |
| ccb4214c…0ed3 ↗ | 4,524,586 | Oct 21 | ZIP 321 QR paid by zingo-cli, txid pasted, /api/redeem → paid |
| 25a8b324…689d ↗ | 4,524,602 | Oct 21 | X-PAYMENT → settlement_pending ×10 → 200 (the exchange above) |
| 408aba26…8dd0 ↗ | 4,524,693 | Oct 21 | X-PAYMENT path, repeated |
| a7c3b0b2…38bc ↗ | 4,524,694 | Oct 21 | ZIP 321 + paste txid, repeated |
| fa011720…c3ad ↗ | 4,526,095 | Oct 21 | Kage Tab: 500,000 zat → 5 tokens → 5 calls (CLI) |
| d3bf0b49…d00d ↗ | 4,526,113 | Oct 21 | Kage Tab: 300,000 zat → 3 tokens → 3 MCP tab_fetch calls (scripted) |
| f037d597…e22a ↗ | 4,529,425 | Oct 22 | LLM over MCP, agent's own wallet: paid_fetch → redeem_payment → 200 |
| b75b2415…4b91 ↗ | 4,529,427 | Oct 22 | Kage Tab, LLM over MCP, agent's own wallet: 300,000 zat → 3 tokens → 3 calls |
Anyone can check them with the demo merchant's published incoming viewing key (in the README). It shows incoming payments to that demo wallet only; it can't spend and shows nothing about the payer.
$ cargo run --release -p x402z-facilitator --example check_uivk -- "$uivk" 030c3cbf…8351 x402z-f19a63da20cbc0749ea800b3 tx 030c3cbf…8351: mined at 4521112, 20 confirmation(s) (tip 4521131) incoming 100000 zat via Ironwood, memo text "x402z-f19a63da20cbc0749ea800b3" total to merchant with memo "x402z-f19a63da20cbc0749ea800b3": 100000 zat
Tests#
139 tests: 128 in Rust, 11 in TypeScript. Six build in-process transactions with real keys, real note encryption and real Halo2 proofs for the Orchard/Ironwood parts (Sapling outputs use sapling-crypto's mock proof bytes); those are not broadcast. Five reach live testnet: three verify the ten real payments with only the UIVK, and two run the TypeScript client and middleware against the real binaries and testnet. A Playwright suite drives the dashboard and this page in Google Chrome. The full list is in TESTING.md.
| Crate | Tests | Runs against |
|---|---|---|
spendguard | 16 | pure logic (allowlist ports and schemes), sealed ledger format |
x402z-protocol | 14 | the x402 spec's own examples, ZIP 321's memo example |
x402z-server | 42 | in-process, incl. 6 Kage Tab attack tests, the X-PAYMENT budget, a lost 200 served again, the admin Host check, an https resource behind a TLS proxy |
x402z-agent | 32 | file-locked sealed ledger, six concurrent runs, racing calls for one URL or tab, a crash mid-broadcast, lost answers, the allowlist before any request, redirects, Tab client and key rules against a localhost RFC 9474 issuer |
x402z-mcp | 7 | the real binary over stdio, incl. resume instead of a second payment |
x402z-facilitator | 17 | 6 Halo2 transactions in-process, 3 live on Zcash testnet, bearer auth, cached health |
x402-zcash | 11 | TypeScript; 2 against the real binaries and testnet; paywall limits in-process |