Zum Inhalt springen
·29 Min. Lesezeit

Ethereum für Webentwickler: Smart Contracts ohne den Hype

Die Ethereum-Konzepte, die jeder Webentwickler braucht: Accounts, Transaktionen, Smart Contracts, ABI-Encoding, ethers.js, WAGMI und On-Chain-Daten lesen ohne eigenen Node.

Teilen:X / TwitterLinkedIn

Die meisten "Ethereum für Entwickler"-Inhalte fallen in zwei Kategorien: übervereinfachte Analogien, die dir nicht beim Bauen helfen, oder tiefe Protokollspezifikationen, die davon ausgehen, dass du bereits weißt, was ein Merkle Patricia Trie ist. Beides ist nutzlos, wenn du ein Webentwickler bist, der einen Token-Kontostand lesen, einen Benutzer eine Transaktion signieren lassen oder NFT-Metadaten in einer React-App anzeigen möchte.

Dieser Beitrag ist der praktische Mittelweg. Ich erkläre genau, was passiert, wenn dein Frontend mit Ethereum kommuniziert, welche Bestandteile es gibt und wie das moderne Tooling (ethers.js, viem, WAGMI) auf Konzepte abbildet, die du vom Bauen von Webanwendungen bereits verstehst.

Keine Metaphern über Verkaufsautomaten. Kein "stell dir eine Welt vor, in der..." Nur das technische Modell und der Code.

Das mentale Modell#

Ethereum ist eine replizierte Zustandsmaschine. Jeder Node im Netzwerk pflegt eine identische Kopie des Zustands — ein massiver Key-Value-Store, der Adressen auf Kontodaten abbildet. Wenn du "eine Transaktion sendest", schlägst du einen Zustandsübergang vor. Wenn genug Validatoren zustimmen, dass er gültig ist, wird der Zustand aktualisiert. Das war's.

Der Zustand selbst ist unkompliziert. Es ist eine Zuordnung von 20-Byte-Adressen zu Account-Objekten. Jeder Account hat vier Felder:

  • nonce: Wie viele Transaktionen dieser Account gesendet hat (für EOAs) oder wie viele Contracts er erstellt hat (für Contract-Accounts). Das verhindert Replay-Angriffe.
  • balance: Wie viel ETH dieser Account besitzt, in wei (1 ETH = 10^18 wei). Ja, das ist eine absurd kleine Einheit. Sie existiert, weil Ethereum keine Fließkommazahlen hat.
  • codeHash: Für Contract-Accounts, der Hash des EVM-Bytecodes. Für EOAs (normale Benutzeraccounts) ist das leer.
  • storageRoot: Für Contract-Accounts, die Wurzel eines Merkle-Baums, der den persistenten Speicher des Contracts enthält. Für EOAs leer.

EOAs vs Contract-Accounts#

EOA (Externally Owned Account): Das ist eine Wallet. Kontrolliert durch einen privaten Schlüssel. Kann Transaktionen initiieren. Hat keinen Code. Dein MetaMask-Account ist ein EOA.

Contract Account: Kontrolliert durch seinen Code, nicht durch einen privaten Schlüssel. Kann Transaktionen nicht initiieren — er kann nur auf sie reagieren. Hat Code und persistenten Speicher.

Das ist eine fundamentale Einschränkung: Smart Contracts können nicht von selbst handeln. Sie können keinen Cron-Job laufen lassen, kein externes API aufrufen und keine Transaktion auslösen. Sie reagieren nur auf eingehende Transaktionen. Jeder Zustandsübergang auf Ethereum beginnt mit einem EOA, der eine Transaktion signiert.

Transaktionen#

Eine Transaktion ist eine signierte Datenstruktur. Hier sind die Felder, die wichtig sind:

typescript
interface Transaction {
  // Wer diese Transaktion empfängt — eine EOA-Adresse oder Contract-Adresse
  to: string;      // 20-Byte Hex-Adresse, oder null für Contract-Deployment
  // Wie viel ETH gesendet wird (in wei)
  value: bigint;   // Kann 0n sein für reine Contract-Aufrufe
  // Kodierte Funktionsaufruf-Daten, oder leer für einfache ETH-Transfers
  data: string;    // Hex-kodierte Bytes, "0x" für einfache Transfers
  // Sequenzieller Zähler, verhindert Replay-Angriffe
  nonce: number;   // Muss exakt dem aktuellen Nonce des Senders entsprechen
  // Gas-Limit — maximales Gas, das diese Tx verbrauchen kann
  gasLimit: bigint;
  // EIP-1559 Gebührenparameter
  maxFeePerGas: bigint;
  maxPriorityFeePerGas: bigint;
  // Chain-Identifikator (1 = Mainnet, 11155111 = Sepolia, 137 = Polygon)
  chainId: number;
}

Der Lebenszyklus einer Transaktion#

  1. Konstruktion: Deine App baut das Transaktionsobjekt. Wenn du eine Contract-Funktion aufrufst, enthält das data-Feld den ABI-kodierten Funktionsaufruf (dazu später mehr).

  2. Signierung: Der private Schlüssel signiert die RLP-kodierte Transaktion und erzeugt v, r, s Signaturkomponenten. Das beweist, dass der Sender genau diese Transaktion autorisiert hat. Die Senderadresse wird aus der Signatur abgeleitet — sie ist nicht explizit in der Transaktion.

  3. Broadcasting: Die signierte Transaktion wird an einen RPC-Node via eth_sendRawTransaction gesendet. Der Node validiert sie (korrekter Nonce, ausreichendes Guthaben, gültige Signatur) und fügt sie seinem Mempool hinzu.

  4. Mempool: Die Transaktion wartet in einem Pool ausstehender Transaktionen. Validatoren wählen Transaktionen für den nächsten Block aus, wobei sie generell höhere Trinkgelder bevorzugen. Hier passiert Front-Running — andere Akteure können deine ausstehende Transaktion sehen und ihre eigene mit einem höheren Trinkgeld senden, um vor deiner ausgeführt zu werden.

  5. Aufnahme: Ein Validator nimmt deine Transaktion in einen Block auf. Die EVM führt sie aus. Wenn sie erfolgreich ist, werden Zustandsänderungen angewandt. Wenn sie revertet, werden Zustandsänderungen rückgängig gemacht — aber du zahlst trotzdem für das bis zum Revert verbrauchte Gas.

  6. Finalität: Bei Proof-of-Stake Ethereum wird ein Block nach zwei Epochen (~12,8 Minuten) "finalisiert". Vor der Finalität sind Chain-Reorganisationen theoretisch möglich (wenn auch selten). Die meisten Apps behandeln 1-2 Blockbestätigungen als "gut genug" für unkritische Operationen.

So sieht ein einfacher ETH-Transfer mit ethers.js v6 aus:

typescript
import { ethers } from "ethers";
 
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const wallet = new ethers.Wallet("0xYOUR_PRIVATE_KEY", provider);
 
const tx = await wallet.sendTransaction({
  to: "0xRecipientAddress...",
  value: ethers.parseEther("0.1"), // Konvertiert "0.1" zu wei (100000000000000000n)
});
 
console.log("Tx hash:", tx.hash);
 
// Auf Aufnahme in einen Block warten
const receipt = await tx.wait();
console.log("Block number:", receipt.blockNumber);
console.log("Gas used:", receipt.gasUsed.toString());
console.log("Status:", receipt.status); // 1 = Erfolg, 0 = Revert

