Ethereum voor Webdevelopers: Smart Contracts Zonder de Hype
De Ethereum-concepten die elke webdeveloper nodig heeft: accounts, transacties, smart contracts, ABI-encoding, ethers.js, WAGMI en on-chain data lezen zonder eigen node.
De meeste "Ethereum voor developers"-content valt in twee categorieen: oversimplificeerde analogieen die je niet helpen iets te bouwen, of diepe protocolspecificaties die ervan uitgaan dat je al weet wat een Merkle Patricia Trie is. Geen van beide is nuttig als je een webdeveloper bent die een token-saldo wil uitlezen, een gebruiker een transactie wil laten ondertekenen, of NFT-metadata wil tonen in een React-app.
Dit bericht is de praktische middenweg. Ik ga precies uitleggen wat er gebeurt wanneer je frontend met Ethereum communiceert, wat de bewegende onderdelen zijn, en hoe de moderne tooling (ethers.js, viem, WAGMI) mapt op concepten die je al kent van het bouwen van webapplicaties.
Geen metaforen over automaten. Geen "stel je een wereld voor..." Gewoon het technische model en de code.
Het Mentale Model#
Ethereum is een gerepliceerde state machine. Elke node in het netwerk onderhoudt een identieke kopie van de state — een enorme key-value store die adressen mapt naar accountgegevens. Wanneer je "een transactie verstuurt," stel je een state-transitie voor. Als genoeg validators het ermee eens zijn dat het geldig is, wordt de state bijgewerkt. Dat is het.
De state zelf is eenvoudig. Het is een mapping van 20-byte adressen naar account-objecten. Elk account heeft vier velden:
- nonce: Hoeveel transacties dit account heeft verstuurd (voor EOA's) of hoeveel contracts het heeft aangemaakt (voor contract-accounts). Dit voorkomt replay-aanvallen.
- balance: Hoeveelheid ETH, uitgedrukt in wei (1 ETH = 10^18 wei). Altijd een big integer.
- codeHash: Hash van de EVM-bytecode. Voor gewone wallets (EOA's) is dit de hash van lege bytes. Voor contracts is het de hash van de gedeployde code.
- storageRoot: Root-hash van de storage trie van het account. Alleen contracts hebben zinvolle storage.
Er zijn twee soorten accounts, en het verschil is belangrijk voor alles wat hierna komt:
Externally Owned Accounts (EOA's) worden beheerd door een private key. Dit is wat MetaMask beheert. Ze kunnen transacties initiëren. Ze hebben geen code. Wanneer iemand "wallet" zegt, bedoelen ze een EOA.
Contract Accounts worden beheerd door hun code. Ze kunnen geen transacties initiëren — ze kunnen alleen uitvoeren als reactie op een aanroep. Ze hebben code en storage. Wanneer iemand "smart contract" zegt, bedoelen ze dit. De code is onveranderbaar zodra deze is gedeployd (met enkele uitzonderingen via proxy-patronen, wat een heel ander verhaal is).
Het cruciale inzicht: elke state-verandering op Ethereum begint met een EOA die een transactie ondertekent. Contracts kunnen andere contracts aanroepen, maar de uitvoeringsketen begint altijd bij een mens (of een bot) met een private key.
Gas: Compute Heeft een Prijs#
Elke operatie in de EVM kost gas. Twee getallen optellen kost 3 gas. Een 32-byte word opslaan kost 20.000 gas (eerste keer) of 5.000 gas (update). Storage lezen kost 2.100 gas (cold) of 100 gas (warm, al eerder benaderd in deze transactie).
Je betaalt geen gas in "gas-eenheden." Je betaalt in ETH. De totale kosten zijn:
totalCost = gasUsed * gasPrice
Na EIP-1559 (London-upgrade) werd gas-pricing een tweedelig systeem:
totalCost = gasUsed * (baseFee + priorityFee)
- baseFee: Wordt door het protocol ingesteld op basis van netwerkcongestie. Wordt verbrand (vernietigd).
- priorityFee (fooi): Gaat naar de validator. Hogere fooi = snellere opname.
- maxFeePerGas: Het maximum dat je bereid bent per gas-eenheid te betalen.
- maxPriorityFeePerGas: De maximale fooi per gas-eenheid.
Als baseFee + priorityFee > maxFeePerGas, wacht je transactie tot de baseFee daalt. Dit is waarom transacties "vastlopen" tijdens hoge congestie.
De praktische implicatie voor webdevelopers: data lezen is gratis. Data schrijven kost geld. Dit is het allerbelangrijkste architectuurverschil tussen Web2 en Web3. Elke SELECT is gratis. Elke INSERT, UPDATE, DELETE kost echt geld. Ontwerp je dApps dienovereenkomstig.
Transacties#
Een transactie is een ondertekende datastructuur. Dit zijn de velden die ertoe doen:
interface Transaction {
// Who receives this transaction — an EOA address or a contract address
to: string; // 20-byte hex address, or null for contract deployment
// How much ETH to send (in wei)
value: bigint; // Can be 0n for pure contract calls
// Encoded function call data, or empty for plain ETH transfers
data: string; // Hex-encoded bytes, "0x" for simple transfers
// Sequential counter, prevents replay attacks
nonce: number; // Must exactly equal sender's current nonce
// Gas limit — maximum gas this tx can consume
gasLimit: bigint;
// EIP-1559 fee parameters
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
// Chain identifier (1 = mainnet, 11155111 = Sepolia, 137 = Polygon)
chainId: number;
}De Levenscyclus van een Transactie#
-
Constructie: Je app bouwt het transactie-object. Als je een contractfunctie aanroept, bevat het
data-veld de ABI-geëncodeerde functie-aanroep (meer hierover hieronder). -
Ondertekening: De private key ondertekent de RLP-geëncodeerde transactie en produceert
v,r,ssignature-componenten. Dit bewijst dat de afzender deze specifieke transactie heeft geautoriseerd. Het adres van de afzender wordt afgeleid uit de signature — het staat niet expliciet in de transactie. -
Broadcasting: De ondertekende transactie wordt naar een RPC-node gestuurd via
eth_sendRawTransaction. De node valideert het (correcte nonce, voldoende saldo, geldige signature) en voegt het toe aan zijn mempool. -
Mempool: De transactie wacht in een pool van openstaande transacties. Validators selecteren transacties om in het volgende block op te nemen, waarbij ze over het algemeen hogere fooien prefereren. Dit is waar front-running plaatsvindt — andere actoren kunnen je openstaande transactie zien en hun eigen transactie indienen met een hogere fooi om voor jou uit te voeren.
-
Opname: Een validator neemt je transactie op in een block. De EVM voert het uit. Als het slaagt, worden state-veranderingen toegepast. Als het terugdraait, worden state-veranderingen ongedaan gemaakt — maar je betaalt nog steeds voor het verbruikte gas tot aan het punt van terugdraaien.
-
Finaliteit: Op proof-of-stake Ethereum wordt een block "gefinaliseerd" na twee epochs (~12,8 minuten). Voor finaliteit zijn chain-reorganisaties theoretisch mogelijk (hoewel zeldzaam). De meeste apps behandelen 1-2 block-bevestigingen als "goed genoeg" voor niet-kritische operaties.
Zo ziet het versturen van een simpele ETH-transfer eruit met ethers.js v6:
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"), // Converts "0.1" to wei (100000000000000000n)
});
console.log("Tx hash:", tx.hash);
// Wait for inclusion in a block
const receipt = await tx.wait();
console.log("Block number:", receipt.blockNumber);
console.log("Gas used:", receipt.gasUsed.toString());
console.log("Status:", receipt.status); // 1 = success, 0 = revertEn hetzelfde met viem:
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);Let op het verschil: ethers retourneert een TransactionResponse-object met een .wait()-methode. Viem retourneert alleen de hash — je gebruikt een aparte publicClient.waitForTransactionReceipt({ hash })-aanroep om op bevestiging te wachten. Deze scheiding van verantwoordelijkheden is opzettelijk in viem's ontwerp.
Smart Contracts#
Een smart contract is gedeployde bytecode plus persistente storage op een specifiek adres. Wanneer je een contract "aanroept," stuur je een transactie (of maak je een read-only call) met het data-veld ingesteld op een geëncodeerde functie-aanroep.
Bytecode en ABI#
De bytecode is de gecompileerde EVM-code. Je interacteert er niet direct mee. Het is wat de EVM uitvoert.
De ABI (Application Binary Interface) is een JSON-beschrijving van de interface van het contract. Het vertelt je client-library hoe functie-aanroepen te encoderen en retourwaarden te decoderen. Zie het als een OpenAPI-spec voor een contract.
Hier is een fragment van een ERC-20 token ABI:
const ERC20_ABI = [
// Read-only functions (view/pure — no gas cost when called externally)
"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)",
// State-changing functions (require a transaction, cost 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 (emitted during execution, stored in transaction logs)
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;Ethers.js accepteert dit "human-readable ABI"-formaat. Viem kan het ook gebruiken, maar vaak werk je met de volledige JSON ABI die door de Solidity-compiler wordt gegenereerd. Beide zijn equivalent — het human-readable formaat is gewoon handiger voor veelgebruikte interfaces.
Hoe Functie-aanroepen Worden Geëncodeerd#
Dit is het deel dat de meeste tutorials overslaan, en het is het deel dat je uren debugging bespaart.
Wanneer je transfer("0xBob...", 1000000) aanroept, wordt het data-veld van de transactie ingesteld op:
0xa9059cbb // Function selector
0000000000000000000000000xBob...000000000000000000000000 // address, padded to 32 bytes
00000000000000000000000000000000000000000000000000000000000f4240 // uint256 amount (1000000 in hex)
De function selector is de eerste 4 bytes van de Keccak-256 hash van de functie-signatuur:
keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = first 4 bytes = 0xa9059cbb
De overige bytes zijn de ABI-geëncodeerde argumenten, elk gepadded tot 32 bytes. Dit encoding-schema is deterministisch — dezelfde functie-aanroep produceert altijd dezelfde calldata.
Waarom doet dit ertoe? Omdat wanneer je ruwe transactiegegevens op Etherscan ziet en het begint met 0xa9059cbb, je weet dat het een transfer-aanroep is. Wanneer je transactie terugdraait en de foutmelding slechts een hex-blob is, kun je het decoderen met de ABI. En wanneer je transactie-batches bouwt of interacteert met multicall-contracts, ga je calldata handmatig encoderen.
Zo codeer en decodeer je handmatig met ethers.js:
import { ethers } from "ethers";
const iface = new ethers.Interface(ERC20_ABI);
// Encode a function call
const calldata = iface.encodeFunctionData("transfer", [
"0xBobAddress...",
1000000n,
]);
console.log(calldata);
// 0xa9059cbb000000000000000000000000bob...000000000000000000000000000f4240
// Decode calldata back to function name and args
const decoded = iface.parseTransaction({ data: calldata });
console.log(decoded.name); // "transfer"
console.log(decoded.args[0]); // "0xBobAddress..."
console.log(decoded.args[1]); // 1000000n (BigInt)
// Decode a function's return data
const returnData = "0x0000000000000000000000000000000000000000000000000000000000000001";
const result = iface.decodeFunctionResult("transfer", returnData);
console.log(result[0]); // trueStorage Slots#
Contract-storage is een key-value store waar zowel keys als values 32 bytes zijn. Solidity wijst storage slots opeenvolgend toe vanaf 0. De eerste gedeclareerde state-variabele gaat in slot 0, de volgende in slot 1, enzovoort. Mappings en dynamische arrays gebruiken een hash-gebaseerd schema.
Je kunt de storage van elk contract direct lezen, zelfs als de variabele als private is gemarkeerd in Solidity. "Private" betekent alleen dat andere contracts het niet kunnen lezen — iedereen kan het lezen via eth_getStorageAt:
// Reading storage slot 0 of a contract
const slot0 = await provider.getStorage(
"0xContractAddress...",
0
);
console.log(slot0); // Raw 32-byte hex valueZo tonen block explorers "interne" contract-state. Er is geen toegangscontrole op storage-reads. Privacy op een publieke blockchain is fundamenteel beperkt.
Events en Logs#
Events zijn de manier waarop een contract gestructureerde data uitzendt die wordt opgeslagen in transactielogs maar niet in contract-storage. Ze zijn goedkoper dan storage-writes (375 gas voor het eerste topic + 8 gas per byte data, versus 20.000 gas voor een storage-write) en ze zijn ontworpen om efficiënt te worden bevraagd.
Een event kan maximaal 3 indexed-parameters hebben (opgeslagen als "topics") en een willekeurig aantal niet-geïndexeerde parameters (opgeslagen als "data"). Op geïndexeerde parameters kan gefilterd worden — je kunt vragen "geef me alle Transfer-events waar to dit adres is." Niet-geïndexeerde parameters kunnen niet worden gefilterd; je moet alle overeenkomende events ophalen en client-side filteren.
// Listening for Transfer events in real-time with ethers.js
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);
});
// Querying historical events
const filter = contract.filters.Transfer(null, "0xMyAddress..."); // from=any, to=specific
const events = await contract.queryFilter(filter, 19000000, 19100000); // block range
for (const event of events) {
console.log("From:", event.args.from);
console.log("Value:", event.args.value.toString());
}Hetzelfde met viem:
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"),
});
// Historical 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("From:", log.args.from);
console.log("To:", log.args.to);
console.log("Value:", log.args.value);
}
// Real-time watching
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}`);
}
},
});
// Call unwatch() to stop listeningOn-Chain Data Lezen#
Dit is waar Ethereum praktisch wordt voor webdevelopers. Je hoeft geen node te draaien. Je hoeft niet te minen. Je hebt niet eens een wallet nodig. Data lezen van Ethereum is gratis, permissionless en werkt via een simpele JSON-RPC API.
JSON-RPC: De HTTP API van Ethereum#
Elke Ethereum-node stelt een JSON-RPC API beschikbaar. Het is letterlijk HTTP POST met JSON-body's. Er is niets blockchain-specifieks aan de transportlaag.
// This is what your library does under the hood
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" }Dat is een ruwe eth_call. Het simuleert een transactie-uitvoering zonder het daadwerkelijk in te dienen. Geen gaskosten. Geen state-verandering. Leest alleen de retourwaarde. Zo werken view- en pure-functies van buitenaf — ze gebruiken eth_call in plaats van eth_sendRawTransaction.
De Twee Cruciale RPC-Methodes#
eth_call: Simuleert uitvoering. Gratis. Geen state-verandering. Wordt gebruikt voor alle leesoperaties — saldo's controleren, prijzen lezen, view-functies aanroepen. Kan op elk historisch block worden aangeroepen door een blocknummer op te geven in plaats van "latest".
eth_sendRawTransaction: Dient een ondertekende transactie in voor opname in een block. Kost gas. Verandert state (indien succesvol). Wordt gebruikt voor alle schrijfoperaties — transfers, approvals, swaps, mints.
Al het andere in de JSON-RPC API is ofwel een variant van deze twee, ofwel een utility-methode (eth_blockNumber, eth_getTransactionReceipt, eth_getLogs, etc.).
Providers: Je Gateway naar de Chain#
Je draait geen eigen node. Bijna niemand doet dat voor applicatieontwikkeling. In plaats daarvan gebruik je een provider-service:
- Alchemy: De populairste. Goed dashboard, webhook-ondersteuning, verbeterde API's voor NFT's en token-metadata. Gratis tier: ~300M compute units/maand.
- Infura: Het origineel. Eigendom van ConsenSys. Betrouwbaar. Gratis tier: 100K requests/dag.
- QuickNode: Goed voor multi-chain. Iets ander prijsmodel.
- Publieke RPC-endpoints:
https://rpc.ankr.com/eth,https://cloudflare-eth.com. Gratis maar rate-limited en af en toe onbetrouwbaar. Prima voor ontwikkeling, gevaarlijk voor productie. - Tenderly: Uitstekend voor simulatie en debugging. Hun RPC bevat een ingebouwde transactie-simulator.
Configureer voor productie altijd minstens twee providers als fallbacks. RPC-downtime is echt en het zal op het slechtst mogelijke moment gebeuren.
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 State Gratis Lezen#
Dit is de power move waar de meeste Web2-developers zich niet van bewust zijn: je kunt alle publieke data van elk contract op Ethereum lezen zonder iets te betalen, zonder wallet, en zonder enige authenticatie behalve een API-key voor je RPC-provider.
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
// ERC-20 interface — just the read functions
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 // Note: provider, not signer. Read-only.
);
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 (NOT 18!)
console.log(`Total supply: ${ethers.formatUnits(totalSupply, decimals)}`);
// Check a specific address's balance
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Balance: ${ethers.formatUnits(balance, decimals)} USDC`);Geen wallet. Geen gas. Geen transactie. Gewoon een JSON-RPC eth_call onder de motorkap. Dit is conceptueel identiek aan het maken van een GET-request naar een REST API. De blockchain is de database, het contract is de API, en eth_call is je SELECT-query.
ethers.js v6#
ethers.js is de jQuery van Web3 — het was de eerste library die de meeste developers leerden, en het is nog steeds de meest gebruikte. Versie 6 is een aanzienlijke verbetering ten opzichte van v5, met native BigInt-ondersteuning (eindelijk), ESM-modules en een schonere API.
De Drie Kernabstracties#
Provider: Een read-only verbinding met de blockchain. Kan view-functies aanroepen, blocks ophalen, logs lezen. Kan niet ondertekenen of transacties versturen.
import { ethers } from "ethers";
// Connect to a node
const provider = new ethers.JsonRpcProvider("https://...");
// Basic queries
const blockNumber = await provider.getBlockNumber();
const balance = await provider.getBalance("0xAddress...");
const block = await provider.getBlock(blockNumber);
const txCount = await provider.getTransactionCount("0xAddress...");Signer: Een abstractie over een private key. Kan transacties en berichten ondertekenen. Een Signer is altijd verbonden met een Provider.
// From a private key (server-side, scripts)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
// From a browser wallet (client-side)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
// Get the address
const address = await signer.getAddress();Contract: Een JavaScript-proxy voor een gedeployd contract. Methodes op het Contract-object komen overeen met functies in de ABI. View-functies retourneren waarden. State-veranderende functies retourneren een TransactionResponse.
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
// Read (free, returns value directly)
const balance = await usdc.balanceOf("0xSomeAddress...");
// balance is a bigint: 1000000000n (1000 USDC with 6 decimals)
// To write, connect with a signer
const usdcWithSigner = usdc.connect(signer);
// Write (costs gas, returns TransactionResponse)
const tx = await usdcWithSigner.transfer("0xRecipient...", 1000000n);
const receipt = await tx.wait(); // Wait for block inclusion
if (receipt.status === 0) {
throw new Error("Transaction reverted");
}TypeChain voor Type Safety#
Ruwe ABI-interacties zijn stringly-typed. Je kunt een functienaam verkeerd spellen, de verkeerde argumenttypes doorgeven, of retourwaarden verkeerd interpreteren. TypeChain genereert TypeScript-types uit je ABI-bestanden:
// Without TypeChain — no type checking
const balance = await contract.balanceOf("0x...");
// balance is 'any'. No autocomplete. Easy to misuse.
// With TypeChain — full type safety
import { USDC__factory } from "./typechain";
const usdc = USDC__factory.connect(USDC_ADDRESS, provider);
const balance = await usdc.balanceOf("0x...");
// balance is BigNumber. Autocomplete works. Type errors caught at compile time.Voor nieuwe projecten, overweeg viem's ingebouwde type-inferentie vanuit ABI's te gebruiken. Het bereikt hetzelfde resultaat zonder een aparte codegeneratiestap.
Luisteren naar Events#
Real-time event streaming is cruciaal voor responsieve dApps. ethers.js gebruikt WebSocket-providers hiervoor:
// WebSocket for real-time 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);
// Listen for all Transfer events
contract.on("Transfer", (from, to, value, event) => {
console.log(`Transfer: ${from} -> ${to}`);
console.log(`Amount: ${ethers.formatUnits(value, 6)} USDC`);
});
// Listen for transfers TO a specific address
const filter = contract.filters.Transfer(null, "0xMyAddress...");
contract.on(filter, (from, to, value) => {
console.log(`Incoming transfer: ${ethers.formatUnits(value, 6)} USDC from ${from}`);
});
// Clean up when done
contract.removeAllListeners();WAGMI + Viem: De Moderne Stack#
WAGMI (We're All Gonna Make It) is een React hooks-library voor Ethereum. Viem is de onderliggende TypeScript-client die het gebruikt. Samen hebben ze grotendeels ethers.js + web3-react vervangen als de standaardstack voor frontend dApp-ontwikkeling.
Waarom de verschuiving? Drie redenen: volledige TypeScript-inferentie vanuit ABI's (geen codegen nodig), kleinere bundel-grootte, en React hooks die het rommelige async state management van wallet-interacties afhandelen.
Opzetten#
// 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"),
},
});// 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 Data Lezen#
useReadContract is de hook die je het meest zult gebruiken. Het wrapt eth_call met React Query caching, refetching en loading/error states:
"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>Loading...</span>;
if (error) return <span>Error: {error.message}</span>;
// balance is typed as bigint because the ABI says uint256
return <span>{formatUnits(balance ?? 0n, 6)} USDC</span>;
}Let op de as const op de ABI. Dit is cruciaal. Zonder dit verliest TypeScript de literal types en wordt balance unknown in plaats van bigint. Het volledige type-inferentiesysteem is afhankelijk van const assertions.
Schrijven naar Contracts#
useWriteContract handelt de volledige levenscyclus af: wallet-prompt, ondertekening, broadcasting en bevestigingstracking.
"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 ? "Confirm in wallet..." : "Send 100 USDC"}
</button>
{hash && <p>Transaction: {hash}</p>}
{isConfirming && <p>Waiting for confirmation...</p>}
{isSuccess && <p>Transfer confirmed!</p>}
{error && <p>Error: {error.message}</p>}
</div>
);
}Events Monitoren#
useWatchContractEvent zet een WebSocket-abonnement op voor real-time event-monitoring:
"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>
);
}Connect Wallet-Patronen#
Een wallet van een gebruiker verbinden is de "login" van Web3. Behalve dat het geen login is. Er is geen sessie, geen cookie, geen server-side state. De wallet-verbinding geeft je app toestemming om het adres van de gebruiker te lezen en transactie-signatures aan te vragen. Dat is het.
De EIP-1193 Provider Interface#
Elke wallet stelt een standaard interface beschikbaar die is gedefinieerd door EIP-1193. Het is een object met een request-methode:
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 injecteert dit als window.ethereum. Andere wallets injecteren ofwel hun eigen property of gebruiken ook window.ethereum (wat conflicten veroorzaakt — het "wallet wars"-probleem, gedeeltelijk opgelost door EIP-6963).
// Low-level wallet interaction (you shouldn't do this directly, but it's useful to understand)
// Request account access
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
console.log("Connected address:", accounts[0]);
// Get the current chain
const chainId = await window.ethereum.request({
method: "eth_chainId",
});
console.log("Chain ID:", parseInt(chainId, 16)); // "0x1" -> 1 (mainnet)
// Listen for account changes (user switches accounts in MetaMask)
window.ethereum.on("accountsChanged", (accounts: string[]) => {
if (accounts.length === 0) {
console.log("Wallet disconnected");
} else {
console.log("Switched to:", accounts[0]);
}
});
// Listen for chain changes (user switches networks)
window.ethereum.on("chainChanged", (chainId: string) => {
// The recommended approach is to reload the page
window.location.reload();
});EIP-6963: Multi-Wallet Discovery#
De oude window.ethereum-aanpak breekt als gebruikers meerdere wallets geïnstalleerd hebben. Welke krijgt window.ethereum? De laatste die injecteert? De eerste? Het is een race condition.
EIP-6963 lost dit op met een discovery-protocol gebaseerd op browser-events:
// Discovering all available wallets
interface EIP6963ProviderDetail {
info: {
uuid: string;
name: string;
icon: string;
rdns: string; // Reverse domain name, e.g., "io.metamask"
};
provider: EIP1193Provider;
}
const wallets: EIP6963ProviderDetail[] = [];
window.addEventListener("eip6963:announceProvider", (event: CustomEvent) => {
wallets.push(event.detail);
});
// Request all wallets to announce themselves
window.dispatchEvent(new Event("eip6963:requestProvider"));
// Now 'wallets' contains all installed wallets with their names and icons
// You can show a wallet selection UIWAGMI handelt dit alles voor je af. Wanneer je de injected()-connector gebruikt, maakt het automatisch gebruik van EIP-6963 indien beschikbaar en valt terug op window.ethereum.
WalletConnect#
WalletConnect is een protocol dat mobiele wallets verbindt met desktop-dApps via een relay-server. De gebruiker scant een QR-code met zijn mobiele wallet, waarmee een versleutelde verbinding wordt opgezet. Transactieverzoeken worden doorgestuurd van je dApp naar hun telefoon.
Met WAGMI is het gewoon weer een connector:
import { walletConnect } from "wagmi/connectors";
const connector = walletConnect({
projectId: "YOUR_PROJECT_ID", // Get from cloud.walletconnect.com
showQrModal: true,
});Chain Switching Afhandelen#
Gebruikers zitten vaak op het verkeerde netwerk. Je dApp draait op Mainnet, zij zijn verbonden met Sepolia. Of ze zitten op Polygon en je hebt Mainnet nodig. WAGMI biedt useSwitchChain:
"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>Please connect your wallet</p>;
if (chain.id !== mainnet.id) {
return (
<div>
<p>Please switch to Ethereum Mainnet</p>
<button
onClick={() => switchChain({ chainId: mainnet.id })}
disabled={isPending}
>
{isPending ? "Switching..." : "Switch Network"}
</button>
</div>
);
}
return <>{children}</>;
}IPFS en Metadata#
NFT's slaan geen afbeeldingen op-chain op. De blockchain slaat een URI op die naar een JSON-metadatabestand wijst, dat op zijn beurt een URL naar de afbeelding bevat. Het standaardpatroon, gedefinieerd door de tokenURI-functie van ERC-721:
Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"
Dat JSON-bestand volgt een standaardschema:
{
"name": "Cool NFT #42",
"description": "A very cool NFT",
"image": "ipfs://QmImageHash...",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Rarity", "value": "Legendary" }
]
}IPFS CID vs URL#
IPFS-adressen gebruiken Content Identifiers (CID's) — hashes van de content zelf. ipfs://QmXyz... betekent "de content waarvan de hash QmXyz... is". Dit is content-addressed storage: de URI is afgeleid van de content, dus de content kan nooit veranderen zonder de URI te veranderen. Dit is de onveranderlijkheidsgarantie waar NFT's op vertrouwen (als ze daadwerkelijk IPFS gebruiken — veel gebruiken gecentraliseerde URL's in plaats daarvan, wat een waarschuwingssignaal is).
Om IPFS-content in een browser weer te geven, heb je een gateway nodig die IPFS-URI's vertaalt naar HTTP:
function ipfsToHttp(uri: string): string {
if (uri.startsWith("ipfs://")) {
const cid = uri.replace("ipfs://", "");
return `https://ipfs.io/ipfs/${cid}`;
// Or use a dedicated gateway:
// return `https://YOUR_PROJECT.mypinata.cloud/ipfs/${cid}`;
}
return uri;
}
// Fetching NFT metadata
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 is een peer-to-peer netwerk. Content blijft alleen beschikbaar zolang iemand het host ("pint"). Als je een NFT-afbeelding uploadt naar IPFS en vervolgens je node afsluit, verdwijnt de content.
Pinning-services houden je content beschikbaar:
- Pinata: De populairste. Simpele API. Genereuze gratis tier (1GB). Dedicated gateways voor sneller laden.
- NFT.Storage: Gratis, ondersteund door Protocol Labs (de makers van IPFS). Specifiek ontworpen voor NFT-metadata. Gebruikt Filecoin voor langdurige persistentie.
- Web3.Storage: Vergelijkbaar met NFT.Storage, meer voor algemeen gebruik.
// Uploading to Pinata
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}`; // Returns CID
}Het Indexeringsprobleem#
Hier is het vuile geheim van blockchain-ontwikkeling: je kunt historische data niet efficiënt opvragen vanaf een RPC-node.
Wil je alle Transfer-events van een token in het afgelopen jaar? Je moet miljoenen blocks scannen met eth_getLogs, paginerend in chunks van 2.000-10.000 blocks (het maximum varieert per provider). Dat zijn duizenden RPC-calls. Het duurt minuten tot uren en vreet je API-quotum op.
Wil je alle tokens in bezit van een specifiek adres? Er is geen enkele RPC-call hiervoor. Je zou elk Transfer-event van elk ERC-20 contract moeten scannen en saldo's bijhouden. Dat is niet haalbaar.
Wil je alle NFT's in een wallet? Hetzelfde probleem. Je moet elk ERC-721 Transfer-event van elk NFT-contract scannen.
De blockchain is een schrijfgeoptimaliseerde datastructuur. Hij is uitstekend in het verwerken van nieuwe transacties. Hij is verschrikkelijk in het beantwoorden van historische queries. Dit is de fundamentele mismatch tussen wat dApp-UI's nodig hebben en wat de chain native biedt.
The Graph Protocol#
The Graph is een gedecentraliseerd indexeringsprotocol. Je schrijft een "subgraph" — een schema en een set event-handlers — en The Graph indexeert de chain en serveert de data via een GraphQL API.
# 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")
}// Querying a subgraph from your frontend
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;
}De afweging: The Graph voegt latency toe (typisch 1-2 blocks achter de chain head) en nog een afhankelijkheid. Het gedecentraliseerde netwerk heeft ook indexeringskosten (je betaalt in GRT-tokens). Voor kleinere projecten is de hosted service (Subgraph Studio) gratis.
Alchemy en Moralis Enhanced API's#
Als je geen subgraph wilt onderhouden, bieden zowel Alchemy als Moralis voorgeïndexeerde API's die veelvoorkomende queries direct beantwoorden:
// Alchemy: Get all ERC-20 token balances for an address
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"],
}),
}
);
// Returns ALL ERC-20 token balances in one call
// vs. scanning every possible ERC-20 contract's balanceOf()// Alchemy: Get all NFTs owned by an address
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}`);
}Deze API's zijn proprietary en gecentraliseerd. Je ruilt decentralisatie in voor developer experience. Voor de meeste dApps is dat een waardevolle afweging. Je gebruikers interesseert het niet of hun portfolioweergave uit een subgraph komt of uit Alchemy's database. Ze vinden het belangrijk dat het in 200ms laadt in plaats van 30 seconden.
Veelvoorkomende Valkuilen#
Na het opleveren van meerdere productie-dApps en het debuggen van andermans code, zijn dit de fouten die ik herhaaldelijk tegenkom. Elk van deze heeft me persoonlijk gebeten.
BigInt Overal#
Ethereum werkt met heel grote getallen. ETH-saldo's zijn in wei (10^18). Token-supplies kunnen 10^27 of hoger zijn. JavaScript Number kan gehele getallen alleen veilig representeren tot 2^53 - 1 (ongeveer 9 * 10^15). Dat is niet genoeg voor wei-bedragen.
// WRONG — silent precision loss
const balance = 1000000000000000000; // 1 ETH in wei
const double = balance * 2;
console.log(double); // 2000000000000000000 — looks right, but...
const largeBalance = 99999999999999999999; // ~100 ETH
console.log(largeBalance); // 100000000000000000000 — WRONG! Rounded up.
console.log(largeBalance === 100000000000000000000); // true — data corruption
// RIGHT — use BigInt
const balance = 1000000000000000000n;
const double = balance * 2n;
console.log(double.toString()); // "2000000000000000000" — correct
const largeBalance = 99999999999999999999n;
console.log(largeBalance.toString()); // "99999999999999999999" — correctRegels voor BigInt in dApp-code:
- Converteer wei-bedragen nooit naar
Number. Gebruik overalBigInt, converteer alleen naar leesbare strings voor weergave. - Gebruik nooit
Math.floor,Math.round, etc. op BigInts. Ze werken niet. Gebruik gehele deling:amount / 10n ** 6n. - JSON ondersteunt geen BigInt. Als je state serialiseert die BigInts bevat, heb je een custom serializer nodig:
JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v). - Gebruik library-formatteringsfuncties.
ethers.formatEther(),ethers.formatUnits(),viem'sformatEther(),formatUnits(). Ze handelen de conversie correct af.
import { formatUnits, parseUnits } from "viem";
// Display: BigInt → human-readable string
const weiAmount = 1500000000000000000n; // 1.5 ETH
const display = formatUnits(weiAmount, 18); // "1.5"
// Input: human-readable string → BigInt
const userInput = "1.5";
const wei = parseUnits(userInput, 18); // 1500000000000000000n
// USDC has 6 decimals, not 18
const usdcAmount = 100000000n; // 100 USDC
const usdcDisplay = formatUnits(usdcAmount, 6); // "100.0"Async Wallet-Operaties#
Elke wallet-interactie is async en kan op manieren falen die je app netjes moet afhandelen:
// The user can reject any wallet prompt
try {
const tx = await writeContract({
address: contractAddress,
abi: ERC20_ABI,
functionName: "approve",
args: [spenderAddress, amount],
});
} catch (error) {
if (error.code === 4001) {
// User rejected the transaction in their wallet
// This is normal — not an error to report
showToast("Transaction cancelled");
} else if (error.code === -32603) {
// Internal JSON-RPC error — often means the transaction would revert
showToast("Transaction would fail. Check your balance.");
} else {
// Unexpected error
console.error("Transaction error:", error);
showToast("Something went wrong. Please try again.");
}
}Belangrijke async-valkuilen:
- Wallet-prompts blokkeren aan de kant van de gebruiker. De
awaitin je code kan 30 seconden duren terwijl de gebruiker de transactiedetails leest in MetaMask. Toon geen laadspinner die hen laat denken dat er iets stuk is. - De gebruiker kan midden in een interactie van account wisselen. Je vraagt goedkeuring aan van Account A, de gebruiker wisselt naar Account B en keurt dan goed. Nu heeft Account B goedgekeurd, maar je staat op het punt een transactie te versturen vanaf Account A. Controleer altijd opnieuw het verbonden account voor kritieke operaties.
- Tweestaps schrijfpatronen zijn gebruikelijk. Veel DeFi-operaties vereisen
approve+execute. De gebruiker moet twee transacties ondertekenen. Als ze goedkeuren maar niet uitvoeren, moet je de allowance-state controleren en de goedkeuringsstap de volgende keer overslaan.
Network Mismatch Fouten#
Deze verspilt meer debugtijd dan elk ander probleem. Je contract staat op Mainnet. Je wallet is op Sepolia. Je RPC-provider wijst naar Polygon. Drie verschillende netwerken, drie verschillende states, drie volledig ongerelateerde blockchains. En de foutmelding is meestal nutteloos — "execution reverted" of "contract not found."
// Defensive chain checking
import { useAccount, useChainId } from "wagmi";
function useRequireChain(requiredChainId: number) {
const chainId = useChainId();
const { isConnected } = useAccount();
if (!isConnected) {
return { ready: false, error: "Please connect your wallet" };
}
if (chainId !== requiredChainId) {
return {
ready: false,
error: `Please switch to ${getChainName(requiredChainId)}. You're on ${getChainName(chainId)}.`,
};
}
return { ready: true, error: null };
}Front-Running in DeFi#
Wanneer je een swap indient op een DEX, is je openstaande transactie zichtbaar in de mempool. Een bot kan je trade zien, front-runnen door de prijs omhoog te duwen, je trade op een slechtere prijs laten uitvoeren, en dan direct daarna verkopen voor winst. Dit heet een "sandwich-aanval."
Als frontend-developer kun je dit niet volledig voorkomen, maar je kunt het beperken:
// Setting slippage tolerance on a Uniswap-style swap
const amountOutMin = expectedOutput * 995n / 1000n; // 0.5% slippage tolerance
// Using a deadline to prevent long-lived pending transactions
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20 minutes
await router.swapExactTokensForTokens(
amountIn,
amountOutMin, // Minimum acceptable output — revert if we'd get less
[tokenA, tokenB],
userAddress,
deadline, // Revert if not executed within 20 minutes
);Voor transacties met hoge waarde, overweeg Flashbots Protect RPC te gebruiken, dat transacties direct naar block builders stuurt in plaats van de publieke mempool. Dit voorkomt sandwich-aanvallen volledig omdat bots je openstaande transactie nooit zien:
// Using Flashbots Protect as your RPC endpoint
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");Decimal-Verwarring#
Niet alle tokens hebben 18 decimalen. USDC en USDT hebben er 6. WBTC heeft er 8. Sommige tokens hebben 0, 2 of willekeurige decimalen. Lees altijd de decimals() van het contract voordat je bedragen formatteert:
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 Estimation Fouten#
Wanneer estimateGas faalt, betekent het meestal dat de transactie zou terugdraaien. Maar de foutmelding is vaak slechts "cannot estimate gas" zonder enige indicatie waarom. Gebruik eth_call om de transactie te simuleren en de daadwerkelijke revert-reden te krijgen:
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; // No error — transaction would succeed
} catch (error) {
// Decode the revert reason
if (error.data) {
// Common revert strings
if (error.data.startsWith("0x08c379a0")) {
// Error(string) — standard revert with message
const reason = decodeAbiParameters(
[{ type: "string" }],
`0x${error.data.slice(10)}`
);
return `Revert: ${reason[0]}`;
}
}
return error.message;
}
}Alles Samenbrengen#
Hier is een compleet, minimaal React-component dat een wallet verbindt, een token-saldo leest en een transfer verstuurt. Dit is het skelet van elke dApp:
"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("");
// Read balance — only runs when address is defined
const { data: balance, refetch: refetchBalance } = useReadContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "balanceOf",
args: address ? [address] : undefined,
query: { enabled: !!address },
});
// Write: transfer tokens
const {
writeContract,
data: txHash,
isPending: isSigning,
error: writeError,
} = useWriteContract();
// Wait for confirmation
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
// Refetch balance after confirmation
if (isSuccess) {
refetchBalance();
}
if (!isConnected) {
return (
<button onClick={() => connect({ connector: injected() })}>
Connect Wallet
</button>
);
}
return (
<div>
<p>Connected: {address}</p>
<p>
USDC Balance:{" "}
{balance !== undefined ? formatUnits(balance, 6) : "Loading..."}
</p>
<div>
<input
placeholder="Recipient address (0x...)"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
<input
placeholder="Amount (e.g., 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
? "Confirm in wallet..."
: isConfirming
? "Confirming..."
: "Send USDC"}
</button>
</div>
{writeError && <p style={{ color: "red" }}>{writeError.message}</p>}
{isSuccess && <p style={{ color: "green" }}>Transfer confirmed!</p>}
{txHash && (
<a
href={`https://etherscan.io/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
>
View on Etherscan
</a>
)}
<button onClick={() => disconnect()}>Disconnect</button>
</div>
);
}Verder Gaan#
Dit bericht behandelde de essentiële concepten en tooling voor webdevelopers die met Ethereum aan de slag gaan. Er is veel meer diepgang in elk gebied:
- Solidity: Als je contracts wilt schrijven, niet alleen ermee interacteren. De officiële docs en Patrick Collins' cursussen zijn de beste startpunten.
- ERC-standaarden: ERC-20 (fungible tokens), ERC-721 (NFT's), ERC-1155 (multi-token), ERC-4626 (tokenized vaults). Elk definieert een standaardinterface die alle contracts in die categorie implementeren.
- Layer 2's: Arbitrum, Optimism, Base, zkSync. Dezelfde developer experience, lagere gaskosten, iets andere trust-aannames. Je ethers.js- en viem-code werkt identiek — verander gewoon de chain ID en RPC URL.
- Account Abstraction (ERC-4337): De volgende evolutie van wallet-UX. Smart contract wallets die gas-sponsoring, social recovery en gebatchte transacties ondersteunen. Dit is waar het "connect wallet"-patroon naartoe gaat.
- MEV en transactie-ordering: Als je DeFi bouwt, is het begrijpen van Maximal Extractable Value niet optioneel. De Flashbots-docs zijn de canonieke bron.
Het blockchain-ecosysteem beweegt snel, maar de fundamenten in dit bericht — accounts, transacties, ABI-encoding, RPC-calls, event-indexering — zijn niet veranderd sinds 2015 en zullen niet snel veranderen. Leer deze goed en al het andere is gewoon API-oppervlak.