Discord Bot for NFT Role Distribution
Token gating is standard practice for NFT communities. Hold an NFT from a collection and you get access to private channels, early access, exclusive drops. Technically it's verification: wallet with required NFT → Discord account → role. The task looks simple, but between "verification" and "permanent actual access" lies an infrastructure layer that fails predictably.
System Architecture
Wallet ↔ Discord Connection
User clicks "Verify" → redirects to verification page → connects wallet via WalletConnect v2 → signs a message (no gas required) → server verifies signature → saves {discordId: walletAddress}.
Message signing is not a transaction; users pay nothing. Standard verification message:
Verify Discord: username#1234
Nonce: a3f8b2c1
Timestamp: 1711234567
Nonce is a random string, unique per session, with 5-minute TTL. Without nonce, replay attacks are possible: a copied signature can be reused.
Backend signature verification:
import { verifyMessage } from 'viem';
const isValid = await verifyMessage({
address: claimedAddress,
message: expectedMessage,
signature: userSignature
});
NFT Ownership Check: Three Approaches
Direct RPC call. balanceOf(wallet, tokenId) via ethers.js or viem. Simple, works for small collections. Problem: with 10,000 users, you make 10,000 RPC calls per check.
Alchemy/Moralis NFT API. getNFTsForOwner(wallet, contractAddress) — one request returns all tokens. Fast, but dependent on external service. If Alchemy goes down, bot doesn't work.
The Graph subgraph. Index Transfer events, build {owner: [tokenIds]} mapping. Fast GraphQL query. Indexing lag ~1-5 minutes — if token is sold quickly, user keeps role for 5 more minutes.
For production bots, we use Alchemy NFT API as primary with The Graph as backup and direct RPC as last fallback.
Discord Bot: Slash Commands and Event Handling
Bot implemented in discord.js v14. Key slash commands:
-
/verify— start verification, bot sends ephemeral message with link -
/check— forced ownership check (for users who sold tokens) -
/roles— show all roles and requirements
Roles assigned via guild.members.cache.get(userId)?.roles.add(roleId). Requires MANAGE_ROLES permission and bot role above assigned roles in hierarchy—common setup error.
async function syncUserRoles(userId: string, wallet: string): Promise<void> {
const member = await guild.members.fetch(userId);
const ownedTokens = await getNFTsForOwner(wallet, CONTRACT_ADDRESS);
for (const [roleId, requirement] of ROLE_REQUIREMENTS) {
const qualifies = checkQualification(ownedTokens, requirement);
if (qualifies && !member.roles.cache.has(roleId)) {
await member.roles.add(roleId);
} else if (!qualifies && member.roles.cache.has(roleId)) {
await member.roles.remove(roleId);
}
}
}
Periodic Resynchronization
Critical moment: user sells NFT and must lose role. Bot doesn't receive Discord event—it must check periodically itself.
Cron job every 10-30 minutes: for each verified user, check current balance, update roles. With 1,000 users and 30-minute interval — ~33 API requests per minute. Fits within Alchemy limits.
Optimization: listen to Transfer events via WebSocket (Alchemy WebSocket API). On any Transfer, check if verified wallet is involved, immediately update role. Reduces latency to seconds.
const provider = new WebSocketProvider(ALCHEMY_WS_URL);
const contract = new Contract(NFT_ADDRESS, erc721Abi, provider);
contract.on('Transfer', async (from, to, tokenId) => {
const affectedWallets = [from, to].filter(w => w !== ethers.ZeroAddress);
for (const wallet of affectedWallets) {
await syncRolesForWallet(wallet);
}
});
Multiple Collections and Trait-Based Roles
Real projects require complex conditions:
- Hold ≥3 tokens from collection A → VIP role
- Hold token with trait "Legendary" → Legendary role
- Hold token from collection A and collection B → Collab role
For trait-based roles, need metadata access. Alchemy getNFTsForOwner returns tokenMetadata including attributes. Role config in JSON:
{
"LEGENDARY_ROLE_ID": {
"contract": "0x...",
"minBalance": 1,
"requiredTrait": {"trait_type": "Rarity", "value": "Legendary"}
}
}
Stack
| Component | Technology |
|---|---|
| Bot | discord.js v14, TypeScript |
| Wallet connect | WalletConnect v2 (web app for verification) |
| NFT data | Alchemy NFT API + WebSocket |
| Database | PostgreSQL (userId ↔ wallet mapping) |
| Hosting | Railway or Render (persistent process) |
| Signature verification | viem verifyMessage |
Timeline Estimates
Basic bot with one collection and one role — 3-4 days. Extended with multiple collections, trait-based roles and real-time sync via WebSocket — 4-5 days.







