Zum Inhalt springen
·11 Min. Lesezeit

On-Chain-Daten in Produktion: Was dir keiner erzählt

Blockchain-Daten sind nicht sauber, zuverlässig oder einfach. RPC-Rate-Limits, Chain-Reorgs, BigInt-Bugs und Indexing-Tradeoffs — harte Lektionen aus dem Ausliefern echter DeFi-Produkte.

Teilen:X / TwitterLinkedIn

Es gibt diese Fantasie, dass On-Chain-Daten von Natur aus vertrauenswürdig sind. Unveränderliches Ledger. Transparenter Zustand. Einfach auslesen und fertig.

Ich habe das auch geglaubt. Dann habe ich ein DeFi-Dashboard in Produktion ausgeliefert und drei Wochen damit verbracht herauszufinden, warum unsere Token-Salden falsch waren, unsere Event-Historie Lücken hatte und unsere Datenbank Transaktionen aus Blöcken enthielt, die nicht mehr existierten.

On-Chain-Daten sind roh, feindlich und voller Randfälle, die deine Anwendung auf Weisen brechen, die du nicht bemerkst, bis ein Nutzer einen Fehlerbericht einreicht. Dieser Beitrag behandelt alles, was ich auf die harte Tour gelernt habe.

Die Illusion zuverlässiger Daten#

Hier ist das Erste, was dir niemand sagt: Die Blockchain gibt dir keine Daten. Sie gibt dir Zustandsübergänge. Es gibt kein SELECT * FROM transfers WHERE user = '0x...'. Es gibt Logs, Receipts, Storage Slots und Call Traces — alle in Formaten kodiert, die Kontext zum Dekodieren benötigen.

Ein Transfer-Event-Log gibt dir from, to und value. Es sagt dir nicht das Token-Symbol. Es sagt dir nicht die Dezimalstellen. Es sagt dir nicht, ob dies ein legitimer Transfer ist oder ein Fee-on-Transfer-Token, der 3% abschöpft. Es sagt dir nicht, ob dieser Block in 30 Sekunden noch existieren wird.

Der "unveränderliche" Teil stimmt — sobald finalisiert. Aber Finalisierung ist nicht sofort. Und die Daten, die du von einem RPC-Node zurückbekommst, stammen nicht unbedingt aus einem finalisierten Block. Die meisten Entwickler fragen latest ab und behandeln es als Wahrheit. Das ist ein Bug, kein Feature.

Dann ist da die Kodierung. Alles ist Hex. Adressen sind gemischt groß-/kleingeschrieben mit Checksumme (oder nicht). Token-Beträge sind Ganzzahlen, multipliziert mit 10^decimals. Ein USDC-Transfer von 100$ sieht On-Chain wie 100000000 aus, weil USDC 6 Dezimalstellen hat, nicht 18. Ich habe Produktionscode gesehen, der 18 Dezimalstellen für jeden ERC-20-Token angenommen hat. Die resultierenden Salden lagen um den Faktor 10^12 daneben.

RPC-Rate-Limits ruinieren dein Wochenende#

Jede produktive Web3-App kommuniziert mit einem RPC-Endpunkt. Und jeder RPC-Endpunkt hat Rate-Limits, die weit aggressiver sind, als du erwartest.

Hier sind die Zahlen, die zählen:

  • Alchemy Free: ~30M Compute Units/Monat, 40 Requests/Minute. Das klingt großzügig, bis du merkst, dass ein einzelner eth_getLogs-Aufruf über einen breiten Block-Bereich Hunderte von CUs verbrauchen kann. Du wirst dein monatliches Kontingent an einem Tag des Indexierens aufbrauchen.
  • Infura Free: 100K Requests/Tag, ungefähr 1,15 Req/Sek. Versuch mal, 500K Blöcke an Event-Logs mit dieser Rate zu paginieren.
  • QuickNode Free: Ähnlich wie Infura — 100K Requests/Tag.

