Skip to main content
6 min read R.S.

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.