A Discord bot that grants roles to users based on ZK-verified ENS text records.
Built on top of zkemail's on-chain infrastructure: when a user runs /verify vitalik.eth, the bot reads verifyTextRecord(namehash("vitalik.eth"), "com.discord", <their-discord-username>) on the Sepolia LinkHandleEntrypoint for Discord. A true return is cryptographic proof that a ZK-verified email proof was submitted binding that ENS name to that Discord handle — not just a wallet-owner self-attestation. The bot then assigns the server's configured "verified" role.
See ens.zk.email for the verification flow users complete.
- Go to https://discord.com/developers/applications and click New Application.
- Bot tab → Reset Token → copy the token (this is
DISCORD_TOKEN). - General Information → copy the Application ID (this is
DISCORD_CLIENT_ID). - Bot tab → under Privileged Gateway Intents, leave them all off (we don't need any).
- OAuth2 → URL Generator → check
botandapplications.commandsscopes. Under bot permissions, checkManage RolesandSend Messages. Copy the generated URL and open it to invite the bot to your test server.
cp .env.example .env
# edit .env with your tokensFor a fast dev loop, set DISCORD_DEV_GUILD_ID to your test server's ID — slash commands register instantly per-guild instead of taking up to an hour globally.
npm install
npm run register # uploads slash commands to Discord
npm run dev # starts the bot/verify-config role role:@verified
Pick a role that's below the bot's own role in the role list (so the bot can assign it). Make sure the bot has the Manage Roles permission.
/verify ens:vitalik.eth
- If
vitalik.ethhas a ZK-verifiedcom.discordtext record matching your Discord username, you get the verified role instantly. - If not, the bot DMs you a deep link to ens.zk.email with step-by-step instructions to verify (takes ~2 minutes). After verifying, run
/verifyagain.
/me— show your linked ENS name (if any) in this server./whois user:@someone— show another user's linked ENS name.
The bot reads verifyTextRecord on the platform's LinkHandleEntrypoint contract. That function only returns true if a valid ZK proof was submitted through the entrypoint's gated write path. Plain resolver.setText(...) writes by the ENS owner are stored in a different contract entirely (the standard ENS resolver) and are invisible to this check.
This means:
- An attacker with the wallet that owns an ENS cannot fake a verified Discord handle on that ENS without also compromising the email account that receives Discord's password-reset mails.
- Unicode-spoofed Discord handles (e.g.,
vitalikwith a Cyrillicа) cannot be smuggled past the bot, because the canonical handle string is extracted from the actual email Discord sends. - The bot does not trust the ENS owner's self-attestation of their Discord handle — only ZK-proven assertions count.
src/
├── chain/ # viem read of verifyTextRecord
├── discord/ # discord.js client + slash commands
├── storage/ # better-sqlite3: per-guild config + per-user links
├── config.ts # env loader
└── index.ts # bot entrypoint
scripts/
├── register-commands.ts # uploads slash command schemas
└── test-verifier.ts # manual on-chain read test
The bot uses discord.js's persistent gateway WebSocket, so serverless platforms (Cloudflare Workers / Vercel / Lambda) won't work as the primary host. You want an always-on worker with a writable disk for the SQLite DB.
A render.yaml Blueprint is in the repo. It provisions a Background Worker on the starter plan with a 1 GB persistent disk mounted at /data. Cost: ~$7/mo worker + ~$0.25/mo disk.
Or manually:
- Push the repo to your GitHub.
- Render dashboard → New + → Blueprint → connect this repo → Apply.
- In the service settings, set the two secrets the blueprint left blank:
DISCORD_TOKEN— your bot token from the Discord Developer Portal.DISCORD_CLIENT_ID— the Application ID.
- Wait for the first deploy to finish.
- Open the service's Shell tab and run once:
npm run register. This uploads slash commands to Discord (idempotent — safe to re-run after any command-shape change). - Invite the bot to your test server using the OAuth2 URL from the Developer Portal (
bot+applications.commandsscopes, withManage RolesandSend Messagespermissions).
- Railway (~$5-7/mo): same model — Background Worker + persistent volume.
- Fly.io (~$2-5/mo): cheapest "real" platform;
flyctl deployfrom a smallfly.toml. - VPS (Hetzner / DO, ~$4-6/mo):
npm ci && npm run register && npm startunder systemd or PM2. Cheapest but you maintain the box.
In every case: ensure DB_PATH points to a persistent volume so user verifications survive restarts.
- Read additional verified text records (twitter, github, reddit, email) and offer per-platform role gates.
- Support web2 users via paytox-style auto-generated subdomains (
<discord-username>.discord.zkemail.eth) — no wallet, no existing ENS required. - Periodic re-checks to revoke roles if claims become invalid.
/tip @userdeep-linking to paytox.
See the plan in /Users/benceharomi/.claude/plans/our-goal-is-to-frolicking-knuth.md.