Die bezahlten Tarife helfen, aber sie beseitigen das Problem nicht. Selbst bei 200$/Monat im Alchemy Growth-Plan wird ein intensiver Indexing-Job Durchsatzlimits erreichen. Und wenn du sie erreichst, bekommst du keine sanfte Degradierung. Du bekommst 429-Fehler, manchmal mit wenig hilfreichen Nachrichten, manchmal ohne Retry-After-Header.

Die Lösung ist eine Kombination aus Fallback-Providern, Retry-Logik und sehr bewusstem Umgang damit, welche Aufrufe du machst. So sieht ein robustes RPC-Setup mit viem aus:

typescript
import { createPublicClient, fallback, http } from "viem";
import { mainnet } from "viem/chains";
 
const client = createPublicClient({
  chain: mainnet,
  transport: fallback(
    [
      http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
        retryCount: 3,
        retryDelay: 1500,
        timeout: 15_000,
      }),
      http("https://mainnet.infura.io/v3/YOUR_KEY", {
        retryCount: 3,
        retryDelay: 1500,
        timeout: 15_000,
      }),
      http("https://rpc.ankr.com/eth", {
        retryCount: 2,
        retryDelay: 2000,
        timeout: 20_000,
      }),
    ],
    { rank: true }
  ),
});

Die Option rank: true ist entscheidend. Sie weist viem an, Latenz und Erfolgsrate für jeden Transport zu messen und automatisch den schnellsten, zuverlässigsten zu bevorzugen. Wenn Alchemy anfängt dich zu drosseln, verlagert viem den Traffic auf Infura. Wenn Infura ausfällt, fällt es auf Ankr zurück.

Aber es gibt eine Feinheit: viems Standard-Retry-Logik nutzt exponentielles Backoff, was normalerweise das ist, was du willst. Allerdings gibt es seit Anfang 2025 ein bekanntes Problem, bei dem retryCount RPC-Level-Fehler (wie 429er) nicht richtig wiederholt, wenn der Batch-Modus aktiviert ist. Wenn du Requests batchst, teste dein Retry-Verhalten explizit. Vertraue nicht darauf, dass es funktioniert.

Reorgs: Der Bug, den du nicht kommen siehst#

Eine Chain-Reorganisation passiert, wenn sich das Netzwerk vorübergehend nicht einig ist, welcher Block kanonisch ist. Node A sieht Block 1000 mit Transaktionen [A, B, C]. Node B sieht einen anderen Block 1000 mit Transaktionen [A, D]. Schließlich konvergiert das Netzwerk und eine Version gewinnt.

Bei Proof-of-Work-Chains war das häufig — 1-3 Block-Reorgs passierten mehrmals täglich. Ethereum nach dem Merge ist besser. Ein erfolgreicher Reorg-Angriff erfordert jetzt die Koordination von fast 50% der Validatoren. Aber "besser" ist nicht "unmöglich." Es gab einen bemerkenswerten 7-Block-Reorg auf der Beacon Chain im Mai 2022, verursacht durch inkonsistente Client-Implementierungen des Proposer-Boost-Forks.

Und es spielt keine Rolle, wie selten Reorgs auf Ethereum Mainnet sind. Wenn du auf L2s oder Sidechains baust — Polygon, Arbitrum, Optimism — sind Reorgs häufiger. Polygon hatte historisch Reorgs von 10+ Blöcken.

Hier ist das praktische Problem: Du hast Block 18.000.000 indexiert. Du hast Events in deine Datenbank geschrieben. Dann wurde Block 18.000.000 reorged. Jetzt hat deine Datenbank Events aus einem Block, der auf der kanonischen Chain nicht existiert. Diese Events könnten Transaktionen referenzieren, die nie stattgefunden haben. Deine Nutzer sehen Phantom-Transfers.

Die Lösung hängt von deiner Architektur ab:

