Kage 影 x402 v1 · zcash-testnet
scheme: zcash-shielded

The spec, the runs, the evidence.

Kage lets AI agents pay for APIs in the x402 v1 wire format, settled as Zcash shielded testnet transactions. spendguard checks every payment before a transaction exists. The merchant's facilitator verifies it with an incoming viewing key alone: no spend key, no view of the merchant's own spends.

git clone https://github.com/nickthelegend/zec-shielded-x402 && cd zec-shielded-x402 && cargo test -p x402z-server

42 server tests, including the Kage Tab attacks, the public/admin port split and the admin port's Host check. A first build took about 2 minutes on a laptop at 2 build jobs, with no Zcash crates involved. The agent and MCP crates compile librustzcash, which took about 4 more minutes. This page is docs and recorded output. A live testnet 402 answers at kage-api…/api/quote (not paid yet; any testnet wallet can pay its ZIP 321 URI), and the Quickstart runs the whole stack on your machine.

10paid testnet txs, verifiable with a published UIVK; 2 paid by an LLM
139tests, 5 against live testnet
0spend keys on the merchant side
recorded run · 2026-10-22 15:34:30–15:36:22 UTC LLM over MCP

An LLM (opencode, nemotron-3-ultra) calling x402z-mcp; the agent pays from its own wallet. Tool calls in order, from docs/mcp-paid-run.jsonl.

  1. 15:34:30callspend_status → cap 600000 zat, spent 0, allowedHosts ["127.0.0.1:4220"]
  2. 15:34:47402paid_fetch /api/quote → "status":"settlement_pending", "txid":"f037d597…e22a", 100000 zat
  3. 15:35:18200redeem_payment f037d597…e22a → {"success":true,"transaction":"f037d597…e22a","payer":""}
  4. 15:35:31202tab_fetch {"buy":3} → "status":"tab_pending", "txid":"b75b2415…4b91", 300000 zat
  5. 15:35:35202tab_fetch → tab_pending, the same txid b75b2415…4b91 (not paid again)
  6. 15:35:39202tab_fetch → tab_pending, the same txid b75b2415…4b91 (not paid again)
  7. 15:35:43202tab_fetch → tab_pending, the same txid b75b2415…4b91 (not paid again)
  8. 15:36:08200tab_fetch → {"success":true,"transaction":"tab:f64cf56451454ddc","payer":""}, 1 ms, 2 tokens left
  9. 15:36:12200tab_fetch → "tab:268c28cae693f124", 1 ms, 1 left
  10. 15:36:14200tab_fetch → "tab:3dc634365b4e2de9", 0 ms, 0 left
  11. 15:36:22callledger_tail → 2 payments: f037d597… 100000 zat settled, b75b2415… 300000 zat

Shortened here: txids, and the fields each call returned. Unedited events: mcp-paid-run.jsonl. The other tabs replay one X-PAYMENT exchange step by step.

tx f037d597…e22a ↗ block 4,529,425 payer: the agent's own wallet, chosen by the LLM
docs / overview

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.

docs / quickstart

Quickstart#

A Rust workspace pinned to toolchain 1.89.0. Run the tests one crate at a time; the Zcash crates are large.

shell
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:

zsh · 2026-10-21no .env, then cap below the price
$ 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.

protocol / payment required

The 402#

The body follows coinbase/x402 v1: {x402Version: 1, accepts: [PaymentRequirements], error}. The Zcash entry:

FieldValueNotes
scheme / networkzcash-shielded / zcash-testnetMainnet challenges are refused by the protocol crate.
maxAmountRequiredzatoshi, as a string1 ZEC = 108 zat. The demo price is 100,000 zat.
payTounified addressShielded receivers only. A UA with a transparent receiver is refused.
extra.memoNoncex402z-<24 hex>Single use. Goes into the payment's encrypted memo as text.
extra.expiresAtUnix secondsmaxTimeoutSeconds is 900. Persisted in SQLite, so a restart keeps it.
extra.paymentUriZIP 321 URIFor any wallet; see ZIP 321.
extra.taboptionalThe 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.

protocol / client policy

spendguard#

