ZeroGateZeroGateLaunch App
Documentation

ZeroGate Docs

Private API subscriptions via x402 and ZK proofs on Stellar. Merchant hidden on-chain. Server blind to your wallet. Every API call unlinkable.

Overview

ZeroGate is a private API subscription protocol combining the x402 payment standard with Groth16 ZK proofs on Stellar. When you call a ZeroGate-protected API without a session, the server returns HTTP 402 Payment Required — but instead of the merchant's wallet, the to: field points to a ShieldedPool Soroban contract.

You deposit USDC to the pool, the server receives only a Poseidon commitment hash, and you prove access with a ZK credential. No wallet. No trace. No linkage.

Merchant hidden

The 402 response shows ShieldedPool as to: — the merchant's G-address is never sent to you

Server blind

/subscribe receives only a commitment hash — zero wallet address, zero tx hash

Unlinkable sessions

HMAC session tokens contain no wallet info. Upgrades to Groth16 in production

Privacy guarantee

ZeroGate's core property: neither side learns the other's identity. ShieldedPool is the neutral blind intermediary between them.

WhoWhat they learnWhy
Subscriber (API caller)❌ Never sees merchant's wallet402 response has to: ShieldedPool — merchant G-address is never sent to the client
Merchant (API server)❌ Never sees subscriber's wallet/subscribe takes only a commitment hash — no wallet, no tx hash, provably blind
On-chain observer❌ No subscriber ↔ merchant linkPayment is wallet → ShieldedPool. Merchant withdraws privately via admin_claim

What is visible on-chain: The USDC transfer amount is public on Stellar (token transfers are always visible in the explorer). Everything else — merchant, subscriber, usage — is hidden.

How it works

Full flow (6 steps)

API → 402compute commitmentdeposit to ShieldedPoolPOST /subscribeget session tokencall API
01

API returns x402 Payment Required

Any request to a ZeroGate-protected endpoint without a session token receives HTTP 402. The response includes a zerogate-shielded payment object. Critically, to: is the ShieldedPool contract address — never the merchant's wallet. The subscriber has no way to learn who the merchant is.

02

Compute commitment in browser

Your browser computes commitment = Poseidon(secret, expiry) entirely client-side using circomlibjs. The secret never leaves the browser. This commitment is what gets stored on-chain — not your wallet, not your identity.

03

Deposit USDC to ShieldedPool

You send USDC to the ShieldedPool Soroban contract via Freighter. The contract stores only the commitment hash and returns a leaf_index (your position in the Merkle tree). The merchant's address is never written to the ledger at any point.

04

Blind subscribe — no wallet sent

POST /subscribe with { commitment, leaf_index, expiry }. No wallet address. No transaction hash. The server is provably blind to who subscribed. It issues a stateless HMAC session token tied to the commitment hash.

05

Call APIs anonymously

Every API call carries X-ZeroGate-Session: <commitment>:<expiry>:<hmac>. The server verifies the HMAC and returns data. Server logs contain: 'valid session presented' — zero wallet info. In production this upgrades to a full Groth16 ZK proof of Merkle membership.

x402 payment flow

ZeroGate implements a private variant of x402 — the merchant's wallet is replaced by the ShieldedPool contract in the 402 response. Subscribers pay the pool, merchants withdraw privately. Neither party learns the other's address.

402 Payment Required response
GET /api/prices  (no session token)
→ HTTP 402

{
  "x402Version": 1,
  "error": "Payment required",
  "accepts": [{
    "scheme": "zerogate-shielded",
    "network": "stellar-testnet",
    "asset": "USDC",
    "to": "CDMJVGYOLXA4UF4FYWMP2XXHBX7OGNM6C54NZ6BAEUPL6TXPSUJVGXYY", ← ShieldedPool
    "maxAmountRequired": "0.50",
    "howToPay": {
      "step1": "Call ShieldedPool.deposit(USDC, 0.50, Poseidon(secret,expiry))",
      "step2": "POST /subscribe with commitment hash (no wallet address sent)",
      "step3": "Retry request with X-ZeroGate-Session header"
    }
  }]
}

# Merchant wallet (GBBG…MQUM) is NEVER in this response.
POST /subscribe — wallet-blind
// Body — server receives ONLY these fields:
{
  "api_id": "price-feed",
  "commitment": "21888242871839275222246405745257275088548364400416034343698204186575808495617",
  "leaf_index": 3,
  "subscriber_secret": "...",
  "subscription_id": "sub_abc",
  "expiry": 1751500000
}

// ✗ wallet address  ✗ tx hash  ✗ payment amount  ✗ any identity

// Response:
{ "session_token": "<commitment>:<expiry>:<hmac>", "expires_at": 1751500000 }
Authenticated API call
GET /api/prices
X-ZeroGate-Session: 21888...617:1751500000:a3f9c2...

→ 200 OK  { "btc": 67420, "eth": 3521, "xlm": 0.12 }

# Server verified HMAC. Logs: "valid session." Nothing else.

ZK circuit

Written in Circom 2.0, compiled to Groth16 over BN254. Poseidon hash is used throughout — it matches Stellar Protocol 25's native bn254_poseidon_hash host function, enabling future on-chain proof verification.