Option 1: Bestätigungsverzögerung. Indexiere Daten erst, wenn N Blöcke an Bestätigungen vergangen sind. Für Ethereum Mainnet geben dir 64 Blöcke (zwei Epochen) Finalitätsgarantien. Für L2s prüfe das spezifische Finalitätsmodell der Chain. Das ist einfach, fügt aber Latenz hinzu — ungefähr 13 Minuten auf Ethereum.

Option 2: Reorg-Erkennung und Rollback. Indexiere aggressiv, aber verfolge Block-Hashes. Bei jedem neuen Block überprüfe, ob der Parent-Hash mit dem vorherigen indexierten Block übereinstimmt. Wenn nicht, hast du einen Reorg erkannt: Lösche alles von den verwaisten Blöcken und indexiere die kanonische Chain neu.

typescript
interface IndexedBlock {
  number: bigint;
  hash: `0x${string}`;
  parentHash: `0x${string}`;
}
 
async function detectReorg(
  client: PublicClient,
  lastIndexed: IndexedBlock
): Promise<{ reorged: boolean; depth: number }> {
  const currentBlock = await client.getBlock({
    blockNumber: lastIndexed.number,
  });
 
  if (currentBlock.hash === lastIndexed.hash) {
    return { reorged: false, depth: 0 };
  }
 
  // Rückwärts gehen, um zu finden, wo die Chain divergiert ist
  let depth = 1;
  let checkNumber = lastIndexed.number - 1n;
 
  while (checkNumber > 0n && depth < 128) {
    const onChain = await client.getBlock({ blockNumber: checkNumber });
    const inDb = await getIndexedBlock(checkNumber); // dein DB-Lookup
 
    if (onChain.hash === inDb?.hash) {
      return { reorged: true, depth };
    }
 
    depth++;
    checkNumber--;
  }
 
  return { reorged: true, depth };
}

Das ist nicht hypothetisch. Ich hatte ein Produktionssystem, bei dem wir Events an der Chain-Spitze ohne Reorg-Erkennung indexiert haben. Drei Wochen lang funktionierte es einwandfrei. Dann verursachte ein 2-Block-Reorg auf Polygon ein doppeltes NFT-Mint-Event in unserer Datenbank. Das Frontend zeigte einem Nutzer den Besitz eines Tokens, den er nicht hatte. Diesen Bug zu debuggen dauerte zwei Tage, weil niemand Reorgs als Ursache in Betracht zog.

Das Indexing-Problem: Wähle dein Gift#

Du hast drei echte Optionen, um strukturierte On-Chain-Daten in deine Anwendung zu bekommen.

Direkte RPC-Aufrufe#

Einfach getLogs, getBlock, getTransaction direkt aufrufen. Das funktioniert für kleine Leseoperationen — den Kontostand eines Nutzers prüfen, aktuelle Events für einen einzelnen Contract abrufen. Es funktioniert nicht für historisches Indexing oder komplexe Abfragen über mehrere Contracts hinweg.

Das Problem ist kombinatorisch. Willst du alle Uniswap V3-Swaps der letzten 30 Tage? Das sind ~200K Blöcke. Bei Alchemys 2K-Block-Bereichslimit pro getLogs-Aufruf sind das mindestens 100 paginierte Requests. Jeder zählt gegen dein Rate-Limit. Und wenn ein Aufruf fehlschlägt, brauchst du Retry-Logik, Cursor-Tracking und eine Möglichkeit, dort fortzufahren, wo du aufgehört hast.

The Graph (Subgraphs)#

The Graph war die ursprüngliche Lösung. Definiere ein Schema, schreibe Mappings in AssemblyScript, deploye und frage mit GraphQL ab. Der Hosted Service wurde eingestellt — alles läuft jetzt im dezentralisierten Graph Network, was bedeutet, dass du mit GRT-Tokens für Abfragen bezahlst.

Das Gute: Standardisiert, gut dokumentiert, großes Ökosystem bestehender Subgraphs, die du forken kannst.

