Discord bot that assigns nobility tier roles (Gentleman → Sovereign) based on a user's combined SIR / HyperSIR / MegaSIR holdings across Ethereum, HyperEVM, and MegaETH.
- User runs
/verifyin Discord. - Bot replies with a one-time link
https://verify.sir.trading/verify/<nonce>(ephemeral — only the user sees it). - User connects their wallet on the verify page and signs a SIWE message — no transaction, no gas.
- Backend verifies the signature, atomically links wallet → Discord ID.
- Bot reads
SirProxy.balanceOf(wallet)on all three chains, sums the result, and assigns the matching tier role. - A background worker repeats the read every 6 hours and updates roles as holdings change.
- TypeScript, Node 20+, pnpm workspaces
apps/bot—discord.jsv14 +viemv2 +better-sqlite3+node-cronapps/verify-web— Next.js 15 (App Router) +wagmi+ RainbowKit +siwepackages/shared— chain registry, tier helpers, SirProxy ABI- Docker Compose + Caddy (auto-TLS) on a single Hetzner box
- A Discord application with bot token (https://discord.com/developers/applications)
- Alchemy API key(s) with access to Ethereum mainnet, HyperEVM, and MegaETH
- The three deployed
SirProxyaddresses (Ethereum / HyperEVM / MegaETH) - A domain pointed at the Hetzner box for the verify page (e.g.
verify.sir.trading)
pnpm install
cp .env.example .env # fill in real values
# in two terminals:
pnpm dev:bot # apps/bot — discord.js client + worker
pnpm dev:web # apps/verify-web — Next.js on :3000For local testing point VERIFY_BASE_URL=http://localhost:3000 and set up a private test Discord guild. SIR is mainnet-only so you'll be testing against real balances — use small amounts.
Windows note:
pnpm --filter @sir/verify-web build(production standalone build) fails on Windows due to pnpm symlinks + Next.js standalone output requiring admin/Developer Mode. Local dev (next dev) works fine. The Linux Docker build (docker compose build) handles this correctly. Use WSL or Docker if you need a local production build.
-
Create the Discord application: https://discord.com/developers/applications → New Application. On the Bot tab, reveal the token (this is
DISCORD_TOKEN). Enable the Server Members Intent (Privileged). Presence/Message Content are NOT needed. -
Generate the invite URL: OAuth2 → URL Generator → scopes
bot+applications.commands; permission: Manage Roles only. Use the generated URL to invite the bot to the guild. -
Create the 12 tier roles in the SIR Discord (lowest → highest):
- Gentleman · Squire · Knight · Baronet · Baron · Viscount · Earl · Marquess · Duke · Archduke · Grand Duke · Sovereign
- Plus an optional
SIR Adminrole.
-
Hierarchy (critical): drag the bot's auto-created role above all 12 tier roles in the guild's Role list. Without this, role assignment fails with HTTP 50013 (Missing Permissions). Re-check after any future role reorg.
-
Copy IDs: enable Discord Developer Mode → right-click each role → Copy ID. Put the 12 IDs into
TIER_ROLE_IDSin.envlowest → highest, matching the order ofTIER_NAMESandTIER_THRESHOLDS_WEI. Also copyGUILD_ID,DISCORD_CLIENT_ID, andADMIN_ROLE_ID.
All config goes through .env. Tier configuration uses three paired arrays:
TIER_NAMES=Gentleman,Squire,Knight,Baronet,Baron,Viscount,Earl,Marquess,Duke,Archduke,Grand Duke,Sovereign
TIER_ROLE_IDS=<id1>,<id2>,...,<id12>
TIER_THRESHOLDS_WEI=100000000000000000000000,250000000000000000000000,...,500000000000000000000000000
- All three arrays must have the same length, in the same order, lowest → highest.
- Thresholds are in SIR's native base units. SIR / HyperSIR / MegaSIR use 12 decimals (not the ERC-20 default of 18) — each value is
<SIR amount> * 10^12. Gentleman = 100k SIR =100000000000000000(17 zeros after the 1). Sovereign = 500M SIR =500000000000000000000(20 zeros after the 5). config.tsvalidates this at boot: identical length, strictly ascending thresholds, Discord snowflake-shaped role IDs, unique names. The bot refuses to start otherwise.
See .env.example for the full list.
Optional but recommended — without it, desktop browser extensions (MetaMask, etc.) still work but mobile users cannot connect.
- Create a project at https://cloud.reown.com and copy its 32-char project ID.
- Add your verify domain (e.g.
https://verify.sir.trading) to the project's Allowed Domains. Otherwise mobile users see a 403 fromapi.web3modal.organd the modal never opens. - Set
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=<id>in.env. - Rebuild the image, don't just restart:
docker compose build verify-web && docker compose up -d verify-web. The value is inlined into the client bundle at build time.
# On the Hetzner box, as root or a deploy user:
git clone <this repo> /opt/sir
cd /opt/sir
cp .env.example .env
# Edit .env with real values
docker compose up -d --build
docker compose logs -f bot # tail bot logs
docker compose logs -f verify-web # tail web logsDNS: A record verify.sir.trading → Hetzner IP. Caddy provisions a Let's Encrypt cert automatically on first request.
Backups: install infra/backup.sh as a host cron (NOT inside a container) for consistent SQLite snapshots:
sudo cp infra/backup.sh /opt/sir/infra/backup.sh
sudo chmod +x /opt/sir/infra/backup.sh
sudo crontab -e
# add: 30 3 * * * /opt/sir/infra/backup.shCombined with a weekly Hetzner snapshot, this is plenty for this data volume.
| Command | What it does |
|---|---|
/verify |
Issues a one-time SIWE link as an ephemeral channel reply. Rate-limited to 3/hour. |
/refresh |
Re-reads your balances now and updates your role. 60s/user cooldown. |
/unlink [wallet] |
Unlink a single wallet, or all of them. Roles are removed within the same tick. |
/balance |
Ephemeral breakdown of your per-chain balance + total + current tier. |
/wallets |
Ephemeral list of your linked wallets. |
/sir-admin recheck-all |
Trigger an off-cycle refresh for everyone. |
/sir-admin stats |
Verified users, wallets, last refresh time, per-tier counts. |
/sir-admin force-unlink @user |
Sybil/abuse response. |
- SIWE: verified via the
siwepackage'sSiweMessage.verify— checks signature AND domain/nonce/expiration. We never fall back to bareverifyMessage. - Nonces: 32 random bytes, single-use, 10-minute TTL. Consume happens atomically with the wallet insert in one transaction.
- One wallet ↔ one Discord: enforced by SQLite
PRIMARY KEYonwallets.wallet_address. - Rate limits:
/verify3/hour,/refresh60s/user. - EOA-only for v1: smart-contract wallets (Safe, Argent, etc.) are not supported by the current signature path. Document this in the verify page UI if needed.
- Partial-cycle policy: if ANY chain RPC fails during a 6h cycle, the bot persists whatever balance reads succeeded but applies NO role changes that cycle. Failed reads could move any user across any threshold; we'd rather be stale than flap.
- HyperEVM caveats: Read only at
latestblock tag — historical block reads andsafe/finalizedtags are not reliable on HyperEVM's default RPC. - Discord role hierarchy: any time you reorganize roles, re-check that the bot's role is still above all 12 tier roles.
See C:\Users\nilga\.claude\plans\i-want-to-implement-twinkling-meadow.md for the full design doc, including security review notes from Codex.