Zum Hauptinhalt springen
7 Min. Lesezeit R.S.

Token-Gating über EVM-Chains: Was wir gelernt haben

Token-Gating klingt einfach: Nutzer besitzt Token X, also erhält er Zugriff auf Ressource Y. In der Praxis wird es komplex, sobald mehrere EVM-Chains, verschiedene Token-Standards und die Realitäten verteilter Systeme ins Spiel kommen. Wir haben bei der Implementierung einer Token-Gating-Lösung für eine DAO-Community-Plattform eine Reihe von Lektionen gelernt, die wir hier dokumentieren.

Ownership-Checks nach Token-Standard

Die drei relevanten Standards — ERC-20, ERC-721 und ERC-1155 — erfordern unterschiedliche Abfragen:

import { ethers } from "ethers";

// ERC-20: Fungible Tokens (z.B. Governance-Token)
async function checkERC20(
  provider: ethers.Provider,
  tokenAddress: string,
  wallet: string,
  minBalance: bigint
): Promise<boolean> {
  const contract = new ethers.Contract(tokenAddress, [
    "function balanceOf(address) view returns (uint256)",
  ], provider);
  const balance: bigint = await contract.balanceOf(wallet);
  return balance >= minBalance;
}

// ERC-721: Non-Fungible Tokens
async function checkERC721(
  provider: ethers.Provider,
  tokenAddress: string,
  wallet: string,
  tokenId?: bigint // optional: spezifische Token-ID
): Promise<boolean> {
  const contract = new ethers.Contract(tokenAddress, [
    "function balanceOf(address) view returns (uint256)",
    "function ownerOf(uint256) view returns (address)",
  ], provider);

  if (tokenId !== undefined) {
    const owner = await contract.ownerOf(tokenId);
    return owner.toLowerCase() === wallet.toLowerCase();
  }
  const balance: bigint = await contract.balanceOf(wallet);
  return balance > 0n;
}

// ERC-1155: Multi-Token (fungible + non-fungible)
async function checkERC1155(
  provider: ethers.Provider,
  tokenAddress: string,
  wallet: string,
  tokenId: bigint,
  minBalance: bigint = 1n
): Promise<boolean> {
  const contract = new ethers.Contract(tokenAddress, [
    "function balanceOf(address,uint256) view returns (uint256)",
  ], provider);
  const balance: bigint = await contract.balanceOf(wallet, tokenId);
  return balance >= minBalance;
}

Soweit die Theorie. In der Praxis tritt das erste Problem auf, sobald Sie mehr als eine Chain unterstützen.

RPC-Latenz: Das unterschätzte Problem

Jeder Ownership-Check erfordert mindestens einen RPC-Call. Bei einer Gate-Konfiguration, die Token auf Ethereum, Polygon und Arbitrum prüft, sind das drei sequenzielle Calls. Unsere Messungen der Latenz verschiedener RPC-Provider:

Chain       | Provider     | balanceOf p50 | balanceOf p99 | Fehlerrate
------------|--------------|---------------|---------------|----------
Ethereum    | Alchemy      | 48 ms         | 210 ms        | 0,02 %
Ethereum    | Infura       | 62 ms         | 340 ms        | 0,05 %
Polygon     | Alchemy      | 31 ms         | 180 ms        | 0,08 %
Arbitrum    | Alchemy      | 22 ms         | 95 ms         | 0,03 %
Base        | Public RPC   | 85 ms         | 1.200 ms      | 0,41 %
Optimism    | Alchemy      | 28 ms         | 120 ms        | 0,04 %

Zwei Erkenntnisse: Erstens sind öffentliche RPCs unzuverlässig — die Fehlerrate bei Base Public RPC war 10x höher als bei dedizierten Providern. Zweitens summieren sich die Latenzen. Ein Gate mit drei Checks auf drei Chains kann im Worst Case über eine Sekunde dauern. Für einen Login-Flow ist das akzeptabel, für die Prüfung bei jeder Nachricht nicht.

Caching-Strategie

Unsere Lösung ist ein mehrschichtiger Cache:

interface TokenGateCache {
  // Layer 1: In-Memory (pro Instanz), TTL 30s
  local: Map<string, { result: boolean; expires: number }>;

  // Layer 2: Redis, TTL 5 Minuten
  redis: Redis;

  // Layer 3: Event-basierte Invalidierung
  eventSubscriptions: Map<string, ethers.Contract>;
}