Das Schlechte: AssemblyScript ist mühsam. Debugging ist eingeschränkt. Deployment dauert Minuten bis Stunden. Wenn dein Subgraph einen Bug hat, deployst du neu und wartest, bis er sich von Grund auf neu synchronisiert. Das dezentralisierte Netzwerk fügt Latenz hinzu und manchmal hinken Indexer hinter der Chain-Spitze her.

Ich habe The Graph für leselastige Dashboards verwendet, bei denen eine Datenfrische von 30-60 Sekunden akzeptabel ist. Dort funktioniert es gut. Ich würde es nicht für etwas verwenden, das Echtzeit-Daten oder komplexe Business-Logik in den Mappings erfordert.

Custom Indexer (Ponder, Envio)#

Hier hat sich das Ökosystem deutlich weiterentwickelt. Ponder und Envio lassen dich Indexing-Logik in TypeScript (nicht AssemblyScript) schreiben, lokal während der Entwicklung ausführen und als eigenständige Services deployen.

Ponder gibt dir maximale Kontrolle. Du definierst Event-Handler in TypeScript, es verwaltet die Indexing-Pipeline und du bekommst eine SQL-Datenbank als Ergebnis. Der Tradeoff: Du besitzt die Infrastruktur. Skalierung, Monitoring, Reorg-Handling — das liegt bei dir.

Envio optimiert für Sync-Geschwindigkeit. Ihre Benchmarks zeigen deutlich schnellere initiale Sync-Zeiten im Vergleich zu The Graph. Sie behandeln Reorgs nativ und unterstützen HyperSync, ein spezialisiertes Protokoll für schnelleres Datenabrufen. Der Tradeoff: Du kaufst dich in ihre Infrastruktur und API ein.

Meine Empfehlung: Wenn du eine produktive DeFi-App baust und Engineering-Kapazitäten hast, nutze Ponder. Wenn du den schnellstmöglichen Sync brauchst und keine Infrastruktur verwalten willst, evaluiere Envio. Wenn du einen schnellen Prototyp brauchst oder community-gewartete Subgraphs nutzen willst, ist The Graph weiterhin gut.

getLogs ist gefährlicher als es aussieht#

Die RPC-Methode eth_getLogs ist trügerisch einfach. Gib ihr einen Block-Bereich und einige Filter, bekomme passende Event-Logs zurück. Hier ist, was in Produktion tatsächlich passiert:

Block-Bereichslimits variieren je nach Provider. Alchemy begrenzt auf 2K Blöcke (unbegrenzte Logs) oder unbegrenzte Blöcke (max 10K Logs). Infura hat andere Limits. QuickNode hat andere Limits. Ein öffentlicher RPC könnte auf 1K Blöcke begrenzen. Dein Code muss all das handhaben.

Antwortgrößenlimits existieren. Selbst innerhalb des Block-Bereichs kann die Antwort das Payload-Limit des Providers überschreiten (150MB bei Alchemy), wenn ein beliebter Contract Tausende von Events pro Block emittiert. Der Aufruf gibt keine Teilergebnisse zurück. Er schlägt fehl.

Leere Bereiche sind nicht kostenlos. Selbst wenn es null passende Logs gibt, scannt der Provider trotzdem den Block-Bereich. Das zählt gegen deine Compute Units.

Hier ist ein Paginierungs-Utility, das diese Einschränkungen behandelt:

typescript
import type { PublicClient, Log, AbiEvent } from "viem";
 
