Skip to main content
7 min read R.S.

Token-Gating Across EVM Chains: Lessons Learned

Token-gating sounds simple: user holds token X, so they get access to resource Y. In practice, it gets complex once multiple EVM chains, different token standards, and the realities of distributed systems come into play. While implementing a token-gating solution for a DAO community platform, we learned a number of lessons that we document here.

Ownership Checks by Token Standard

The three relevant standards — ERC-20, ERC-721, and ERC-1155 — each require different queries:

import { ethers } from "ethers";

// ERC-20: Fungible tokens (e.g., governance tokens)
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: specific 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;
}

So far, so straightforward. In practice, the first issues arise as soon as you support more than one chain.

RPC Latency: The Underestimated Problem

Every ownership check requires at least one RPC call. For a gate configuration that checks tokens on Ethereum, Polygon, and Arbitrum, that means three sequential calls. Our latency measurements across different RPC providers:

Chain       | Provider     | balanceOf p50 | balanceOf p99 | Error rate
------------|--------------|---------------|---------------|----------
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%

Two takeaways: first, public RPCs are unreliable — the error rate on Base Public RPC was 10x higher than dedicated providers. Second, latencies add up. A gate with three checks on three chains can take over a second in the worst case. Acceptable for a login flow, not for checking on every message.

Caching Strategy

Our solution is a multi-layered cache:

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

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

  // Layer 3: Event-based invalidation
  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: Fresh 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;
}

Additionally, we subscribe to Transfer events on the relevant token contracts. On a transfer, the cache for both sender and receiver is invalidated immediately:

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

Bridged Tokens

A user holds their tokens on Arbitrum, but the gate checks Ethereum. The tokens technically exist on both chains, but ownership is only visible on one. Our solution: gates can be configured as an OR across multiple chains. The check runs in 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 failure ≠ gate failure
  );
  const results = await Promise.all(checks);
  return results.some(r => r === true);
}

Delegated Ownership

Some DAOs use delegation: a wallet delegates its voting power (and implicitly its token access) to another wallet. We check the delegate registry (EIP-5639 / delegate.xyz) as a fallback when the direct ownership check fails.

Reorgs and Finality

On Ethereum, a block containing a token transfer can be reversed by a reorg. We set the cache TTL based on 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: short TTL due to fast finality
  if (blocks <= 1) return 60;
  // L1: longer TTL after finality
  return 300;
}

Results

After implementing the caching layer, our numbers look like this:

Scenario                          | Latency p50 | Latency p99
----------------------------------|-------------|----------
No cache (3 chains)               | 142 ms      | 890 ms
Redis cache (hit)                 | 1.2 ms      | 4.8 ms
Local cache (hit)                 | 0.01 ms     | 0.03 ms
Cache hit rate after 1h operation | 94.2%       |
Event-based invalidation          | < 2s after transfer

Token-gating across multiple EVM chains is feasible, but the complexity does not lie in the ownership check itself. It lies in the boundary conditions: RPC reliability, cache consistency, chain finality, and the diversity of token standards. Plan for significantly more development time than the initial balanceOf call suggests.