Und dasselbe mit viem:

typescript
import { createWalletClient, http, parseEther } from "viem";
import { mainnet } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
 
const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY");
 
const client = createWalletClient({
  account,
  chain: mainnet,
  transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
 
const hash = await client.sendTransaction({
  to: "0xRecipientAddress...",
  value: parseEther("0.1"),
});
 
console.log("Tx hash:", hash);

Beachte den Unterschied: ethers gibt ein TransactionResponse-Objekt mit einer .wait()-Methode zurück. Viem gibt nur den Hash zurück — du nutzt einen separaten publicClient.waitForTransactionReceipt({ hash })-Aufruf, um auf die Bestätigung zu warten. Diese Trennung der Zuständigkeiten ist beabsichtigt in viems Design.

Smart Contracts#

Ein Smart Contract ist deployter Bytecode plus persistenter Speicher an einer bestimmten Adresse. Wenn du einen Contract "aufrufst", sendest du eine Transaktion (oder machst einen Read-Only-Aufruf) mit dem data-Feld auf einen kodierten Funktionsaufruf gesetzt.

Bytecode und ABI#

Der Bytecode ist der kompilierte EVM-Code. Du interagierst nicht direkt damit. Es ist das, was die EVM ausführt.

Das ABI (Application Binary Interface) ist eine JSON-Beschreibung der Schnittstelle des Contracts. Es sagt deiner Client-Bibliothek, wie Funktionsaufrufe kodiert und Rückgabewerte dekodiert werden. Denke daran wie eine OpenAPI-Spezifikation für einen Contract.

Hier ein Fragment eines ERC-20 Token ABI:

typescript
const ERC20_ABI = [
  // Read-Only-Funktionen (view/pure — keine Gaskosten bei externem Aufruf)
  "function name() view returns (string)",
  "function symbol() view returns (string)",
  "function decimals() view returns (uint8)",
  "function totalSupply() view returns (uint256)",
  "function balanceOf(address owner) view returns (uint256)",
  "function allowance(address owner, address spender) view returns (uint256)",
 
  // Zustandsändernde Funktionen (erfordern Transaktion, kosten Gas)
  "function transfer(address to, uint256 amount) returns (bool)",
  "function approve(address spender, uint256 amount) returns (bool)",
  "function transferFrom(address from, address to, uint256 amount) returns (bool)",
 
  // Events (werden während der Ausführung emittiert, in Transaktionslogs gespeichert)
  "event Transfer(address indexed from, address indexed to, uint256 value)",
  "event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;

Ethers.js akzeptiert dieses "menschenlesbare ABI"-Format. Viem kann es auch verwenden, aber oft arbeitest du mit dem vollständigen JSON-ABI, das der Solidity-Compiler generiert. Beide sind äquivalent — das menschenlesbare Format ist einfach bequemer für gängige Interfaces.

Wie Funktionsaufrufe kodiert werden#

Das ist der Teil, den die meisten Tutorials überspringen, und der Teil, der dir Stunden beim Debugging erspart.

Wenn du transfer("0xBob...", 1000000) aufrufst, wird das data-Feld der Transaktion auf folgendes gesetzt:

0xa9059cbb                                                         // Function Selector
0000000000000000000000000xBob...000000000000000000000000             // address, auf 32 Bytes aufgefüllt
00000000000000000000000000000000000000000000000000000000000f4240     // uint256 amount (1000000 in Hex)

Der Function Selector sind die ersten 4 Bytes des Keccak-256-Hashs der Funktionssignatur:

keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = erste 4 Bytes = 0xa9059cbb

Die restlichen Bytes sind die ABI-kodierten Argumente, jedes auf 32 Bytes aufgefüllt. Dieses Kodierungsschema ist deterministisch — derselbe Funktionsaufruf erzeugt immer dieselben Calldata.

Warum ist das wichtig? Weil du, wenn du rohe Transaktionsdaten auf Etherscan siehst, die mit 0xa9059cbb beginnen, weißt, dass es ein transfer-Aufruf ist. Wenn deine Transaktion revertet und die Fehlermeldung nur ein Hex-Blob ist, kannst du sie mit dem ABI dekodieren. Und wenn du Transaktions-Batches baust oder mit Multicall-Contracts interagierst, wirst du Calldata manuell kodieren.

So kodierst und dekodierst du manuell mit ethers.js:

typescript
import { ethers } from "ethers";
 
const iface = new ethers.Interface(ERC20_ABI);
 
// Funktionsaufruf kodieren
const calldata = iface.encodeFunctionData("transfer", [
  "0xBobAddress...",
  1000000n,
]);
console.log(calldata);
// 0xa9059cbb000000000000000000000000bob...000000000000000000000000000f4240
 
// Calldata zurück zu Funktionsname und Argumenten dekodieren
const decoded = iface.parseTransaction({ data: calldata });
console.log(decoded.name);       // "transfer"
console.log(decoded.args[0]);    // "0xBobAddress..."
console.log(decoded.args[1]);    // 1000000n (BigInt)
 
// Rückgabedaten einer Funktion dekodieren
const returnData = "0x0000000000000000000000000000000000000000000000000000000000000001";
const result = iface.decodeFunctionResult("transfer", returnData);
console.log(result[0]); // true

Storage Slots#

Contract-Speicher ist ein Key-Value-Store, bei dem sowohl Schlüssel als auch Werte 32 Bytes groß sind. Solidity weist Storage Slots sequentiell ab 0 zu. Die erste deklarierte Zustandsvariable kommt in Slot 0, die nächste in Slot 1 und so weiter. Mappings und dynamische Arrays verwenden ein hash-basiertes Schema.

Du kannst den Speicher jedes Contracts direkt lesen, selbst wenn die Variable in Solidity als private markiert ist. "Private" bedeutet nur, dass andere Contracts sie nicht lesen können — jeder kann sie via eth_getStorageAt lesen:

typescript
// Storage Slot 0 eines Contracts lesen
const slot0 = await provider.getStorage(
  "0xContractAddress...",
  0
);
console.log(slot0); // Roher 32-Byte Hex-Wert

So zeigen Block-Explorer "internen" Contract-Zustand an. Es gibt keine Zugriffskontrolle auf Speicherlesezugriffe. Privatsphäre auf einer öffentlichen Blockchain ist grundlegend begrenzt.

Events und Logs#

Events sind die Art des Contracts, strukturierte Daten zu emittieren, die in Transaktionslogs gespeichert werden, aber nicht im Contract-Speicher. Sie sind günstiger als Speicherschreibzugriffe (375 Gas für das erste Topic + 8 Gas pro Byte Daten, vs 20.000 Gas für einen Speicherschreibzugriff) und sie sind für effiziente Abfragen konzipiert.

Ein Event kann bis zu 3 indexed-Parameter haben (als "Topics" gespeichert) und beliebig viele nicht-indizierte Parameter (als "Data" gespeichert). Auf indizierte Parameter kann gefiltert werden — du kannst fragen "gib mir alle Transfer-Events, bei denen to diese Adresse ist." Nicht-indizierte Parameter können nicht gefiltert werden; du musst alle passenden Events abrufen und clientseitig filtern.

typescript
// Transfer-Events in Echtzeit mit ethers.js abhören
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
 
contract.on("Transfer", (from, to, value, event) => {
  console.log(`${from} -> ${to}: ${ethers.formatUnits(value, 18)} Tokens`);
  console.log("Block:", event.log.blockNumber);
  console.log("Tx hash:", event.log.transactionHash);
});
 
// Historische Events abfragen
const filter = contract.filters.Transfer(null, "0xMyAddress..."); // from=beliebig, to=spezifisch
const events = await contract.queryFilter(filter, 19000000, 19100000); // Blockbereich
 
for (const event of events) {
  console.log("Von:", event.args.from);
  console.log("Wert:", event.args.value.toString());
}

Dasselbe mit viem:

typescript
import { createPublicClient, http, parseAbiItem } from "viem";
import { mainnet } from "viem/chains";
 
const client = createPublicClient({
  chain: mainnet,
  transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
 
// Historische Logs
const logs = await client.getLogs({
  address: "0xTokenAddress...",
  event: parseAbiItem(
    "event Transfer(address indexed from, address indexed to, uint256 value)"
  ),
  args: {
    to: "0xMyAddress...",
  },
  fromBlock: 19000000n,
  toBlock: 19100000n,
});
 
for (const log of logs) {
  console.log("Von:", log.args.from);
  console.log("An:", log.args.to);
  console.log("Wert:", log.args.value);
}
 
// Echtzeit-Überwachung
const unwatch = client.watchEvent({
  address: "0xTokenAddress...",
  event: parseAbiItem(
    "event Transfer(address indexed from, address indexed to, uint256 value)"
  ),
  onLogs: (logs) => {
    for (const log of logs) {
      console.log(`Transfer: ${log.args.from} -> ${log.args.to}`);
    }
  },
});
 
// unwatch() aufrufen zum Beenden

On-Chain-Daten lesen#

Hier wird Ethereum praktisch für Webentwickler. Du brauchst keinen eigenen Node. Du brauchst nicht zu minen. Du brauchst nicht mal eine Wallet. Daten von Ethereum zu lesen ist kostenlos, erlaubnisfrei und funktioniert über ein einfaches JSON-RPC API.

JSON-RPC: Das HTTP-API von Ethereum#

Jeder Ethereum-Node stellt ein JSON-RPC API bereit. Es ist buchstäblich HTTP POST mit JSON-Bodies. Am Transport-Layer gibt es nichts Blockchain-Spezifisches.

typescript
// Das macht deine Bibliothek unter der Haube
const response = await fetch("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    jsonrpc: "2.0",
    id: 1,
    method: "eth_call",
    params: [
      {
        to: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
        data: "0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
        // balanceOf(vitalik.eth)
      },
      "latest",
    ],
  }),
});
 
const result = await response.json();
console.log(result);
// { jsonrpc: "2.0", id: 1, result: "0x000000000000000000000000000000000000000000000000000000174876e800" }

Das ist ein roher eth_call. Er simuliert eine Transaktionsausführung, ohne sie tatsächlich abzusenden. Keine Gaskosten. Keine Zustandsänderung. Liest nur den Rückgabewert. So funktionieren view- und pure-Funktionen von außen — sie nutzen eth_call statt eth_sendRawTransaction.

Die zwei kritischen RPC-Methoden#

eth_call: Simuliert die Ausführung. Kostenlos. Keine Zustandsänderung. Wird für alle Leseoperationen verwendet — Kontostände prüfen, Preise lesen, View-Funktionen aufrufen. Kann auf jedem historischen Block aufgerufen werden, indem eine Blocknummer statt "latest" angegeben wird.

eth_sendRawTransaction: Sendet eine signierte Transaktion zur Aufnahme in einen Block. Kostet Gas. Ändert den Zustand (bei Erfolg). Wird für alle Schreiboperationen verwendet — Transfers, Approvals, Swaps, Mints.

Alles andere im JSON-RPC API ist entweder eine Variante dieser beiden oder eine Hilfsmethode (eth_blockNumber, eth_getTransactionReceipt, eth_getLogs usw.).

Provider: Dein Tor zur Chain#

Du betreibst keinen eigenen Node. Fast niemand tut das für die Anwendungsentwicklung. Stattdessen nutzt du einen Provider-Service:

  • Alchemy: Der beliebteste. Großartiges Dashboard, Webhook-Support, erweiterte APIs für NFTs und Token-Metadaten. Kostenloses Kontingent: ~300M Recheneinheiten/Monat.
  • Infura: Das Original. Gehört ConsenSys. Zuverlässig. Kostenloses Kontingent: 100K Requests/Tag.
  • QuickNode: Gut für Multi-Chain. Etwas anderes Preismodell.
  • Öffentliche RPC-Endpunkte: https://rpc.ankr.com/eth, https://cloudflare-eth.com. Kostenlos, aber ratenbegrenzt und gelegentlich unzuverlässig. Gut für Entwicklung, gefährlich für Produktion.
  • Tenderly: Exzellent für Simulation und Debugging. Ihr RPC enthält einen eingebauten Transaktionssimulator.

Für Produktion immer mindestens zwei Provider als Fallback konfigurieren. RPC-Ausfälle sind real und passieren zum ungünstigsten Zeitpunkt.

typescript
import { ethers } from "ethers";
 
// ethers.js v6 Fallback Provider
const provider = new ethers.FallbackProvider([
  {
    provider: new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/KEY1"),
    priority: 1,
    stallTimeout: 2000,
    weight: 2,
  },
  {
    provider: new ethers.JsonRpcProvider("https://mainnet.infura.io/v3/KEY2"),
    priority: 2,
    stallTimeout: 2000,
    weight: 1,
  },
]);

Contract-Zustand kostenlos lesen#

Das ist der Power Move, den die meisten Web2-Entwickler nicht kennen: du kannst beliebige öffentliche Daten von jedem Contract auf Ethereum lesen, ohne etwas zu bezahlen, ohne Wallet und ohne andere Authentifizierung als einen API-Key für deinen RPC-Provider.

typescript
import { ethers } from "ethers";
 
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
 
// ERC-20 Interface — nur die Lesefunktionen
const erc20 = new ethers.Contract(
  "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
  [
    "function name() view returns (string)",
    "function symbol() view returns (string)",
    "function decimals() view returns (uint8)",
    "function totalSupply() view returns (uint256)",
    "function balanceOf(address) view returns (uint256)",
  ],
  provider // Beachte: provider, nicht signer. Nur Lesen.
);
 
const [name, symbol, decimals, totalSupply] = await Promise.all([
  erc20.name(),
  erc20.symbol(),
  erc20.decimals(),
  erc20.totalSupply(),
]);
 
console.log(`${name} (${symbol})`);           // "USD Coin (USDC)"
console.log(`Decimals: ${decimals}`);          // 6 (NICHT 18!)
console.log(`Total supply: ${ethers.formatUnits(totalSupply, decimals)}`);
 
// Kontostand einer bestimmten Adresse prüfen
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Balance: ${ethers.formatUnits(balance, decimals)} USDC`);

Keine Wallet. Kein Gas. Keine Transaktion. Nur ein JSON-RPC eth_call unter der Haube. Konzeptionell ist das identisch mit einem GET-Request an ein REST-API. Die Blockchain ist die Datenbank, der Contract ist das API, und eth_call ist dein SELECT-Query.

ethers.js v6#

ethers.js ist das jQuery von Web3 — es war die erste Bibliothek, die die meisten Entwickler gelernt haben, und sie ist immer noch die am meisten verwendete. Version 6 ist eine deutliche Verbesserung gegenüber v5, mit nativem BigInt-Support (endlich), ESM-Modulen und einem saubereren API.

Die drei Kernabstraktionen#

Provider: Eine Nur-Lese-Verbindung zur Blockchain. Kann View-Funktionen aufrufen, Blocks abrufen, Logs lesen. Kann nicht signieren oder Transaktionen senden.

typescript
import { ethers } from "ethers";
 
// Mit einem Node verbinden
const provider = new ethers.JsonRpcProvider("https://...");
 
// Grundlegende Abfragen
const blockNumber = await provider.getBlockNumber();
const balance = await provider.getBalance("0xAddress...");
const block = await provider.getBlock(blockNumber);
const txCount = await provider.getTransactionCount("0xAddress...");

Signer: Eine Abstraktion über einen privaten Schlüssel. Kann Transaktionen und Nachrichten signieren. Ein Signer ist immer mit einem Provider verbunden.

typescript
// Aus einem privaten Schlüssel (serverseitig, Skripte)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
 
// Aus einer Browser-Wallet (clientseitig)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
 
// Adresse abrufen
const address = await signer.getAddress();

Contract: Ein JavaScript-Proxy für einen deployten Contract. Methoden auf dem Contract-Objekt entsprechen Funktionen im ABI. View-Funktionen geben Werte zurück. Zustandsändernde Funktionen geben eine TransactionResponse zurück.

typescript
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
 
// Lesen (kostenlos, gibt Wert direkt zurück)
const balance = await usdc.balanceOf("0xSomeAddress...");
// balance ist ein bigint: 1000000000n (1000 USDC mit 6 Dezimalstellen)
 
// Zum Schreiben mit einem Signer verbinden
const usdcWithSigner = usdc.connect(signer);
 
// Schreiben (kostet Gas, gibt TransactionResponse zurück)
const tx = await usdcWithSigner.transfer("0xRecipient...", 1000000n);
const receipt = await tx.wait(); // Auf Blockaufnahme warten
 
if (receipt.status === 0) {
  throw new Error("Transaktion revertet");
}

TypeChain für Type Safety#

Rohe ABI-Interaktionen sind stringtypisiert. Du kannst einen Funktionsnamen falsch schreiben, die falschen Argumenttypen übergeben oder Rückgabewerte fehlinterpretieren. TypeChain generiert TypeScript-Typen aus deinen ABI-Dateien:

typescript
// Ohne TypeChain — keine Typprüfung
const balance = await contract.balanceOf("0x...");
// balance ist 'any'. Keine Autovervollständigung. Leicht falsch zu verwenden.
 
// Mit TypeChain — volle Type Safety
import { USDC__factory } from "./typechain";
 
const usdc = USDC__factory.connect(USDC_ADDRESS, provider);
const balance = await usdc.balanceOf("0x...");
// balance ist BigNumber. Autovervollständigung funktioniert. Typfehler zur Kompilierzeit erkannt.

Für neue Projekte erwäge viems eingebaute Typinferenz aus ABIs stattdessen. Sie erreicht dasselbe Ergebnis ohne einen separaten Codegenerierungsschritt.

Events abhören#

Echtzeit-Event-Streaming ist kritisch für responsive dApps. ethers.js nutzt WebSocket-Provider dafür:

typescript
// WebSocket für Echtzeit-Events
const wsProvider = new ethers.WebSocketProvider("wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
 
const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wsProvider);
 
// Alle Transfer-Events abhören
contract.on("Transfer", (from, to, value, event) => {
  console.log(`Transfer: ${from} -> ${to}`);
  console.log(`Betrag: ${ethers.formatUnits(value, 6)} USDC`);
});
 
// Transfers AN eine bestimmte Adresse abhören
const filter = contract.filters.Transfer(null, "0xMyAddress...");
contract.on(filter, (from, to, value) => {
  console.log(`Eingehender Transfer: ${ethers.formatUnits(value, 6)} USDC von ${from}`);
});
 
// Aufräumen wenn fertig
contract.removeAllListeners();

WAGMI + Viem: Der moderne Stack#

WAGMI (We're All Gonna Make It) ist eine React-Hooks-Bibliothek für Ethereum. Viem ist der zugrunde liegende TypeScript-Client, den sie nutzt. Zusammen haben sie weitgehend ethers.js + web3-react als Standard-Stack für Frontend-dApp-Entwicklung abgelöst.

Warum der Wechsel? Drei Gründe: volle TypeScript-Inferenz aus ABIs (kein Codegen nötig), kleinere Bundle-Größe und React Hooks, die das unübersichtliche asynchrone State-Management von Wallet-Interaktionen übernehmen.

Setup#

typescript
// wagmi.config.ts
import { createConfig, http } from "wagmi";
import { mainnet, sepolia } from "wagmi/chains";
import { injected, walletConnect } from "wagmi/connectors";
 
export const config = createConfig({
  chains: [mainnet, sepolia],
  connectors: [
    injected(),
    walletConnect({ projectId: "YOUR_WALLETCONNECT_PROJECT_ID" }),
  ],
  transports: {
    [mainnet.id]: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
    [sepolia.id]: http("https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY"),
  },
});
typescript
// app/providers.tsx
"use client";
 
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./wagmi.config";
 
const queryClient = new QueryClient();
 
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProvider>
  );
}

Contract-Daten lesen#

useReadContract ist der Hook, den du am meisten nutzen wirst. Er umhüllt eth_call mit React Query Caching, Refetching und Loading/Error-States:

typescript
"use client";
 
import { useReadContract } from "wagmi";
import { formatUnits } from "viem";
 
const ERC20_ABI = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "owner", type: "address" }],
    outputs: [{ name: "balance", type: "uint256" }],
  },
] as const;
 
function TokenBalance({ address }: { address: `0x${string}` }) {
  const { data: balance, isLoading, error } = useReadContract({
    address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
    abi: ERC20_ABI,
    functionName: "balanceOf",
    args: [address],
  });
 
  if (isLoading) return <span>Lädt...</span>;
  if (error) return <span>Fehler: {error.message}</span>;
 
  // balance ist als bigint typisiert, weil das ABI uint256 angibt
  return <span>{formatUnits(balance ?? 0n, 6)} USDC</span>;
}

Beachte das as const auf dem ABI. Das ist kritisch. Ohne es verliert TypeScript die literalen Typen und balance wird unknown statt bigint. Das gesamte Typinferenzsystem hängt von Const Assertions ab.

In Contracts schreiben#

useWriteContract übernimmt den gesamten Lebenszyklus: Wallet-Prompt, Signierung, Broadcasting und Bestätigungsverfolgung.

typescript
"use client";
 
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseUnits } from "viem";
 
function SendTokens() {
  const { writeContract, data: hash, isPending, error } = useWriteContract();
 
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
    hash,
  });
 
  function handleSend() {
    writeContract({
      address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
      abi: ERC20_ABI,
      functionName: "transfer",
      args: [
        "0xRecipientAddress...",
        parseUnits("100", 6), // 100 USDC
      ],
    });
  }
 
  return (
    <div>
      <button onClick={handleSend} disabled={isPending}>
        {isPending ? "In Wallet bestätigen..." : "100 USDC senden"}
      </button>
 
      {hash && <p>Transaktion: {hash}</p>}
      {isConfirming && <p>Warte auf Bestätigung...</p>}
      {isSuccess && <p>Transfer bestätigt!</p>}
      {error && <p>Fehler: {error.message}</p>}
    </div>
  );
}

Events überwachen#

useWatchContractEvent richtet eine WebSocket-Subscription für Echtzeit-Event-Monitoring ein:

typescript
"use client";
 
import { useWatchContractEvent } from "wagmi";
import { useState } from "react";
import { formatUnits } from "viem";
 
interface TransferEvent {
  from: string;
  to: string;
  value: string;
}
 
function LiveTransfers() {
  const [transfers, setTransfers] = useState<TransferEvent[]>([]);
 
  useWatchContractEvent({
    address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
    abi: ERC20_ABI,
    eventName: "Transfer",
    onLogs(logs) {
      const newTransfers = logs.map((log) => ({
        from: log.args.from as string,
        to: log.args.to as string,
        value: formatUnits(log.args.value as bigint, 6),
      }));
      setTransfers((prev) => [...newTransfers, ...prev].slice(0, 50));
    },
  });
 
  return (
    <ul>
      {transfers.map((t, i) => (
        <li key={i}>
          {t.from.slice(0, 8)}... → {t.to.slice(0, 8)}...: {t.value} USDC
        </li>
      ))}
    </ul>
  );
}

Wallet-Verbindungsmuster#

Eine Wallet des Benutzers zu verbinden ist das "Login" von Web3. Nur dass es kein Login ist. Es gibt keine Session, kein Cookie, keinen serverseitigen State. Die Wallet-Verbindung gibt deiner App die Erlaubnis, die Adresse des Benutzers zu lesen und Transaktionssignaturen anzufordern. Das war's.

Das EIP-1193 Provider Interface#

Jede Wallet stellt ein Standard-Interface bereit, das von EIP-1193 definiert wird. Es ist ein Objekt mit einer request-Methode:

typescript
interface EIP1193Provider {
  request(args: { method: string; params?: unknown[] }): Promise<unknown>;
  on(event: string, handler: (...args: unknown[]) => void): void;
  removeListener(event: string, handler: (...args: unknown[]) => void): void;
}

MetaMask injiziert dies als window.ethereum. Andere Wallets injizieren entweder ihre eigene Eigenschaft oder nutzen ebenfalls window.ethereum (was zu Konflikten führt — das "Wallet Wars"-Problem, teilweise gelöst durch EIP-6963).

typescript
// Low-Level Wallet-Interaktion (solltest du nicht direkt machen, aber es ist nützlich zum Verständnis)
 
// Account-Zugriff anfordern
const accounts = await window.ethereum.request({
  method: "eth_requestAccounts",
});
console.log("Verbundene Adresse:", accounts[0]);
 
// Aktuelle Chain abrufen
const chainId = await window.ethereum.request({
  method: "eth_chainId",
});
console.log("Chain ID:", parseInt(chainId, 16)); // "0x1" -> 1 (Mainnet)
 
// Auf Account-Wechsel lauschen (Benutzer wechselt Account in MetaMask)
window.ethereum.on("accountsChanged", (accounts: string[]) => {
  if (accounts.length === 0) {
    console.log("Wallet getrennt");
  } else {
    console.log("Gewechselt zu:", accounts[0]);
  }
});
 
// Auf Chain-Wechsel lauschen (Benutzer wechselt Netzwerk)
window.ethereum.on("chainChanged", (chainId: string) => {
  // Der empfohlene Ansatz ist, die Seite neu zu laden
  window.location.reload();
});

EIP-6963: Multi-Wallet-Erkennung#

Der alte window.ethereum-Ansatz funktioniert nicht, wenn Benutzer mehrere Wallets installiert haben. Welche bekommt window.ethereum? Die zuletzt injizierte? Die erste? Es ist eine Race Condition.

EIP-6963 behebt das mit einem Erkennungsprotokoll basierend auf Browser-Events:

typescript
// Alle verfügbaren Wallets erkennen
interface EIP6963ProviderDetail {
  info: {
    uuid: string;
    name: string;
    icon: string;
    rdns: string;  // Reverse Domain Name, z.B. "io.metamask"
  };
  provider: EIP1193Provider;
}
 
const wallets: EIP6963ProviderDetail[] = [];
 
window.addEventListener("eip6963:announceProvider", (event: CustomEvent) => {
  wallets.push(event.detail);
});
 
// Alle Wallets auffordern, sich anzukündigen
window.dispatchEvent(new Event("eip6963:requestProvider"));
 
// Jetzt enthält 'wallets' alle installierten Wallets mit ihren Namen und Icons
// Du kannst eine Wallet-Auswahl-UI anzeigen

WAGMI übernimmt das alles für dich. Wenn du den injected()-Connector nutzt, verwendet er automatisch EIP-6963 wenn verfügbar und fällt auf window.ethereum zurück.

WalletConnect#

WalletConnect ist ein Protokoll, das Mobile Wallets über einen Relay-Server mit Desktop-dApps verbindet. Der Benutzer scannt einen QR-Code mit seiner Mobile Wallet und stellt eine verschlüsselte Verbindung her. Transaktionsanfragen werden von deiner dApp an sein Handy weitergeleitet.

Mit WAGMI ist es einfach ein weiterer Connector:

typescript
import { walletConnect } from "wagmi/connectors";
 
const connector = walletConnect({
  projectId: "YOUR_PROJECT_ID", // Hole von cloud.walletconnect.com
  showQrModal: true,
});

Chain-Wechsel handhaben#

Benutzer sind oft im falschen Netzwerk. Deine dApp ist auf Mainnet, sie sind mit Sepolia verbunden. Oder sie sind auf Polygon und du brauchst Mainnet. WAGMI bietet useSwitchChain:

typescript
"use client";
 
import { useAccount, useSwitchChain } from "wagmi";
import { mainnet } from "wagmi/chains";
 
function NetworkGuard({ children }: { children: React.ReactNode }) {
  const { chain } = useAccount();
  const { switchChain, isPending } = useSwitchChain();
 
  if (!chain) return <p>Bitte verbinde deine Wallet</p>;
 
  if (chain.id !== mainnet.id) {
    return (
      <div>
        <p>Bitte wechsle zum Ethereum Mainnet</p>
        <button
          onClick={() => switchChain({ chainId: mainnet.id })}
          disabled={isPending}
        >
          {isPending ? "Wechsle..." : "Netzwerk wechseln"}
        </button>
      </div>
    );
  }
 
  return <>{children}</>;
}

IPFS und Metadaten#

NFTs speichern keine Bilder on-chain. Die Blockchain speichert eine URI, die auf eine JSON-Metadatendatei zeigt, die wiederum eine URL zum Bild enthält. Das Standardmuster, definiert durch die tokenURI-Funktion von ERC-721:

Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"

Diese JSON-Datei folgt einem Standardschema:

json
{
  "name": "Cool NFT #42",
  "description": "Ein sehr cooler NFT",
  "image": "ipfs://QmImageHash...",
  "attributes": [
    { "trait_type": "Background", "value": "Blue" },
    { "trait_type": "Rarity", "value": "Legendary" }
  ]
}

IPFS CID vs URL#

IPFS-Adressen verwenden Content Identifiers (CIDs) — Hashes des Inhalts selbst. ipfs://QmXyz... bedeutet "der Inhalt, dessen Hash QmXyz... ist". Das ist inhaltsadressierter Speicher: die URI wird vom Inhalt abgeleitet, sodass sich der Inhalt nie ändern kann, ohne die URI zu ändern. Das ist die Unveränderlichkeitsgarantie, auf die NFTs setzen (wenn sie tatsächlich IPFS nutzen — viele verwenden stattdessen zentralisierte URLs, was ein Warnsignal ist).

Um IPFS-Inhalte im Browser anzuzeigen, brauchst du ein Gateway, das IPFS-URIs in HTTP übersetzt:

typescript
function ipfsToHttp(uri: string): string {
  if (uri.startsWith("ipfs://")) {
    const cid = uri.replace("ipfs://", "");
    return `https://ipfs.io/ipfs/${cid}`;
    // Oder ein dediziertes Gateway verwenden:
    // return `https://YOUR_PROJECT.mypinata.cloud/ipfs/${cid}`;
  }
  return uri;
}
 