async function fetchLogsInChunks<T extends AbiEvent>(
  client: PublicClient,
  params: {
    address: `0x${string}`;
    event: T;
    fromBlock: bigint;
    toBlock: bigint;
    maxBlockRange?: bigint;
  }
): Promise<Log<bigint, number, false, T, true>[]> {
  const { address, event, fromBlock, toBlock, maxBlockRange = 2000n } = params;
  const allLogs: Log<bigint, number, false, T, true>[] = [];
 
  let currentFrom = fromBlock;
 
  while (currentFrom <= toBlock) {
    const currentTo =
      currentFrom + maxBlockRange - 1n > toBlock
        ? toBlock
        : currentFrom + maxBlockRange - 1n;
 
    try {
      const logs = await client.getLogs({
        address,
        event,
        fromBlock: currentFrom,
        toBlock: currentTo,
      });
 
      allLogs.push(...logs);
      currentFrom = currentTo + 1n;
    } catch (error) {
      // Wenn der Bereich zu groß ist (zu viele Ergebnisse), halbiere ihn
      if (isRangeTooLargeError(error) && currentTo > currentFrom) {
        const mid = currentFrom + (currentTo - currentFrom) / 2n;
        const firstHalf = await fetchLogsInChunks(client, {
          address,
          event,
          fromBlock: currentFrom,
          toBlock: mid,
          maxBlockRange,
        });
        const secondHalf = await fetchLogsInChunks(client, {
          address,
          event,
          fromBlock: mid + 1n,
          toBlock: currentTo,
          maxBlockRange,
        });
        allLogs.push(...firstHalf, ...secondHalf);
        currentFrom = currentTo + 1n;
      } else {
        throw error;
      }
    }
  }
 
  return allLogs;
}
 
function isRangeTooLargeError(error: unknown): boolean {
  const message = error instanceof Error ? error.message : String(error);
  return (
    message.includes("Log response size exceeded") ||
    message.includes("query returned more than") ||
    message.includes("exceed maximum block range")
  );
}

Die zentrale Erkenntnis ist die binäre Aufteilung bei Fehlern. Wenn ein 2K-Block-Bereich zu viele Logs zurückgibt, teile ihn in zwei 1K-Bereiche. Wenn 1K immer noch zu viel ist, teile erneut. Das passt sich automatisch an Contracts mit hoher Aktivität an, ohne dass du die Event-Dichte im Voraus kennen musst.

BigInt wird dich demütigen#

JavaScripts Number-Typ ist ein 64-Bit-Float. Er kann Ganzzahlen bis 2^53 - 1 darstellen — etwa 9 Billiarden. Das klingt nach viel, bis du merkst, dass ein Token-Betrag von 1 ETH in Wei 1000000000000000000 ist — eine Zahl mit 18 Nullen. Das ist 10^18, weit jenseits von Number.MAX_SAFE_INTEGER.

Wenn du irgendwo in deiner Pipeline versehentlich einen BigInt in eine Number konvertierst — JSON.parse, ein Datenbanktreiber, eine Logging-Bibliothek — bekommst du stillen Präzisionsverlust. Die Zahl sieht ungefähr richtig aus, aber die letzten Ziffern sind falsch. Du wirst das beim Testen nicht bemerken, weil deine Testbeträge klein sind.

Hier ist der Bug, den ich in Produktion ausgeliefert habe:

typescript
// DER BUG: Sieht harmlos aus, ist es aber nicht
function formatTokenAmount(amount: bigint, decimals: number): string {
  return (Number(amount) / Math.pow(10, decimals)).toFixed(4);
}
 
// Für kleine Beträge funktioniert das:
formatTokenAmount(1000000n, 6); // "1.0000" -- korrekt
 
// Für große Beträge bricht es still zusammen:
formatTokenAmount(123456789012345678n, 18);
// Gibt "0.1235" zurück -- FALSCH, tatsächliche Präzision geht verloren
// Number(123456789012345678n) === 123456789012345680
// Die letzten zwei Ziffern wurden durch IEEE 754 gerundet

Die Lösung: Konvertiere niemals zu Number, bevor du dividierst. Verwende viems eingebaute Hilfsfunktionen, die mit Strings und BigInts arbeiten:

typescript
import { formatUnits, parseUnits } from "viem";
 
