Разработка NFT-membership системы
Самая частая ошибка в NFT-membership: разработчики реализуют проверку ownerOf(tokenId) == msg.sender и считают задачу решённой. Но NFT можно одолжить, flash loan-нуть (на один блок) или выставить на маркетплейс, сохранив доступ через делегирование. Правильная membership система требует понимания этих векторов и явного выбора модели доверия.
Контрактная архитектура
Базовая модель: владение токеном
Для простых кейсов (доступ к контенту, Discord-верификация) достаточно ERC-721 с функцией проверки:
function isMember(address user) public view returns (bool) {
return balanceOf(user) > 0;
}
balanceOf дешевле ownerOf при множественных токенах и устойчивее к edge cases. Но она не защищает от listing: владелец может выставить NFT на OpenSea, получить доступ к закрытому контенту, и снять листинг после.
Тиерная membership через ERC-1155
Для нескольких уровней доступа (Bronze/Silver/Gold, или месяц/год/lifetime) ERC-1155 нативно подходит лучше ERC-721. Каждый tokenId — тир:
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; // не участник
}
Тиры с накопленным доступом: Gold включает всё что есть в Silver и Bronze. Проверяем сверху вниз.
Soulbound (нетрансферабельные) membership токены
Если цель — привязать доступ к конкретному человеку, а не кошельку, используем EIP-5192 (Minimal Soulbound NFT) или просто переопределяем transfer функции:
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. Всё остальное запрещено. Проблема: потеря ключей = потеря membership. Решение: предусмотреть recovery механизм через мультисиг или социальное восстановление (ERC-4337 account abstraction).
Временная membership
Expiring membership требует хранения дат. Два подхода:
On-chain timestamp: маппинг tokenId → expiresAt. Проверка в isMember() включает block.timestamp < memberships[tokenId].expiresAt. Renewal — транзакция с оплатой, обновляет timestamp. Газ на каждую проверку.
Signature-based off-chain: бэкенд выдаёт подписанные JWT с expiry, контракт не хранит время. Дешевле по газу, но требует доверия к сервису подписей. Подходит для Web2-hybrid систем.
Для fully on-chain — первый подход. ERC-5643 — черновик стандарта для subscription NFT с renewSubscription(uint256 tokenId, uint64 duration).
Интеграция с off-chain системами
Верификация через EIP-1271
Для проверки membership в бэкенде без транзакций: пользователь подписывает сообщение (EIP-191 или EIP-712), бэкенд верифицирует через eth_call к isValidSignature(bytes32 hash, bytes signature) для смарт-кошельков или через ecrecover для 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);
}
Делегирование через delegate.xyz
delegate.cash (EIP-нет, но де-факто стандарт) позволяет владельцу NFT делегировать cold wallet → hot wallet. Для membership систем это важно: держатели хранят дорогой NFT в cold wallet, а взаимодействуют через горячий. Интеграция:
IDelegationRegistry constant DELEGATION_REGISTRY =
IDelegationRegistry(0x00000000000076A84feF008CDAbe6409d2FE638B);
function isMember(address user) public view returns (bool) {
if (balanceOf(user) > 0) return true;
// Проверяем делегирование
address[] memory delegators = DELEGATION_REGISTRY.getDelegationsByDelegate(user);
for (uint i = 0; i < delegators.length; i++) {
if (balanceOf(delegators[i]) > 0) return true;
}
return false;
}
Это реальная потребность: Moonbirds, Doodles и другие крупные коллекции интегрировали delegate.cash именно для этого.
Mint механизм и ценообразование
Allowlist через Merkle Tree — стандарт для 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 генерируется off-chain (merkletreejs), root загружается в контракт. Список на 10,000 адресов — proof из ~14 хэшей, calldata ~450 байт.
Ориентиры по срокам
ERC-721 membership с тирами и Merkle allowlist — 2 дня. Добавление временной подписки (ERC-5643 стиль) + бэкенд верификация — ещё 1-2 дня. Полная система с делегированием, soulbound recovery и фронтендом — 4-5 дней.