// NFT-Metadaten abrufen
async function getNftMetadata(
  contractAddress: string,
  tokenId: bigint,
  provider: ethers.Provider
) {
  const contract = new ethers.Contract(
    contractAddress,
    ["function tokenURI(uint256 tokenId) view returns (string)"],
    provider
  );
 
  const tokenUri = await contract.tokenURI(tokenId);
  const httpUri = ipfsToHttp(tokenUri);
 
  const response = await fetch(httpUri);
  const metadata = await response.json();
 
  return {
    name: metadata.name,
    description: metadata.description,
    image: ipfsToHttp(metadata.image),
    attributes: metadata.attributes,
  };
}

Pinning-Services#

IPFS ist ein Peer-to-Peer-Netzwerk. Inhalte bleiben nur verfügbar, solange jemand sie hostet ("pinnt"). Wenn du ein NFT-Bild zu IPFS hochlädst und dann deinen Node abschaltest, verschwindet der Inhalt.

Pinning-Services halten deine Inhalte verfügbar:

  • Pinata: Der beliebteste. Einfaches API. Großzügiges kostenloses Kontingent (1GB). Dedizierte Gateways für schnelleres Laden.
  • NFT.Storage: Kostenlos, unterstützt von Protocol Labs (den Machern von IPFS). Speziell für NFT-Metadaten konzipiert. Nutzt Filecoin für langfristige Persistenz.
  • Web3.Storage: Ähnlich wie NFT.Storage, universeller einsetzbar.
