Zum Hauptinhalt springen
6 Min. Lesezeit R.S.

Multi-Chain-Abstraktion: Eine Codebasis, 16+ Chains

Wenn Ihre Anwendung Token-Gating, On-Chain-Governance oder NFT-Metadaten über mehrere EVM-Chains unterstützen soll, stehen Sie vor einer Entscheidung: pro Chain eine eigene Integration schreiben oder eine Abstraktionsschicht bauen, die Chain-Unterschiede kapselt. Wir haben uns für Letzteres entschieden und unterstützen damit aktuell 16 Chains mit einer einzigen Codebasis. Hier ist der Ansatz.

Die Chain-Konfiguration

Jede Chain wird durch ein Konfigurationsobjekt beschrieben. Neue Chains lassen sich hinzufügen, indem eine JSON-Datei ergänzt wird — kein Code-Deployment nötig.

interface ChainConfig {
  id: number;
  name: string;
  rpcUrls: string[];          // Fallback-Liste
  blockTime: number;          // Durchschnitt in ms
  finalityBlocks: number;
  nativeCurrency: {
    name: string;
    symbol: string;
    decimals: number;
  };
  explorer: {
    url: string;
    apiUrl: string;
    apiKey?: string;
  };
  gasStrategy: "eip1559" | "legacy";
  maxBlockRange: number;      // für Event-Queries
  supportsEthCall: boolean;   // Batch-Calls
}

// Beispiel: 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,
};

Der Provider-Layer

Die Abstraktionsschicht verwaltet Provider-Instanzen und implementiert automatisches 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; // Merke funktionierenden 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-spezifische Eigenheiten

Gas-Schätzung

EIP-1559-Chains (Ethereum, Polygon, Arbitrum) verwenden maxFeePerGas und maxPriorityFeePerGas. Legacy-Chains nutzen einen festen gasPrice. Die Abstraktionsschicht wählt die Strategie basierend auf der Konfiguration:

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 und Block-Ranges

Eine oft übersehene Eigenheit: verschiedene Chains haben unterschiedliche Limits für eth_getLogs-Abfragen. Ethereum erlaubt große Ranges, aber Polygon und BNB Chain begrenzen auf wenige tausend Blöcke. Unsere Abstraktion chunked automatisch:

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

Die Bestätigungszeiten unterscheiden sich drastisch:

Chain          | Finality-Blocks | Ungefähre Zeit
---------------|-----------------|---------------
Ethereum       | 64              | ~13 Minuten
Polygon PoS    | 256             | ~9 Minuten
Arbitrum One   | 1 (L1-final)    | Sekunden*
Optimism       | 1 (L1-final)    | Sekunden*
Base           | 1 (L1-final)    | Sekunden*
BNB Chain      | 15              | ~45 Sekunden
Avalanche C    | 1               | ~2 Sekunden
Gnosis Chain   | 8               | ~40 Sekunden

* L2-Transaktionen sind nach dem L1-Batch-Post endgültig.

Wir verwenden diese Werte, um zu entscheiden, wann ein Event als bestätigt gilt. Für Token-Gating reicht oft die sofortige Sichtbarkeit; für finanzielle Operationen warten wir auf Finality.

Multicall-Batching

Wenn Sie für 50 Nutzer gleichzeitig den Token-Balance prüfen müssen, sind 50 einzelne RPC-Calls ineffizient. Wir nutzen den Multicall3-Contract (0xcA11bde05977b3631167028862bE2a173976CA11), der auf allen relevanten Chains deployed ist:

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 einem Call statt 50 einzelnen Calls — Latenz sinkt von ~2.400 ms auf ~80 ms.

Fazit

Der Aufwand für die Abstraktionsschicht hat sich gelohnt. Das Hinzufügen einer neuen Chain dauert ca. 15 Minuten (Konfiguration + RPC-Keys) statt mehrerer Stunden Integration. Die kritischen Stellen sind nicht die offensichtlichen (Provider-Instanzen, ABI-Calls), sondern die subtilen Unterschiede: Block-Ranges, Gas-Strategien, Finality-Modelle. Kapseln Sie diese früh, dann skaliert Ihre Anwendung mit jedem neuen L2, ohne dass die Business-Logik angefasst werden muss.