// Korrekt: arbeitet mit BigInt, gibt String zurück
function formatTokenAmount(
  amount: bigint,
  decimals: number,
  displayDecimals: number = 4
): string {
  const formatted = formatUnits(amount, decimals);
 
  // formatUnits gibt den String mit voller Präzision zurück wie "0.123456789012345678"
  // Auf gewünschte Anzeigepräzision kürzen (nicht runden)
  const [whole, fraction = ""] = formatted.split(".");
  const truncated = fraction.slice(0, displayDecimals).padEnd(displayDecimals, "0");
 
  return `${whole}.${truncated}`;
}
 
// Auch wichtig: parseUnits für Nutzereingaben verwenden, niemals parseFloat
function parseTokenInput(input: string, decimals: number): bigint {
  // parseUnits behandelt die String-zu-BigInt-Konvertierung korrekt
  return parseUnits(input, decimals);
}

Beachte, dass ich kürze statt zu runden. Das ist beabsichtigt. In finanziellen Kontexten ist es besser, "1,0001 ETH" anzuzeigen, wenn der echte Wert "1,00009999..." ist, als "1,0001" zu zeigen, wenn der echte Wert "1,00005001..." war und aufgerundet wurde. Nutzer treffen Entscheidungen basierend auf angezeigten Beträgen. Kürzung ist die konservative Wahl.

Eine weitere Falle: JSON.stringify kann BigInt nicht serialisieren. Es wirft einen Fehler. Jede einzelne Antwort deiner API, die Token-Beträge enthält, braucht eine Serialisierungsstrategie. Ich verwende String-Konvertierung an der API-Grenze:

typescript
// API-Antwort-Serialisierer
function serializeForApi(data: Record<string, unknown>): string {
  return JSON.stringify(data, (_, value) =>
    typeof value === "bigint" ? value.toString() : value
  );
}

Caching-Strategie: Was, wie lange und wann invalidieren#

Nicht alle On-Chain-Daten haben die gleichen Frischeanforderungen. Hier ist die Hierarchie, die ich verwende:

Für immer cachen (unveränderlich):

  • Transaktionsbelege (einmal gemined, ändern sie sich nicht)
  • Finalisierte Block-Daten (Block-Hash, Zeitstempel, Transaktionsliste)
  • Contract-Bytecode
  • Historische Event-Logs aus finalisierten Blöcken

Minuten bis Stunden cachen:

  • Token-Metadaten (Name, Symbol, Dezimalstellen) — technisch unveränderlich für die meisten Token, aber Proxy-Upgrades können die Implementierung ändern
  • ENS-Auflösungen — 5 Minuten TTL funktioniert gut
  • Token-Preise — abhängig von deinen Genauigkeitsanforderungen, 30 Sekunden bis 5 Minuten

Sekunden oder gar nicht cachen:

  • Aktuelle Blocknummer
  • Kontostände und Nonce
  • Status ausstehender Transaktionen
  • Nicht finalisierte Event-Logs (wieder das Reorg-Problem)

Die Implementierung muss nicht komplex sein. Ein zweistufiger Cache mit In-Memory LRU und Redis deckt die meisten Fälle ab:

typescript
import { LRUCache } from "lru-cache";
 
const memoryCache = new LRUCache<string, unknown>({
  max: 10_000,
  ttl: 1000 * 60, // 1 Minute Standard
});
 
type CacheTier = "immutable" | "short" | "volatile";
 
const TTL_MAP: Record<CacheTier, number> = {
  immutable: 1000 * 60 * 60 * 24, // 24 Stunden im Speicher, permanent in Redis
  short: 1000 * 60 * 5,            // 5 Minuten
  volatile: 1000 * 15,             // 15 Sekunden
};
 
async function cachedRpcCall<T>(
  key: string,
  tier: CacheTier,
  fetcher: () => Promise<T>
): Promise<T> {
  // Zuerst Speicher prüfen
  const cached = memoryCache.get(key) as T | undefined;
  if (cached !== undefined) return cached;
 
  // Dann Redis (falls vorhanden)
  // const redisCached = await redis.get(key);
  // if (redisCached) { ... }
 
  const result = await fetcher();
  memoryCache.set(key, result, { ttl: TTL_MAP[tier] });
 
  return result;
}
 