typescript
// Zu Pinata hochladen
async function pinToIpfs(file: File): Promise<string> {
  const formData = new FormData();
  formData.append("file", file);
 
  const response = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${PINATA_JWT}`,
    },
    body: formData,
  });
 
  const result = await response.json();
  return `ipfs://${result.IpfsHash}`; // Gibt CID zurück
}

Das Indexierungsproblem#

Hier ist das schmutzige Geheimnis der Blockchain-Entwicklung: du kannst historische Daten nicht effizient von einem RPC-Node abfragen.

Willst du alle Transfer-Events für einen Token im letzten Jahr? Du musst Millionen von Blocks mit eth_getLogs scannen, in Chunks von 2.000-10.000 Blocks paginierend (das Maximum variiert je nach Provider). Das sind Tausende von RPC-Aufrufen. Es dauert Minuten bis Stunden und verbraucht dein API-Kontingent.

Willst du alle Tokens, die eine bestimmte Adresse besitzt? Es gibt keinen einzelnen RPC-Aufruf dafür. Du müsstest jedes Transfer-Event für jeden ERC-20-Contract scannen und Kontostände verfolgen. Das ist nicht machbar.

Willst du alle NFTs in einer Wallet? Gleiches Problem. Du musst jedes ERC-721 Transfer-Event über jeden NFT-Contract scannen.

