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 membership list position: your app cannot tell which enrolled member just 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 module ZkAgent does —
// pkg/grantor_agent_wasm.js (built by `just agent-ts`), not the
// authenticate()/discover() high-level module — same convention
// agent-tokens.md documents for ZkAgent.
import { Registry } from "../../sdk/agent/ts/src/registry.js";
// 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 —
// excerpted from examples/mcp-server/server.mjs's own handler.
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 (the registry ref, the origin vouch, allowInsecureOrigin, nowUnix) — see Agent tokens § The agent-zk recipe for what each one does; the provenance check runs the same way, before proving, on the mint path here too. Your app verifies a user-zk deed exactly the way it verifies 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(). The signature itself never leaves their machine; only the commitment crosses to the admin. - 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#
A user-zk deed proves "this signer is one of the tenant's enrolled users" without revealing which one — the same Semaphore zero-knowledge membership proof agent-zk uses, over a different tree. The sub your app receives is a stable pseudonym, 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). This advances the on-chain user tree's root immediately — the member's prior membership 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, because your app's verify_deed call checks root recency on every request, not just at mint time. There is no grace period and nothing for the revoked member to do; the next login simply 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 deliberately out of scope for this mode and are not shipped:
- Token-gating / DAO membership — proving membership via an on-chain token balance or a DAO's own membership set, rather than an admin-curated allowlist. This needs a maintained snapshot tree or storage proofs, which is a materially larger, separate piece of work.
- Passkey-derived anonymous identities — a
user-zk-shaped mode identity-derived from a passkey instead of a wallet signature. There is no stable secret a passkey can deterministically re-derive across devices the way a wallet signature can, so this has 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.