async function checkGate(
  gate: GateConfig,
  wallet: string
): Promise<boolean> {
  const key = `gate:${gate.id}:${wallet.toLowerCase()}`;

  // Layer 1
  const local = cache.local.get(key);
  if (local && local.expires > Date.now()) return local.result;

  // Layer 2
  const cached = await cache.redis.get(key);
  if (cached !== null) {
    const result = cached === "1";
    cache.local.set(key, {
      result,
      expires: Date.now() + 30_000,
    });
    return result;
  }

  // Layer 3: Frischer RPC-Call
  const result = await performCheck(gate, wallet);
  await cache.redis.setex(key, 300, result ? "1" : "0");
  cache.local.set(key, {
    result,
    expires: Date.now() + 30_000,
  });
  return result;
}

Zusätzlich abonnieren wir Transfer-Events der relevanten Token-Contracts. Bei einem Transfer wird der Cache für Sender und Empfänger sofort invalidiert:

function subscribeToTransfers(
  gate: GateConfig,
  provider: ethers.Provider
) {
  const contract = new ethers.Contract(gate.tokenAddress, [
    "event Transfer(address indexed from, address indexed to, uint256 value)",
  ], provider);

  contract.on("Transfer", async (from, to) => {
    const keys = [
      `gate:${gate.id}:${from.toLowerCase()}`,
      `gate:${gate.id}:${to.toLowerCase()}`,
    ];
    await Promise.all(keys.map(k => cache.redis.del(k)));
    keys.forEach(k => cache.local.delete(k));
  });
}

Edge Cases

Gebridgte Tokens

Ein Nutzer hält seine Token auf Arbitrum, das Gate prüft Ethereum. Die Token existieren technisch auf beiden Chains, aber die Ownership ist nur auf einer sichtbar. Unsere Lösung: Gates können als OR-Verknüpfung über mehrere Chains konfiguriert werden. Der Check läuft parallel:

async function checkMultiChainGate(
  gate: MultiChainGateConfig,
  wallet: string
): Promise<boolean> {
  const checks = gate.chains.map(chain =>
    checkGate({ ...gate, chainId: chain.id, rpcUrl: chain.rpc }, wallet)
      .catch(() => false) // Chain-Ausfall ≠ Gate-Ausfall
  );
  const results = await Promise.all(checks);
  return results.some(r => r === true);
}

Delegierte Ownership

Manche DAOs nutzen Delegation: Ein Wallet delegiert seine Stimmrechte (und damit implizit seinen Token-Zugriff) an ein anderes Wallet. Wir prüfen die delegate-Registry (EIP-5639 bzw. delegate.xyz) als Fallback, falls der direkte Ownership-Check fehlschlägt.

Reorgs und Finality

Auf Ethereum kann ein Block, der einen Token-Transfer enthält, durch einen Reorg rückgängig gemacht werden. Wir setzen den Cache-TTL abhängig von der Chain-Finality:

const FINALITY_BLOCKS: Record<number, number> = {
  1:      64,   // Ethereum: ~13 min (post-Merge)
  137:    256,  // Polygon: ~9 min
  42161:  1,    // Arbitrum: L1-finalized
  10:     1,    // Optimism: L1-finalized
  8453:   1,    // Base: L1-finalized
};

function getCacheTTL(chainId: number): number {
  const blocks = FINALITY_BLOCKS[chainId] ?? 64;
  // L2 Rollups: kurzer TTL, da schnelle Finality
  if (blocks <= 1) return 60;
  // L1: längerer TTL nach Finality
  return 300;
}

Ergebnisse

Nach der Implementierung des Caching-Layers sehen unsere Zahlen so aus:

Szenario                          | Latenz p50 | Latenz p99
----------------------------------|------------|----------
Ohne Cache (3 Chains)             | 142 ms     | 890 ms
Mit Redis-Cache (Hit)             | 1,2 ms     | 4,8 ms
Mit Local-Cache (Hit)             | 0,01 ms    | 0,03 ms
Cache-Hitrate nach 1h Betrieb     | 94,2 %     |
Event-basierte Invalidierung      | < 2s nach Transfer

Token-Gating über mehrere EVM-Chains ist machbar, aber die Komplexität liegt nicht im Ownership-Check selbst, sondern in den Randbedingungen: RPC-Zuverlässigkeit, Cache-Konsistenz, Chain-Finality und die Vielfalt der Token-Standards. Planen Sie dafür deutlich mehr Entwicklungszeit ein als für den initialen balanceOf-Call.