Die Blockchain ist eine schreiboptimierte Datenstruktur. Sie ist exzellent darin, neue Transaktionen zu verarbeiten. Sie ist miserabel darin, historische Abfragen zu beantworten. Das ist die fundamentale Diskrepanz zwischen dem, was dApp-UIs brauchen, und dem, was die Chain nativ bietet.

The Graph Protocol#

The Graph ist ein dezentrales Indexierungsprotokoll. Du schreibst einen "Subgraph" — ein Schema und eine Menge von Event-Handlern — und The Graph indexiert die Chain und stellt die Daten über ein GraphQL-API bereit.

graphql
# Subgraph-Schema (schema.graphql)
type Transfer @entity {
  id: Bytes!
  from: Bytes!
  to: Bytes!
  value: BigInt!
  blockNumber: BigInt!
  timestamp: BigInt!
}
 
type Account @entity {
  id: Bytes!
  balance: BigInt!
  transfersFrom: [Transfer!]! @derivedFrom(field: "from")
  transfersTo: [Transfer!]! @derivedFrom(field: "to")
}
typescript
// Subgraph von deinem Frontend abfragen
const SUBGRAPH_URL =
  "https://api.studio.thegraph.com/query/YOUR_ID/YOUR_SUBGRAPH/v0.0.1";
 
