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:
- The human derives their identity from a wallet signature (
ZkUser.fromWalletSignature, every language) and reads off the publiccommitment(). Only the commitment crosses to the admin; the signature never leaves their machine. - The admin calls
registerZkUser(tenantId, commitment)— orregisterZkUserBatch(tenantId, commitments)to enroll several members in one transaction, atomically checked against the tier'smaxUserscap before any of them lands. - The admin publishes an origin vouch for the app's origin, the same mechanism
agent-zkuses, somintUserDeed'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 tokens —
agent-zk, the same proof shape for enrolled machines. - Wallet login —
user-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.