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.