async function getRecentTransfers(address: string) {
  const query = `
    query GetTransfers($address: Bytes!) {
      transfers(
        where: { from: $address }
        orderBy: blockNumber
        orderDirection: desc
        first: 100
      ) {
        id
        from
        to
        value
        blockNumber
        timestamp
      }
    }
  `;
 
  const response = await fetch(SUBGRAPH_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables: { address } }),
  });
 
  const { data } = await response.json();
  return data.transfers;
}

Der Kompromiss: The Graph fügt Latenz hinzu (typischerweise 1-2 Blocks hinter dem Chain Head) und eine weitere Abhängigkeit. Das dezentrale Netzwerk hat auch Indexierungskosten (du zahlst in GRT-Tokens). Für kleinere Projekte ist der gehostete Service (Subgraph Studio) kostenlos.

Alchemy und Moralis Enhanced APIs#

Wenn du keinen Subgraph pflegen willst, bieten sowohl Alchemy als auch Moralis vorindexierte APIs, die gängige Abfragen direkt beantworten:

typescript
// Alchemy: Alle ERC-20 Token-Kontostände für eine Adresse abrufen
const response = await fetch(
  `https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "alchemy_getTokenBalances",
      params: ["0xAddress...", "erc20"],
    }),
  }
);
 
// Gibt ALLE ERC-20 Token-Kontostände in einem Aufruf zurück
// vs. jeden möglichen ERC-20 Contract's balanceOf() scannen
typescript
// Alchemy: Alle NFTs einer Adresse abrufen
const response = await fetch(
  `https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY/getNFTs?owner=0xAddress...`
);
 
const { ownedNfts } = await response.json();
for (const nft of ownedNfts) {
  console.log(`${nft.title} - ${nft.contract.address}#${nft.tokenId}`);
}

Diese APIs sind proprietär und zentralisiert. Du tauschst Dezentralisierung gegen Entwicklererfahrung. Für die meisten dApps ist das ein lohnender Kompromiss. Deine Benutzer interessiert es nicht, ob ihre Portfolio-Ansicht von einem Subgraph oder von Alchemys Datenbank kommt. Sie interessiert, dass sie in 200ms statt 30 Sekunden lädt.