// Verwendung:
const receipt = await cachedRpcCall(
  `receipt:${txHash}`,
  "immutable",
  () => client.getTransactionReceipt({ hash: txHash })
);

Die kontraintuitive Lektion: Der größte Performance-Gewinn ist nicht das Caching von RPC-Antworten. Es ist, RPC-Aufrufe komplett zu vermeiden. Jedes Mal, wenn du getBlock aufrufen willst, frage dich: Brauche ich wirklich gerade Daten von der Chain, oder kann ich sie aus bereits vorhandenen Daten ableiten? Kann ich statt zu pollen über WebSocket auf Events lauschen? Kann ich mehrere Lesevorgänge in einem einzigen Multicall bündeln?

TypeScript und Contract ABIs: Der richtige Weg#

Viems Typsystem, angetrieben von ABIType, bietet vollständige End-to-End-Typinferenz von deinem Contract-ABI bis zu deinem TypeScript-Code. Aber nur, wenn du es richtig einrichtest.

Der falsche Weg:

typescript
// Keine Typinferenz — args ist unknown[], Rückgabe ist unknown
const result = await client.readContract({
  address: "0x...",
  abi: JSON.parse(abiString), // zur Laufzeit geparst = keine Typinformationen
  functionName: "balanceOf",
  args: ["0x..."],
});

Der richtige Weg:

typescript
// ABI als const definieren für volle Typinferenz
const erc20Abi = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "balance", type: "uint256" }],
  },
  {
    name: "transfer",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "to", type: "address" },
      { name: "amount", type: "uint256" },
    ],
    outputs: [{ name: "success", type: "bool" }],
  },
] as const;
 
// Jetzt weiß TypeScript:
// - functionName vervollständigt automatisch zu "balanceOf" | "transfer"
// - args für balanceOf ist [address: `0x${string}`]
// - Rückgabetyp für balanceOf ist bigint
const balance = await client.readContract({
  address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
  abi: erc20Abi,
  functionName: "balanceOf",
  args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
});
// typeof balance = bigint -- voll typisiert

Die as const-Assertion ist das, was es funktionieren lässt. Ohne sie erweitert TypeScript den ABI-Typ zu { name: string, type: string, ... }[] und die gesamte Inferenz-Maschinerie bricht zusammen. Das ist der häufigste Fehler, den ich in Web3-TypeScript-Codebasen sehe.

Für größere Projekte verwende @wagmi/cli, um typisierte Contract-Bindings direkt aus deinem Foundry- oder Hardhat-Projekt zu generieren. Es liest deine kompilierten ABIs und erzeugt TypeScript-Dateien mit bereits angewendeten as const-Assertions. Kein manuelles ABI-Kopieren, kein Typ-Drift.

Die unbequeme Wahrheit#

Blockchain-Daten sind ein verteiltes-Systeme-Problem, das sich als Datenbankproblem tarnt. Sobald du es wie "nur eine weitere API" behandelst, sammelst du Bugs an, die in der Entwicklung unsichtbar und in Produktion intermittierend sind.

Das Tooling ist dramatisch besser geworden. Viem ist eine massive Verbesserung gegenüber ethers.js in Bezug auf Typsicherheit und Entwicklererfahrung. Ponder und Envio haben Custom Indexing zugänglich gemacht. Aber die fundamentalen Herausforderungen — Reorgs, Rate-Limits, Kodierung, Finalität — sind auf Protokollebene. Keine Bibliothek abstrahiert sie weg.

Baue mit der Annahme, dass dein RPC dich anlügen wird, deine Blöcke sich reorganisieren werden, deine Zahlen überlaufen werden und dein Cache veraltete Daten liefern wird. Dann behandle jeden Fall explizit.

So sehen produktionsreife On-Chain-Daten aus.

Ähnliche Beiträge