NFT Membership System Development
The most common mistake in NFT membership: developers implement ownerOf(tokenId) == msg.sender check and consider the problem solved. But NFT can be lent, flash-loaned (for one block), or listed on marketplace while retaining access via delegation. A proper membership system requires understanding these vectors and explicitly choosing a trust model.
Contract Architecture
Basic Model: Token Ownership
For simple cases (content access, Discord verification), ERC-721 with check function suffices:
function isMember(address user) public view returns (bool) {
return balanceOf(user) > 0;
}
balanceOf is cheaper than ownerOf with multiple tokens and more resilient to edge cases. But it doesn't protect from listing: owner can list NFT on OpenSea, access closed content, and remove listing afterward.
Tiered Membership via ERC-1155
For multiple access levels (Bronze/Silver/Gold, or month/year/lifetime), ERC-1155 better suited than ERC-721 natively. Each tokenId is a tier:
uint256 public constant TIER_BRONZE = 1;
uint256 public constant TIER_SILVER = 2;
uint256 public constant TIER_GOLD = 3;
function getMemberTier(address user) external view returns (uint256) {
if (balanceOf(user, TIER_GOLD) > 0) return TIER_GOLD;
if (balanceOf(user, TIER_SILVER) > 0) return TIER_SILVER;
if (balanceOf(user, TIER_BRONZE) > 0) return TIER_BRONZE;
return 0; // not member
}
Tiers with cumulative access: Gold includes everything in Silver and Bronze. Check top-down.
Soulbound (Non-transferable) Membership Tokens
If goal is binding access to specific person, not wallet, use EIP-5192 (Minimal Soulbound NFT) or simply override transfer functions:
function _beforeTokenTransfer(
address from,
address to,
uint256 tokenId,
uint256 batchSize
) internal override {
require(from == address(0) || to == address(0), "Soulbound: non-transferable");
super._beforeTokenTransfer(from, to, tokenId, batchSize);
}
from == address(0) — mint, to == address(0) — burn. Everything else forbidden. Problem: key loss = membership loss. Solution: provide recovery mechanism via multisig or social recovery (ERC-4337 account abstraction).
Temporary Membership
Expiring membership requires storing dates. Two approaches:
On-chain timestamp: mapping tokenId → expiresAt. Check in isMember() includes block.timestamp < memberships[tokenId].expiresAt. Renewal — transaction with payment, updates timestamp. Gas on every check.
Signature-based off-chain: backend issues signed JWT with expiry, contract doesn't store time. Cheaper gas, but requires trusting signing service. Suits Web2-hybrid systems.
For fully on-chain — first approach. ERC-5643 — draft standard for subscription NFT with renewSubscription(uint256 tokenId, uint64 duration).
Integration with Off-chain Systems
Verification via EIP-1271
For checking membership in backend without transactions: user signs message (EIP-191 or EIP-712), backend verifies via eth_call to isValidSignature(bytes32 hash, bytes signature) for smart wallets or ecrecover for EOA.
async function verifyMembership(
userAddress: string,
signature: string,
message: string,
nftContract: ethers.Contract
): Promise<boolean> {
const signerAddress = ethers.verifyMessage(message, signature);
if (signerAddress.toLowerCase() !== userAddress.toLowerCase()) return false;
const balance = await nftContract.balanceOf(userAddress);
return balance.gt(0);
}
Delegation via delegate.xyz
delegate.cash (no EIP, but de facto standard) allows NFT owner to delegate cold wallet → hot wallet. Critical for membership: holders store expensive NFT in cold wallet, interact via hot. Integration:
IDelegationRegistry constant DELEGATION_REGISTRY =
IDelegationRegistry(0x00000000000076A84feF008CDAbe6409d2FE638B);
function isMember(address user) public view returns (bool) {
if (balanceOf(user) > 0) return true;
// Check delegation
address[] memory delegators = DELEGATION_REGISTRY.getDelegationsByDelegate(user);
for (uint i = 0; i < delegators.length; i++) {
if (balanceOf(delegators[i]) > 0) return true;
}
return false;
}
Real need: Moonbirds, Doodles, and other major collections integrated delegate.cash for this.
Mint Mechanism and Pricing
Allowlist via Merkle Tree — standard for presale:
bytes32 public merkleRoot;
function allowlistMint(bytes32[] calldata proof) external payable {
bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
require(msg.value >= PRICE, "Insufficient payment");
_safeMint(msg.sender, _nextTokenId());
}
Proof generated off-chain (merkletreejs), root uploaded to contract. List of 10,000 addresses — proof ~14 hashes, calldata ~450 bytes.
Timeline Estimates
ERC-721 membership with tiers and Merkle allowlist — 2 days. Adding temporary subscription (ERC-5643 style) + backend verification — another 1-2 days. Full system with delegation, soulbound recovery, and frontend — 4-5 days.