Constraints11,741
CurveBN254
Proof systemGroth16
Merkle depth20 levels
HashPoseidon
Proof time~2.8s browser
subscription_proof.circom
pragma circom 2.0.0;
include "poseidon.circom";
include "merkleProof.circom";

template SubscriptionProof(levels) {
    // Private inputs — never leave the browser
    signal input secret;
    signal input expiry;
    signal input merkle_path[levels];
    signal input path_indices[levels];
    signal input nullifier_secret;

    // Public outputs — the only things the server sees
    signal output root;           // current Merkle root (verified on-chain)
    signal output nullifier;      // Poseidon(nullifier_secret, leaf_index)
    signal output commitment;     // Poseidon(secret, expiry) — matches on-chain leaf

    // Verify commitment is correctly formed
    component commitHash = Poseidon(2);
    commitHash.inputs[0] <== secret;
    commitHash.inputs[1] <== expiry;

    // Verify Merkle membership
    component tree = MerkleProof(levels);
    tree.leaf <== commitHash.out;
    // ... 11,741 constraints total
}

The production path replaces the HMAC session token with this Groth16 proof. The server verifies it against the live Merkle root — no wallet, no identity, just proof of membership.

ShieldedPool contract

A single Soroban contract (subscription_registry) acts as the ShieldedPool. It accepts USDC, stores commitment hashes in a Poseidon Merkle tree, and lets the merchant withdraw privately. Compiled to wasm32v1-none with soroban-sdk 26.x.

Deployed address (Stellar Testnet)

CDMJVGYOLXA4UF4FYWMP2XXHBX7OGNM6C54NZ6BAEUPL6TXPSUJVGXYY

subscription_registryShieldedPool — the neutral intermediary
initialize(admin: Address)

Set contract admin (merchant)

deposit(from, token, amount, commitment: BytesN<32>) → u32

Transfer USDC from subscriber, store commitment leaf, return leaf_index. Merchant address never written.

get_root() → BytesN<32>

Current Poseidon Merkle root — used by ZK proof verification

get_proof(leaf_index: u32) → (Vec<BytesN<32>>, Vec<u32>)

Merkle siblings + directions for client-side proof generation

leaf_count() → u32

Total number of deposits (size of Merkle tree)

claim(token, to: Address, amount: i128)

Admin only — merchant withdraws USDC. Not linked to any subscriber transaction.

Events emitted on deposit: ("deposit", "v1") → (leaf_index, commitment). Amount and subscriber address are NOT emitted — they are not discoverable from on-chain data.

API reference

Express backend on port 3001. Protected routes accept either an X-ZeroGate-Session HMAC token (playground) or an X-ZeroGate-Proof Groth16 proof (production).

GET/health

Health check — returns { status: 'ok', service: 'zerogate-api' }

POST/subscribe

Blind subscribe. Body: { api_id, commitment, leaf_index, expiry }. No wallet. Returns { session_token, expires_at }.

GET/api/weather
session

Live weather data. Params: lat, lon.

GET/api/prices
session

Crypto spot prices — BTC, ETH, XLM.

POST/api/analyze
session

AI text analysis. Body: { text }.

Auth headers
# Playground — HMAC session token (stateless, survives restarts)
X-ZeroGate-Session: <commitment>:<expiry>:<hmac-sha256>

# Production — Groth16 ZK proof (base64-encoded JSON)
X-ZeroGate-Proof: <base64(JSON.stringify({ proof, publicSignals }))>

# publicSignals = [root, nullifier, commitment]
# proof = { pi_a: [...], pi_b: [[...],[...]], pi_c: [...] }

# CORS allowed headers: Content-Type, Authorization,
#                       X-ZeroGate-Session, X-ZeroGate-Proof

Quick start

1. Clone and install
git clone https://github.com/ayushsingh82/ZeroGate.git
cd ZeroGate

# Frontend
cd frontend && npm install && npm run dev    # http://localhost:3000

# API server
cd ../api && npm install && npx tsx src/server.ts  # http://localhost:3001
2. Fund your testnet wallet
# XLM (for gas)
curl "https://friendbot.stellar.org?addr=YOUR_G_ADDRESS"

# USDC (for subscriptions)
# https://faucet.circle.com → Stellar Testnet → paste your G address
3. Subscribe via the app
1. Connect Freighter wallet at http://localhost:3000/app
2. Click "Pay X USDC · Subscribe privately" on any API card
3. Approve the ShieldedPool deposit in Freighter
4. Your USDC goes to the pool — merchant address never shown
5. Session token is saved locally — start calling APIs in Playground
4. api/.env
SUBSCRIPTION_REGISTRY_CONTRACT=CDMJVGYOLXA4UF4FYWMP2XXHBX7OGNM6C54NZ6BAEUPL6TXPSUJVGXYY
MERCHANT_SALT=<32-byte-hex>         # signs HMAC session tokens
SERVER_SECRET_KEY=S...              # merchant keypair for admin_claim
MERCHANT_ADDRESS=G...               # merchant public key
5. Build Soroban contract (optional — already deployed)
cd contracts/subscription_registry
rustup target add wasm32v1-none
cargo build --target wasm32v1-none --release
# → target/wasm32v1-none/release/subscription_registry.wasm