The agent checks every payment before it touches the wallet. Rules run in order, and the first denial wins:

  1. Host allowlist. Deny by default. Exact host or *.suffix; *.data.test matches x.data.test, not evildata.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.
  2. Amount > 0.
  3. Daily cap per agent and UTC day, inclusive, with checked arithmetic: an overflow is a denial.
  4. 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.

protocol / retry

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.

X-PAYMENT, decoded
{ "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.

protocol / verification

Facilitator#

GET /supported, POST /verify (read-only), POST /settle and GET /health. For each payment it:

  1. fetches the transaction with lightwalletd GetTransaction and checks its txid;
  2. parses it at the consensus branch of its mined height;
  3. trial-decrypts every Sapling, Orchard and Ironwood output with the merchant UIVK;
  4. sums the outputs whose memo is the nonce and whose recipient is the matching payTo receiver;
  5. reads the mined block's header time and checks it against expiresAt plus a 1,200 s grace.
Least privilege, tested. A test builds the merchant's own change and outgoing outputs with the open nonce in every memo. The full viewing key sees all four outputs; the UIVK sees none, so none can be credited.

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.

protocol / response

Settlement#

The server checks the receipt against X402Z_RECEIPT_PUBKEY and the stored challenge, burns the nonce once in SQLite, and answers:

OutcomeStatusX-PAYMENT-RESPONSE
Paid200{success: true, transaction, network, payer: ""}
Not confirmed yet402settlement_pending, the same challenge, nothing burned
Refused402invalid_payment, payment_expired, nonce_already_used, unknown_challenge, invalid_payload, invalid_network, invalid_scheme; a fresh challenge
Facilitator down502facilitator_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.

surfaces / any wallet

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.

the recorded challenge's URI (address shortened)
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).

surfaces / llm agents

MCP server#

x402z-mcp speaks MCP over stdio (JSON-RPC 2.0, protocol 2025-06-18) with the agent's own configuration.

ToolDoes
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.
opencode · nemotron-3-ultra:free · 2026-10-22agent's own wallet
[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).

surfaces / batching

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 · testnet · 2026-10-211 tx → 5 calls
$ 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.

Limits. A token hides only among the tokens issued under the same key (7-day epochs by default), and only if the merchant gives every buyer that same key; spending them in a burst links them by time. The runs used one machine. Those two tabs were paid from a zingo-cli demo wallet through --external; the third (Oct 22) from the agent's own wallet. Tokens are bearer secrets, sealed on disk with the ledger key.
trust / limits

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-PAYMENT is 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.

trust / on-chain

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.

TxidBlockDatePath
030c3cbf…8351 ↗4,521,112Oct 20Agent's own wallet, first paid run (earlier receipt header)
cd64980a…916d ↗4,521,440Oct 20Agent's own wallet (earlier receipt header)
ccb4214c…0ed3 ↗4,524,586Oct 21ZIP 321 QR paid by zingo-cli, txid pasted, /api/redeem → paid
25a8b324…689d ↗4,524,602Oct 21X-PAYMENT → settlement_pending ×10 → 200 (the exchange above)
408aba26…8dd0 ↗4,524,693Oct 21X-PAYMENT path, repeated
a7c3b0b2…38bc ↗4,524,694Oct 21ZIP 321 + paste txid, repeated
fa011720…c3ad ↗4,526,095Oct 21Kage Tab: 500,000 zat → 5 tokens → 5 calls (CLI)
d3bf0b49…d00d ↗4,526,113Oct 21Kage Tab: 300,000 zat → 3 tokens → 3 MCP tab_fetch calls (scripted)
f037d597…e22a ↗4,529,425Oct 22LLM over MCP, agent's own wallet: paid_fetch → redeem_payment → 200
b75b2415…4b91 ↗4,529,427Oct 22Kage 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.

check_uivk · 2026-10-20UIVK only
$ 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
trust / tests

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.

CrateTestsRuns against
spendguard16pure logic (allowlist ports and schemes), sealed ledger format
x402z-protocol14the x402 spec's own examples, ZIP 321's memo example
x402z-server42in-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-agent32file-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-mcp7the real binary over stdio, incl. resume instead of a second payment
x402z-facilitator176 Halo2 transactions in-process, 3 live on Zcash testnet, bearer auth, cached health
x402-zcash11TypeScript; 2 against the real binaries and testnet; paywall limits in-process