Häufige Stolperfallen#

Nach dem Ausliefern mehrerer Produktions-dApps und dem Debugging von Code anderer Teams sind das die Fehler, die ich immer wieder sehe. Jeder einzelne hat mich persönlich getroffen.

BigInt überall#

Ethereum rechnet mit sehr großen Zahlen. ETH-Kontostände sind in wei (10^18). Token-Supplies können 10^27 oder höher sein. JavaScripts Number kann Integer nur sicher bis 2^53 - 1 (etwa 9 * 10^15) darstellen. Das reicht nicht für wei-Beträge.

typescript
// FALSCH — stiller Genauigkeitsverlust
const balance = 1000000000000000000; // 1 ETH in wei
const double = balance * 2;
console.log(double); // 2000000000000000000 — sieht richtig aus, aber...
 
const largeBalance = 99999999999999999999; // ~100 ETH
console.log(largeBalance);          // 100000000000000000000 — FALSCH! Aufgerundet.
console.log(largeBalance === 100000000000000000000); // true — Datenkorruption
 
// RICHTIG — BigInt verwenden
const balance = 1000000000000000000n;
const double = balance * 2n;
console.log(double.toString()); // "2000000000000000000" — korrekt
 
const largeBalance = 99999999999999999999n;
console.log(largeBalance.toString()); // "99999999999999999999" — korrekt

Regeln für BigInt in dApp-Code:

  1. Niemals wei-Beträge zu Number konvertieren. Überall BigInt verwenden, nur zur Anzeige in menschenlesbare Strings konvertieren.
  2. Niemals Math.floor, Math.round usw. auf BigInts anwenden. Sie funktionieren nicht. Verwende Ganzzahldivision: amount / 10n ** 6n.
  3. JSON unterstützt kein BigInt. Wenn du State serialisierst, der BigInts enthält, brauchst du einen eigenen Serializer: JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v).
  4. Bibliotheks-Formatierungsfunktionen verwenden. ethers.formatEther(), ethers.formatUnits(), viems formatEther(), formatUnits(). Sie handhaben die Konvertierung korrekt.
typescript
import { formatUnits, parseUnits } from "viem";
 
// Anzeige: BigInt → menschenlesbarer String
const weiAmount = 1500000000000000000n; // 1.5 ETH
const display = formatUnits(weiAmount, 18); // "1.5"
 
// Eingabe: menschenlesbarer String → BigInt
const userInput = "1.5";
const wei = parseUnits(userInput, 18); // 1500000000000000000n
 
// USDC hat 6 Dezimalstellen, nicht 18
const usdcAmount = 100000000n; // 100 USDC
const usdcDisplay = formatUnits(usdcAmount, 6); // "100.0"

Asynchrone Wallet-Operationen#

Jede Wallet-Interaktion ist asynchron und kann auf Arten fehlschlagen, die deine App elegant handhaben muss:

typescript
// Der Benutzer kann jeden Wallet-Prompt ablehnen
try {
  const tx = await writeContract({
    address: contractAddress,
    abi: ERC20_ABI,
    functionName: "approve",
    args: [spenderAddress, amount],
  });
} catch (error) {
  if (error.code === 4001) {
    // Benutzer hat die Transaktion in seiner Wallet abgelehnt
    // Das ist normal — kein Fehler zum Melden
    showToast("Transaktion abgebrochen");
  } else if (error.code === -32603) {
    // Interner JSON-RPC-Fehler — bedeutet oft, die Transaktion würde reverten
    showToast("Transaktion würde fehlschlagen. Prüfe dein Guthaben.");
  } else {
    // Unerwarteter Fehler
    console.error("Transaktionsfehler:", error);
    showToast("Etwas ist schiefgelaufen. Bitte versuche es erneut.");
  }
}

Wichtige asynchrone Stolperfallen:

  • Wallet-Prompts blockieren auf der Seite des Benutzers. Das await in deinem Code kann 30 Sekunden dauern, während der Benutzer die Transaktionsdetails in MetaMask liest. Zeige keinen Ladespinner, der ihn denken lässt, etwas sei kaputt.
  • Der Benutzer kann mitten in der Interaktion den Account wechseln. Du forderst Approval von Account A an, der Benutzer wechselt zu Account B und genehmigt dann. Jetzt hat Account B genehmigt, aber du bist dabei, eine Transaktion von Account A zu senden. Prüfe den verbundenen Account immer erneut vor kritischen Operationen.
  • Zweistufige Schreibmuster sind üblich. Viele DeFi-Operationen erfordern approve + execute. Der Benutzer muss zwei Transaktionen signieren. Wenn er genehmigt, aber nicht ausführt, musst du den Allowance-Status prüfen und den Genehmigungsschritt beim nächsten Mal überspringen.

Netzwerk-Fehlanpassungsfehler#

Dieser verschwendet mehr Debugging-Zeit als jedes andere Problem. Dein Contract ist auf Mainnet. Deine Wallet ist auf Sepolia. Dein RPC-Provider zeigt auf Polygon. Drei verschiedene Netzwerke, drei verschiedene Zustände, drei komplett unzusammenhängende Blockchains. Und die Fehlermeldung ist meist wenig hilfreich — "execution reverted" oder "contract not found."

typescript
// Defensive Chain-Prüfung
import { useAccount, useChainId } from "wagmi";
 
function useRequireChain(requiredChainId: number) {
  const chainId = useChainId();
  const { isConnected } = useAccount();
 
  if (!isConnected) {
    return { ready: false, error: "Bitte verbinde deine Wallet" };
  }
 
  if (chainId !== requiredChainId) {
    return {
      ready: false,
      error: `Bitte wechsle zu ${getChainName(requiredChainId)}. Du bist auf ${getChainName(chainId)}.`,
    };
  }
 
  return { ready: true, error: null };
}

Front-Running in DeFi#

Wenn du einen Swap auf einer DEX einreichst, ist deine ausstehende Transaktion im Mempool sichtbar. Ein Bot kann deinen Trade sehen, ihn durch Front-Running den Preis hochtreiben, deinen Trade zu einem schlechteren Preis ausführen lassen und dann sofort danach für Profit verkaufen. Das nennt man einen "Sandwich-Angriff."

Als Frontend-Entwickler kannst du das nicht vollständig verhindern, aber du kannst es abmildern:

typescript
// Slippage-Toleranz bei einem Uniswap-artigen Swap setzen
const amountOutMin = expectedOutput * 995n / 1000n; // 0,5% Slippage-Toleranz
 
// Deadline verwenden, um langlebige ausstehende Transaktionen zu verhindern
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20 Minuten
 
await router.swapExactTokensForTokens(
  amountIn,
  amountOutMin,  // Minimaler akzeptabler Output — revertet wenn weniger
  [tokenA, tokenB],
  userAddress,
  deadline,       // Revertet wenn nicht innerhalb von 20 Minuten ausgeführt
);

Für hochwertige Transaktionen erwäge die Nutzung von Flashbots Protect RPC, das Transaktionen direkt an Block Builder sendet statt an den öffentlichen Mempool. Das verhindert Sandwich-Angriffe komplett, weil Bots deine ausstehende Transaktion nie sehen:

