Разработка системы token-gated доступа
Token-gating — ограничение доступа к контенту или функциям на основе владения определёнными токенами. Технически задача решается за 20 минут с помощью готовых решений типа Lit Protocol или Guild.xyz. Но в 9 из 10 случаев нужна кастомная реализация: нестандартные условия, интеграция с существующей auth системой, мультичейн проверки, performance требования.
Архитектурные варианты
Client-side (плохо) — проверяем баланс прямо в браузере и показываем/скрываем контент. Контент уже в DOM, любой может его увидеть через DevTools. Годится только для soft gates (nice-to-have, не критичный контент).
Server-side (правильно) — сервер проверяет владение токеном перед выдачей контента. Клиент получает только то, к чему у него есть доступ.
JWT с blockchain claims (оптимально) — при логине через SIWE проверяем токены, вписываем права в JWT. Последующие запросы не требуют обращения к ноде — только JWT верификация.
SIWE + token check при логине
// После успешной SIWE верификации
async function createSessionToken(address: string): Promise<string> {
const [nftBalance, tokenBalance, ensName] = await Promise.all([
checkNFTOwnership(address),
checkTokenBalance(address),
resolveENS(address),
]);
const roles: string[] = [];
if (nftBalance > 0) roles.push("nft_holder");
if (tokenBalance >= parseEther("100")) roles.push("token_holder");
if (ensName) roles.push("ens_user");
return jwt.sign(
{
sub: address,
roles,
// Expires в 24 часа - баланс мог измениться
exp: Math.floor(Date.now() / 1000) + 86400,
},
process.env.JWT_SECRET!
);
}
Важно: expiry JWT. Токены продаются и покупаются. Сессия на неделю означает, что продавший NFT продолжает иметь доступ 7 дней. Для высоких stakes — короткий expiry (1-4 часа) + refresh через re-verification.
On-chain проверки через viem/wagmi
import { createPublicClient, http, erc721Abi, erc20Abi } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.ETH_RPC),
});
// Проверка ERC-721 владения
async function checkNFTOwnership(
address: `0x${string}`,
collection: `0x${string}`
): Promise<number> {
const balance = await client.readContract({
address: collection,
abi: erc721Abi,
functionName: "balanceOf",
args: [address],
});
return Number(balance);
}
// Проверка конкретного tokenId
async function checkSpecificToken(
address: `0x${string}`,
collection: `0x${string}`,
tokenId: bigint
): Promise<boolean> {
const owner = await client.readContract({
address: collection,
abi: erc721Abi,
functionName: "ownerOf",
args: [tokenId],
});
return owner.toLowerCase() === address.toLowerCase();
}
Мультичейн gates
Пользователь может держать NFT на Ethereum, а токены на Polygon. Проверяем параллельно:
const [ethBalance, polyBalance, arbBalance] = await Promise.all([
checkBalanceOnChain(address, "ethereum"),
checkBalanceOnChain(address, "polygon"),
checkBalanceOnChain(address, "arbitrum"),
]);
const hasAccess = ethBalance > 0 || polyBalance > 0 || arbBalance > 0;
Кешируем результаты в Redis с TTL 5-15 минут — постоянные вызовы к нодам дороги и медленны.
Сложные условия доступа
Real-world token gates редко бывают простыми. Типичные случаи:
// AND условие: нужен И NFT, И достаточный баланс токена
const hasAccess = nftBalance > 0 && tokenBalance >= minTokenThreshold;
// OR: достаточно любого из условий
const hasPremium = nftBalance > 0 || tokenBalance >= premiumThreshold || hasStaked;
// Trait-based: конкретные атрибуты NFT
// Требует off-chain metadata или on-chain traits
const isGoldMember = await checkTraitOwnership(address, "tier", "gold");
// Snapshot-based: баланс на конкретный блок (для airdrop eligibility)
const snapshotBalance = await client.readContract({
...tokenContract,
functionName: "balanceOfAt",
args: [address, snapshotBlockNumber],
blockNumber: snapshotBlockNumber,
});
Middleware для API routes
// Express/Fastify middleware
async function tokenGateMiddleware(req, res, next) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: "Unauthorized" });
try {
const payload = jwt.verify(token, process.env.JWT_SECRET) as JWTPayload;
// Проверяем роли из JWT (без обращения к блокчейну)
if (!payload.roles.includes("nft_holder")) {
return res.status(403).json({ error: "NFT required" });
}
req.user = payload;
next();
} catch {
return res.status(401).json({ error: "Invalid token" });
}
}
// Применяем к защищённым роутам
app.get("/api/premium/content", tokenGateMiddleware, getPremiumContent);
Frontend: UX для отказа в доступе
Хуже нет когда пользователь не понимает почему ему отказали и что нужно сделать:
function GatedContent({ requiredNFT, children }) {
const { address } = useAccount();
const { data: balance } = useReadContract({
address: requiredNFT,
abi: erc721Abi,
functionName: "balanceOf",
args: [address],
query: { enabled: !!address },
});
if (!address) {
return <ConnectWalletPrompt />;
}
if (!balance || balance === 0n) {
return (
<AccessDenied
message="Для доступа необходим NFT коллекции XYZ"
ctaText="Купить на OpenSea"
ctaUrl={`https://opensea.io/collection/xyz`}
currentFloorPrice={useFloorPrice(requiredNFT)}
/>
);
}
return children;
}
Делегирование через delegate.cash
Пользователь не хочет подключать cold wallet с ценными NFT к сайту. delegate.cash (EIP-5639) позволяет владельцу cold wallet делегировать права на hot wallet:
// Проверяем не только прямое владение, но и делегирование
const { data: isDelegated } = useReadContract({
address: DELEGATE_REGISTRY,
abi: delegateRegistryAbi,
functionName: "checkDelegateForContract",
args: [hotWallet, coldWallet, nftCollection],
});
const hasAccess = directBalance > 0 || isDelegated;
Для production gates с ценным контентом — обязательно реализуем поддержку delegate.cash.







