A Zcash payments stack for agents. It ships two binaries that split one
trust boundary: zpay-runtime, a facilitator speaking an
x402-style wire (/x402/v2/*) so agents and
merchants can charge for content in native ZEC, and zspend-runtime, a
wallet that signs agent spends under bounded, user-approved grants.
Registered payees own the offer terms, the wallet signs the spend, the
facilitator mediates the lifecycle and confirms on-chain.
The facilitator holds no funds and no spending keys: it brokers the
handoff and zinder handles
broadcast and confirmation against the chain. The wallet runtime holds
only its own sealed agent seed, never broadcasts, and signs exclusively
under a payment_authorization grant the user approved.
Integrating a payee or agent? Start at Wire surface and Integrating as a relying party. Operating a deployment? Start at Quick start, Deploy, and the runbooks.
The facilitator runs a payment through four typed stages:
- Advertise. An operator-registered payee TOML describes what each
(payee_id, scheme, network)accepts: recipient address, amount, expiry delta, validity window. - Prepare. An agent authenticated by a key-bound request proof
(DPoP) posts
(payee_id, scheme, network)to/x402/v2/prepare. The facilitator resolves the offer from the registry, derives expiry from a chain-tip oracle, composes a domain-separated structured memo (ZIP-302) server-side, and returns a payment URI (ZIP-321) plus a stablepayment_id. - Settle. The wallet signs and posts the transaction to
/x402/v2/settle; zpay checks the memo version and that the signed expiry height matches the prepared row, broadcasts through zinder, and records the outcome in libSQL. - Confirm. A background oracle and a live chain-event subscription
track confirmations and emit per-payment snapshots over Server-Sent
Events. Statuses can regress: a payment whose block is reorged away
returns to
broadcastuntil it re-mines or its expiry lapses. A payment is immutable only oncesettled, meaning its block sits at or below zinder's settled tip. See ADR-0009.
For receipts, POST /x402/v2/verify accepts a payment disclosure
(ZIP-311), runs the BIP-322 transparent
check in-process, and
reports a three-axis posture: cryptographic_verdict, chain_presence,
amount_reconciliation. Sapling shielded verification ships behind the
verify_sapling feature in a follow-on slice; today shielded disclosures
surface as Inconclusive { unsupported_pool }.
Every JSON response body is the bare inner type. Errors follow
RFC 7807 as application/problem+json.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /healthz |
none | Liveness; returns {"status":"alive"} |
| GET | /x402/v2/accepts?payee_id=… |
none | List the payee's accepted (scheme, network, pay_to, amount_zat) offers |
| GET | /x402/v2/tip?network=… |
none | Chain-tip height the prepare path uses for expiry math |
| POST | /x402/v2/prepare |
DPoP | Allocate a payment_id, return a ZIP-321 URI and memo bytes |
| POST | /x402/v2/settle |
DPoP | Broadcast a wallet-signed transaction; jkt must match the prepare proof |
| POST | /x402/v2/verify |
none | Verify a ZIP-311 disclosure; three-axis response |
| GET | /x402/v2/payments/{id} |
none | Snapshot with reorg_count and settled; status is awaiting, broadcast, mined, final, failed, never_issued, or expired |
| GET | /x402/v2/payments/{id}/events |
none | SSE stream of snapshots; closes once the payment is settled, expired, failed, or never_issued |
/prepare and /settle require a DPoP
proof signed by the caller's ES256 key. Idempotency is scoped by
(jkt, idempotency_key), so two callers sharing an Idempotency-Key
header receive distinct payment IDs. A second /settle from a different
jkt returns dpop_mismatch 403.
Requests are rate limited per DPoP key on authenticated routes and per
client IP elsewhere (ZPAY_RATE_LIMIT__PER_JKT_PER_MINUTE, default 120;
ZPAY_RATE_LIMIT__PER_IP_PER_MINUTE, default 600; 0 disables a
dimension). Over the limit, responses are 429 with a Retry-After
header. Forwarded-for headers are ignored unless
ZPAY_RATE_LIMIT__TRUST_FORWARDED_HEADERS is set behind a trusted
proxy. Cross-origin browser access is off unless
ZPAY_SERVER__CORS__ALLOWLIST names exact origins.
The reproducible path is the bundled Docker image:
# Bring up zpay and the zspend wallet runtime against testnet with the
# bundled aether-demo placeholder
docker compose up -d
curl -s http://127.0.0.1:8080/healthz
# {"status":"alive"}The compose file sets ZPAY_ALLOW_DEMO_PAYEE=1 because the bundled
etc/aether-demo.toml carries a placeholder recipient address. The
runtime refuses to start with the placeholder unless this flag is
explicitly set, so a production deploy that forgets to override the
payees file fails loud at boot.
Cargo run for local development:
ZPAY_NETWORK=testnet \
ZPAY_VERIFY__NETWORK=testnet \
ZPAY_ALLOW_DEMO_PAYEE=1 \
ZPAY_PAYEES__CONFIG_PATH=$(pwd)/etc/aether-demo.toml \
cargo run --release --bin zpay-runtimeThe HTTP listener binds on ZPAY_SERVER__BIND_ADDR (default
127.0.0.1:8080). The operations listener binds on
ZPAY_OPS__BIND_ADDR (default 127.0.0.1:9295) and serves /healthz,
/readyz (a live chain probe, a store probe, and chain-view freshness,
each reported per dependency), and Prometheus /metrics.
The expected flow for an agent or merchant BFF:
- Register the payee in your
payees.toml. Each entry carries one or moreaccepts[]templates describing(scheme, network, pay_to, amount_zat, max_validity_seconds, expiry_delta_blocks?). - Mint a DPoP ES256 keypair. Persist the seed in a stable secret so the JKT survives process restarts and idempotency keeps working.
- Compute a deterministic
idempotency_keyfrom the intent (user, task, item, amount). The facilitator resolves replays of the same key to the samepayment_id. POST /x402/v2/preparewith a DPoP header carryinghtm=POST,htu=https://your-zpay-host/x402/v2/prepare,iatwithin 60s, and a freshjti. The response is{ payment_id, payment_uri, memo_bytes, expiry_height, amount_zat }.- Hand
payment_urito the user's wallet for signing. The wallet posts the signed transaction back through your own surface; you forward it toPOST /x402/v2/settlewith a DPoP proof from the same keypair. - Subscribe to
GET /x402/v2/payments/{payment_id}/eventsto observe the lifecycle. Treatfinalas a confirmation-depth milestone, not settlement: a reorg can return aminedorfinalpayment tobroadcast, and the stream stays open until the payment issettled(or reachesexpired,failed, ornever_issued).
The aether scenario in zentity's demo relying party demonstrates the full path including a CIBA-bound user approval and a six-character phishing-prevention code derived from the URI.
zspend-runtime is the wallet service for autonomous agent payments. It holds
the spending seed (sealed at rest), syncs against zinder, and exposes
POST /v1/payments/sign. The facilitator's /settle is intent-blind behind a
shielded viewing key, so the wallet is the sole place the spend's intent is
checked. Before it signs, the wallet runs four checks:
- verifies a DPoP-bound
payment_authorizationaccess token, - pins the token's
audto this wallet instance, - re-derives the
intent_hashand matches it against the signed grant, - consults a revocation cache.
It then reserves the token jti in a durable single-use ledger,
write-then-sign, so a replay returns the cached payload and a
conflicting reuse is refused.
The wallet exposes /v1/payments/sign, /v1/wallet/address,
/v1/capabilities, /.well-known/wallet-configuration, Prometheus
/metrics, and a computed /readyz that reports JWKS reachability,
revocation-cache state, and the seed's sealing posture. See
Proposal-0003 for
the decision record, and the
Aether demo
for the end-to-end flow where an issuer mints the token and zspend signs.
The diagram shows the facilitator plane only; the zspend wallet runtime is a separate binary in the same workspace, described above.
agent / relying party (DPoP-authenticated)
|
v
+----+-------------------------------------------------+
| zpay-runtime (axum binary, ops listener, env-driven |
| config, healthcheck on /healthz) |
| +-------------------------------+ |
| | zpay-x402 | wire adapter |
| | /accepts /tip /prepare | + DPoP middle |
| | /settle /verify | + SSE hub |
| | /payments/{id} /events | |
| +---------------+---------------+ |
| | |
| v |
| zpay-core (prepare, settle, status, verify, binding |
| memo, registry, broadcast trait, |
| chain-tip oracle, transaction fetcher, |
| DPoP-aware PreparedTxStore) |
| |
| zpay-store (libSQL prepared_tx + settlement_ledger) |
+-------------------------+-----------------------------+
|
+--------------+--------------+
| |
v v
zinder-client zinder-client
(gRPC broadcast) (gRPC chain tip + tx fetch)
| |
v v
zinder -> Zebra
Across the two planes, the agent obtains a signed payload from zspend's
/v1/payments/sign, then hands that payload to zpay's /settle for
broadcast.
zpay calls zinder over gRPC for broadcast, chain-tip reads, and the transaction fetch the verify path needs. It depends on neither zinder's HTTP surface nor the chain directly; the only public HTTP in this loop is zpay's own.
zpay/
Cargo.toml workspace root
Dockerfile multi-stage Rust release build
docker-compose.yml local dev: zpay + dev-only payee bypass
railway.toml Railway deploy config; same project as zinder
rust-toolchain.toml 1.95
crates/
zpay-core/ types, lifecycle, traits, ZIP-302 binding,
ZIP-311 verifier
zpay-store/ libSQL impls of PreparedTxStore and
SettlementLedgerStore + migrations
zpay-x402/ x402 v2 HTTP routes, SSE hub, DPoP middleware
zpay-runtime/ binary, env-driven config, oracle wiring,
composition root
zpay-e2e/ integration harness (zally as the wallet
counterparty)
zspend-core/ agent-wallet trust-boundary vocabulary: the
payment_authorization RAR, at+jwt and DPoP
verification, intent-hash, SigningPolicy
zspend-runtime/ wallet binary: sealed-seed zally wallet,
/v1/payments/sign, single-use jti ledger,
revocation check, posture gate
etc/
aether-demo.toml placeholder payee config used by docker-compose
scripts/
deploy-to-railway.sh self-contained Railway uploader
test-persistence.sh end-to-end persistence probe
test-sse.sh SSE wire contract probe
test-cold-start.sh empty-volume migration probe
test-payee-override.sh payees TOML bind-mount probe
mint-dpop-proof.py ES256 proof helper used by probes
docs/
product-requirements.md whole-product PRD
architecture/ wire vocabulary, plane boundaries
adrs/ locked architectural decisions
runbooks/ operational procedures (Railway deploy,
reorg recovery, zspend seed)
reference/ error vocabulary
proposals/ asks against sibling repos
Railway is the supported managed path. The runbook covers the env-var matrix, volume provisioning, and the cross-service wiring to zinder:
./scripts/deploy-to-railway.shSee docs/runbooks/railway-deploy.md
for the first-deploy checklist, the placeholder-payee boot gate, and
the rollback procedure. The Dockerfile is the source of truth for the
build; Docker Compose mirrors it locally with the dev-only
ZPAY_ALLOW_DEMO_PAYEE=1 flag flipped on.
Every change in this repository must pass:
cargo build --release
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
docker buildx build -f Dockerfile -t zpay-runtime:dev .
./scripts/test-persistence.sh
./scripts/test-sse.sh
./scripts/test-cold-start.sh
./scripts/test-payee-override.shContainer probes run against the rebuilt image to lock the wire contract; treat any change to a probe assertion as a wire-shape decision.
| Document | Purpose |
|---|---|
| Product requirements | Problem, positioning, capability requirements |
| Public interfaces | Vocabulary spine; mandatory read before any new identifier |
| Operational surfaces | Env-var schema, readiness, ops port |
| Facilitator plane | Lifecycle and typed errors across boundaries |
| Error vocabulary | Every typed error, retry posture, operator action |
| Runbooks | Railway deploy, reorg recovery, zspend seed ceremony |
| ADR index | Locked architectural decisions, including ADR-0006 (trust boundary), ADR-0008 (compliance authority), and ADR-0009 (settlement finality) |
zpay sits next to two sibling projects in the Zcash agent stack:
- zally is the wallet library; relying parties typically run zally to sign the transaction the facilitator hands back as a ZIP-321 URI.
- zinder is the chain index; zpay reads chain tip, fetches transactions, and broadcasts through its gRPC surface.
Both upstreams are pinned by git rev in Cargo.toml; bump the rev to
promote upstream changes into zpay. The pinned line is Ironwood-aware
(NU6.3): Zebra is the only full validator past activation, /settle
parses v5 and v6 transactions alike, and zinder stores below artifact
schema 12 must be wiped and resynced. See
upstream platform binding.
MIT. See LICENSE.
Read AGENTS.md before opening a PR. Wire-shape changes are expensive to revert; check the vocabulary spine in docs/architecture/public-interfaces.md first.