typescript
// Flashbots Protect als RPC-Endpunkt verwenden
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");

Dezimalstellen-Verwirrung#

Nicht alle Tokens haben 18 Dezimalstellen. USDC und USDT haben 6. WBTC hat 8. Manche Tokens haben 0, 2 oder beliebige Dezimalstellen. Lies immer die decimals() aus dem Contract, bevor du Beträge formatierst:

typescript
async function formatTokenAmount(
  tokenAddress: string,
  rawAmount: bigint,
  provider: ethers.Provider
): Promise<string> {
  const contract = new ethers.Contract(
    tokenAddress,
    ["function decimals() view returns (uint8)", "function symbol() view returns (string)"],
    provider
  );
 
  const [decimals, symbol] = await Promise.all([
    contract.decimals(),
    contract.symbol(),
  ]);
 
  return `${ethers.formatUnits(rawAmount, decimals)} ${symbol}`;
}
 
// formatTokenAmount(USDC, 1000000n, provider) → "1.0 USDC"
// formatTokenAmount(WETH, 1000000000000000000n, provider) → "1.0 WETH"
// formatTokenAmount(WBTC, 100000000n, provider) → "1.0 WBTC"

Gas-Schätzungsfehler#

Wenn estimateGas fehlschlägt, bedeutet das normalerweise, dass die Transaktion reverten würde. Aber die Fehlermeldung ist oft nur "cannot estimate gas" ohne Hinweis auf den Grund. Verwende eth_call, um die Transaktion zu simulieren und den tatsächlichen Revert-Grund zu erhalten:

typescript
import { createPublicClient, http, decodeFunctionResult } from "viem";
 
async function simulateAndGetError(client: ReturnType<typeof createPublicClient>, tx: object) {
  try {
    await client.call({
      account: tx.from,
      to: tx.to,
      data: tx.data,
      value: tx.value,
    });
    return null; // Kein Fehler — Transaktion würde erfolgreich sein
  } catch (error) {
    // Revert-Grund dekodieren
    if (error.data) {
      // Gängige Revert-Strings
      if (error.data.startsWith("0x08c379a0")) {
        // Error(string) — Standard-Revert mit Nachricht
        const reason = decodeAbiParameters(
          [{ type: "string" }],
          `0x${error.data.slice(10)}`
        );
        return `Revert: ${reason[0]}`;
      }
    }
    return error.message;
  }
}

Alles zusammenfügen#

Hier ist eine vollständige, minimale React-Komponente, die eine Wallet verbindet, einen Token-Kontostand liest und einen Transfer sendet. Das ist das Skelett jeder dApp:

typescript
"use client";
 
import { useAccount, useConnect, useDisconnect, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { injected } from "wagmi/connectors";
import { formatUnits, parseUnits } from "viem";
import { useState } from "react";
 
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
 
const USDC_ABI = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
  },
  {
    name: "transfer",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "to", type: "address" },
      { name: "amount", type: "uint256" },
    ],
    outputs: [{ name: "", type: "bool" }],
  },
] as const;
 
export function TokenDashboard() {
  const { address, isConnected } = useAccount();
  const { connect } = useConnect();
  const { disconnect } = useDisconnect();
 
  const [recipient, setRecipient] = useState("");
  const [amount, setAmount] = useState("");
 
  // Kontostand lesen — läuft nur wenn Adresse definiert ist
  const { data: balance, refetch: refetchBalance } = useReadContract({
    address: USDC_ADDRESS,
    abi: USDC_ABI,
    functionName: "balanceOf",
    args: address ? [address] : undefined,
    query: { enabled: !!address },
  });
 
  // Schreiben: Tokens transferieren
  const {
    writeContract,
    data: txHash,
    isPending: isSigning,
    error: writeError,
  } = useWriteContract();
 
  // Auf Bestätigung warten
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
    hash: txHash,
  });
 
  // Kontostand nach Bestätigung erneut abrufen
  if (isSuccess) {
    refetchBalance();
  }
 
  if (!isConnected) {
    return (
      <button onClick={() => connect({ connector: injected() })}>
        Wallet verbinden
      </button>
    );
  }
 
  return (
    <div>
      <p>Verbunden: {address}</p>
      <p>
        USDC-Kontostand:{" "}
        {balance !== undefined ? formatUnits(balance, 6) : "Lädt..."}
      </p>
 
      <div>
        <input
          placeholder="Empfängeradresse (0x...)"
          value={recipient}
          onChange={(e) => setRecipient(e.target.value)}
        />
        <input
          placeholder="Betrag (z.B. 100)"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
        />
        <button
          onClick={() => {
            writeContract({
              address: USDC_ADDRESS,
              abi: USDC_ABI,
              functionName: "transfer",
              args: [recipient as `0x${string}`, parseUnits(amount, 6)],
            });
          }}
          disabled={isSigning || isConfirming}
        >
          {isSigning
            ? "In Wallet bestätigen..."
            : isConfirming
              ? "Bestätige..."
              : "USDC senden"}
        </button>
      </div>
 
      {writeError && <p style={{ color: "red" }}>{writeError.message}</p>}
      {isSuccess && <p style={{ color: "green" }}>Transfer bestätigt!</p>}
      {txHash && (
        <a
          href={`https://etherscan.io/tx/${txHash}`}
          target="_blank"
          rel="noopener noreferrer"
        >
          Auf Etherscan anzeigen
        </a>
      )}
 
      <button onClick={() => disconnect()}>Trennen</button>
    </div>
  );
}

Wie es weitergeht#

Dieser Beitrag hat die wesentlichen Konzepte und das Tooling für Webentwickler behandelt, die in Ethereum einsteigen. In jedem Bereich gibt es noch viel mehr Tiefe:

  • Solidity: Wenn du Contracts schreiben willst, nicht nur mit ihnen interagieren. Die offizielle Dokumentation und Patrick Collins' Kurse sind die besten Startpunkte.
  • ERC-Standards: ERC-20 (fungible Tokens), ERC-721 (NFTs), ERC-1155 (Multi-Token), ERC-4626 (tokenisierte Vaults). Jeder definiert ein Standard-Interface, das alle Contracts dieser Kategorie implementieren.
  • Layer 2s: Arbitrum, Optimism, Base, zkSync. Gleiche Entwicklererfahrung, niedrigere Gaskosten, leicht andere Vertrauensannahmen. Dein ethers.js- und viem-Code funktioniert identisch — ändere einfach die Chain ID und RPC URL.
  • Account Abstraction (ERC-4337): Die nächste Evolution der Wallet-UX. Smart Contract Wallets, die Gas-Sponsoring, Social Recovery und gebatchte Transaktionen unterstützen. Dorthin entwickelt sich das "Wallet verbinden"-Muster.
  • MEV und Transaktionsreihenfolge: Wenn du DeFi baust, ist das Verständnis von Maximal Extractable Value nicht optional. Die Flashbots-Dokumentation ist die kanonische Ressource.

Das Blockchain-Ökosystem bewegt sich schnell, aber die Grundlagen in diesem Beitrag — Accounts, Transaktionen, ABI-Encoding, RPC-Aufrufe, Event-Indexierung — haben sich seit 2015 nicht geändert und werden sich auch in absehbarer Zeit nicht ändern. Lerne diese gut und alles andere ist nur API-Oberfläche.

Ähnliche Beiträge