docs / guide

view as .md

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 for the mental model.

user-zk sits beside agent-zk 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#

// 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,
);
// 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 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.

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.

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 tokensagent-zk, the same proof shape for enrolled machines.
  • Wallet loginuser-sig, permissionless login with no enrollment or revocation.
  • Verify a deed — the relying-party side.
  • Concepts — the mental model.
  • Errors — every error code a relying party branches on.

This page is also served as Markdown — agents should read that. The whole tree is indexed for machines in llms.txt.