Multi-Chain Abstraction: One Codebase, 16+ Chains
When your application needs to support token-gating, on-chain governance, or NFT metadata across multiple EVM chains, you face a choice: write a separate integration per chain or build an abstraction layer that encapsulates chain differences. We chose the latter and currently support 16 chains from a single codebase. Here is the approach.
The Chain Configuration
Each chain is described by a configuration object. New chains can be added by dropping in a JSON file — no code deployment required.
interface ChainConfig {
id: number;
name: string;
rpcUrls: string[]; // Fallback list
blockTime: number; // Average in ms
finalityBlocks: number;
nativeCurrency: {
name: string;
symbol: string;
decimals: number;
};
explorer: {
url: string;
apiUrl: string;
apiKey?: string;
};
gasStrategy: "eip1559" | "legacy";
maxBlockRange: number; // For event queries
supportsEthCall: boolean; // Batch calls
}
// Example: Arbitrum One
const arbitrum: ChainConfig = {
id: 42161,
name: "Arbitrum One",
rpcUrls: [
"https://arb-mainnet.g.alchemy.com/v2/KEY",
"https://arbitrum.llamarpc.com",
],
blockTime: 250,
finalityBlocks: 1,
nativeCurrency: {
name: "Ether", symbol: "ETH", decimals: 18,
},
explorer: {
url: "https://arbiscan.io",
apiUrl: "https://api.arbiscan.io/api",
},
gasStrategy: "eip1559",
maxBlockRange: 100_000,
supportsEthCall: true,
};
The Provider Layer
The abstraction layer manages provider instances and implements automatic failover:
class ChainProvider {
private providers: ethers.JsonRpcProvider[];
private current = 0;
private config: ChainConfig;
constructor(config: ChainConfig) {
this.config = config;
this.providers = config.rpcUrls.map(
url => new ethers.JsonRpcProvider(url, config.id)
);
}
async execute<T>(
fn: (provider: ethers.Provider) => Promise<T>
): Promise<T> {
let lastError: Error | undefined;
for (let i = 0; i < this.providers.length; i++) {
const idx = (this.current + i) % this.providers.length;
try {
const result = await fn(this.providers[idx]);
this.current = idx; // Remember working provider
return result;
} catch (err) {
lastError = err as Error;
console.warn(
`RPC ${this.config.rpcUrls[idx]} failed, trying next`
);
}
}
throw new Error(
`All RPCs for ${this.config.name} failed: ${lastError?.message}`
);
}
get chainId(): number { return this.config.id; }
}
// Registry
const chains = new Map<number, ChainProvider>();
function getChain(chainId: number): ChainProvider {
const provider = chains.get(chainId);
if (!provider) throw new Error(`Chain ${chainId} not configured`);
return provider;
}
Chain-Specific Quirks
Gas Estimation
EIP-1559 chains (Ethereum, Polygon, Arbitrum) use maxFeePerGas and
maxPriorityFeePerGas. Legacy chains use a flat gasPrice.
The abstraction layer selects the strategy based on the configuration:
async function estimateGas(
chainId: number,
tx: ethers.TransactionRequest
): Promise<ethers.TransactionRequest> {
const chain = getChain(chainId);
const config = chainConfigs.get(chainId)!;
return chain.execute(async (provider) => {
const gasLimit = await provider.estimateGas(tx);
if (config.gasStrategy === "eip1559") {
const feeData = await provider.getFeeData();
return {
...tx,
gasLimit: gasLimit * 120n / 100n, // 20% buffer
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
};
}
// Legacy
const gasPrice = await provider.getFeeData();
return {
...tx,
gasLimit: gasLimit * 120n / 100n,
gasPrice: gasPrice.gasPrice,
};
});
}
Event Queries and Block Ranges
A frequently overlooked quirk: different chains have different limits for
eth_getLogs queries. Ethereum allows large ranges, but Polygon and
BNB Chain cap at a few thousand blocks. Our abstraction automatically chunks:
async function getLogs(
chainId: number,
filter: ethers.Filter,
fromBlock: number,
toBlock: number
): Promise<ethers.Log[]> {
const config = chainConfigs.get(chainId)!;
const chain = getChain(chainId);
const maxRange = config.maxBlockRange;
const allLogs: ethers.Log[] = [];
let current = fromBlock;
while (current <= toBlock) {
const end = Math.min(current + maxRange - 1, toBlock);
const logs = await chain.execute(provider =>
provider.getLogs({ ...filter, fromBlock: current, toBlock: end })
);
allLogs.push(...logs);
current = end + 1;
}
return allLogs;
}
Finality
Confirmation times differ drastically:
Chain | Finality blocks | Approximate time
---------------|-----------------|------------------
Ethereum | 64 | ~13 minutes
Polygon PoS | 256 | ~9 minutes
Arbitrum One | 1 (L1-final) | Seconds*
Optimism | 1 (L1-final) | Seconds*
Base | 1 (L1-final) | Seconds*
BNB Chain | 15 | ~45 seconds
Avalanche C | 1 | ~2 seconds
Gnosis Chain | 8 | ~40 seconds
* L2 transactions are final after the L1 batch post.
We use these values to decide when an event counts as confirmed. For token-gating, immediate visibility is usually sufficient; for financial operations, we wait for finality.
Multicall Batching
When you need to check token balances for 50 users simultaneously, 50 individual
RPC calls are inefficient. We use the Multicall3 contract
(0xcA11bde05977b3631167028862bE2a173976CA11), which is deployed on
all relevant chains:
async function batchBalanceCheck(
chainId: number,
tokenAddress: string,
wallets: string[]
): Promise<Map<string, bigint>> {
const chain = getChain(chainId);
const iface = new ethers.Interface([
"function balanceOf(address) view returns (uint256)",
]);
const calls = wallets.map(w => ({
target: tokenAddress,
callData: iface.encodeFunctionData("balanceOf", [w]),
}));
return chain.execute(async (provider) => {
const multicall = new ethers.Contract(
"0xcA11bde05977b3631167028862bE2a173976CA11",
["function aggregate(tuple(address target, bytes callData)[]) returns (uint256, bytes[])"],
provider
);
const [, results] = await multicall.aggregate.staticCall(calls);
const balances = new Map<string, bigint>();
results.forEach((data: string, i: number) => {
const [balance] = iface.decodeFunctionResult("balanceOf", data);
balances.set(wallets[i].toLowerCase(), balance);
});
return balances;
});
}
50 balance checks in one call instead of 50 individual ones — latency drops from ~2,400 ms to ~80 ms.
Takeaways
The investment in the abstraction layer paid off. Adding a new chain takes about 15 minutes (configuration + RPC keys) instead of several hours of integration. The critical points are not the obvious ones (provider instances, ABI calls) but the subtle differences: block ranges, gas strategies, finality models. Encapsulate these early, and your application scales with every new L2 without touching business logic.