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.