# User gating (`user-zk`)

A human proves membership of your app's own **user allowlist** in zero-knowledge.
Your app learns *"an enrolled member of tenant T logged in"* plus a stable,
app-scoped **pseudonym**, and nothing else — no wallet address, no name, no list
position. You cannot tell *which* enrolled member signed in, only that one did.
See [Concepts](concepts.md) for the mental model.

`user-zk` sits beside [`agent-zk`](agent-tokens.md) as a *kind of deed* — same
zero-knowledge membership proof, same on-chain revocation, same billing check —
proved against a **separate, admin-curated user tree** instead of the agent
tree. It is additive alongside `user-sig`/`user-passkey`/`user-1271`: pick it
when a human logging in must be on YOUR allowlist first, not merely hold a key.

## Mint + verify, at a glance

```ts
// ZkUser ships from the same low-level agent module ZkAgent does, not the
// authenticate()/discover() high-level module — the same convention
// agent-tokens.md documents for ZkAgent.
import { Registry } from "@grantor/agent/registry";

// once, per member: derive the identity from a wallet signature and hand
// the PUBLIC commitment to your admin flow — never the signature itself.
const user = ZkUser.fromWalletSignature(walletSignature);
const commitment = user.commitment(); // -> tenant admin calls registerZkUser(tenantId, commitment)

// each login: sync the user tree from chain, prove membership, mint a
// user-zk deed bound to a challenge YOUR app issued.
const deed = await user.mintUserDeed(
  rpcUrl, Registry.canonical(), tenantId, audience, origin, challenge, expUnix,
  vouchSignature, vouchEpoch, vouchExp, allowInsecureOrigin, nowUnix,
);
```

```js
// your app: the same DeedGuard.verify call every mode goes through.
const claims = await g.guard.verify(deedJson, challenge);
// claims.sub is the pseudonym — log ONLY this, never a wallet address.
console.log(`an enrolled member of tenant ${claims.tenant} logged in (sub=${claims.sub})`);
```

`mintUserDeed`'s parameters mirror `ZkAgent.mintDeed` exactly (registry ref,
origin vouch, `allowInsecureOrigin`, `nowUnix`) — see [Agent tokens § The
`agent-zk` recipe](agent-tokens.md#the-agent-zk-recipe) for what each does. The
provenance check runs the same way, before proving. Your app verifies a
`user-zk` deed like every other mode — the same `DeedGuard`/`DeedVerifier`, the
same `verify_deed` call. See [Verify a deed](verify-tokens.md).

## Admin enrollment (once per member, on-chain)

Before a human can mint a `user-zk` deed, the tenant admin enrolls their
public commitment in the tenant's on-chain **user tree** — a tree the
`GrantorRegistry` keeps entirely separate from the agent tree `agent-zk`
proves against, with its own cap and its own counter:

1. The human derives their identity from a wallet signature
   (`ZkUser.fromWalletSignature`, every language) and reads off the public
   `commitment()`. Only the commitment crosses to the admin; the signature
   never leaves their machine.
2. The admin calls `registerZkUser(tenantId, commitment)` — or
   `registerZkUserBatch(tenantId, commitments)` to enroll several members in
   one transaction, atomically checked against the tier's `maxUsers` cap
   before any of them lands.
3. The admin publishes an **origin vouch** for the app's origin, the same
   mechanism `agent-zk` uses, so `mintUserDeed`'s provenance check can pass —
   see [Sovereign tier § Origin provenance](../sovereign-tier.md#origin-provenance).

## Anonymous membership and the pseudonym

The proof is the same Semaphore membership proof `agent-zk` uses, over a
different tree. The `sub` your app receives is scoped to `(the member's
identity, tenant, audience)`: the same member gets the same `sub` on every
login to the same app, and a different, uncorrelatable `sub` at a different
app. Use it as your primary key for per-member state; do not expect it to
reveal, or be derivable from, a wallet address.

## Revocation

The admin revokes a member with `revokeZkUser(tenantId, commitment)`, which
advances the on-chain user tree's root immediately. The member's prior proof
stops being valid input to a *new* proof at once, and any `user-zk` deed
already minted against the pre-revocation root stops verifying on its next
presentation — your app's `verify_deed` call checks root recency on every
request, not just at mint time. There is no grace period: the next login fails
the same way an expired or never-enrolled one would.

## The wallet-derived-identity requirement

`user-zk` identities are derived deterministically from a wallet signature
(`ZkUser.fromWalletSignature`), the same derivation `agent-zk` uses for an
agent key. A member needs a signing key to enroll and to log in — there is
no keyless or browser-only variant of this mode today.

## Not built yet

Two related gating shapes are out of scope for this mode and not shipped:

- **Token-gating / DAO membership** — proving membership via an on-chain
  token balance or a DAO's membership set, rather than an admin-curated
  allowlist. This needs a maintained snapshot tree or storage proofs — a
  materially larger, separate piece of work.
- **Passkey-derived anonymous identities** — a `user-zk`-shaped mode derived
  from a passkey instead of a wallet signature. A passkey has no stable
  secret it can deterministically re-derive across devices, so there is no
  clean derivation to build on yet.

`user-zk` itself is complete for admin-curated, wallet-derived, anonymous,
pseudonymous, revocable human membership — that is its whole scope.

## See also

- [Agent tokens](agent-tokens.md) — `agent-zk`, the same proof shape for
  enrolled machines.
- [Wallet login](wallet-login.md) — `user-sig`, permissionless login with no
  enrollment or revocation.
- [Verify a deed](verify-tokens.md) — the relying-party side.
- [Concepts](concepts.md) — the mental model.
- [Errors](errors.md) — every error code a relying party branches on.
