Ethereum för webbutvecklare: Smarta kontrakt utan hypen
Ethereum-koncepten varje webbutvecklare behöver: konton, transaktioner, smarta kontrakt, ABI-kodning, ethers.js, WAGMI och läsning av on-chain-data utan att köra en egen nod.
Det mesta "Ethereum för utvecklare"-innehållet faller i två kategorier: förenklade analogier som inte hjälper dig bygga något, eller djupa protokollspecifikationer som förutsätter att du redan vet vad en Merkle Patricia Trie är. Ingetdera är användbart om du är en webbutvecklare som vill läsa ett tokensaldo, låta en användare signera en transaktion, eller visa NFT-metadata i en React-app.
Det här inlägget är den praktiska mellanvägen. Jag kommer att förklara exakt vad som händer när din frontend kommunicerar med Ethereum, vilka de rörliga delarna är, och hur de moderna verktygen (ethers.js, viem, WAGMI) mappar till koncept du redan förstår från att bygga webbapplikationer.
Inga metaforer om varuautomater. Ingen "föreställ dig en värld där..." Bara den tekniska modellen och koden.
Den mentala modellen#
Ethereum är en replikerad tillståndsmaskin. Varje nod i nätverket upprätthåller en identisk kopia av tillståndet — ett massivt nyckel-värde-lager som mappar adresser till kontodata. När du "skickar en transaktion" föreslår du en tillståndsövergång. Om tillräckligt många validerare godkänner den, uppdateras tillståndet. Det är allt.
Själva tillståndet är rakt på sak. Det är en mappning från 20-byte-adresser till kontoobjekt. Varje konto har fyra fält:
- nonce: Hur många transaktioner detta konto har skickat (för EOA:er) eller hur många kontrakt det har skapat (för kontraktskonton). Detta förhindrar replay-attacker.
- balance: Mängd ETH, uttryckt i wei (1 ETH = 10^18 wei). Alltid ett stort heltal.
- codeHash: Hash av EVM-bytekoden. För vanliga plånböcker (EOA:er) är detta hashen av tomma bytes. För kontrakt är det hashen av den deployade koden.
- storageRoot: Rothash av kontots storage-trie. Bara kontrakt har meningsfull lagring.
Det finns två typer av konton, och distinktionen är viktig för allt som följer:
Externally Owned Accounts (EOA:er) kontrolleras av en privat nyckel. Det är vad MetaMask hanterar. De kan initiera transaktioner. De har ingen kod. När någon säger "plånbok" menar de en EOA.
Kontraktskonton kontrolleras av sin kod. De kan inte initiera transaktioner — de kan bara exekvera som svar på att bli anropade. De har kod och lagring. När någon säger "smart kontrakt" menar de detta. Koden är oföränderlig när den väl deployats (med vissa undantag via proxy-mönster, vilket är en helt annan diskussion).
Den kritiska insikten: varje tillståndsändring på Ethereum börjar med att en EOA signerar en transaktion. Kontrakt kan anropa andra kontrakt, men exekveringskedjan börjar alltid med en människa (eller en bot) med en privat nyckel.
Gas: Beräkning har ett pris#
Varje operation i EVM kostar gas. Att addera två tal kostar 3 gas. Att lagra ett 32-byte-ord kostar 20 000 gas (första gången) eller 5 000 gas (uppdatering). Att läsa lagring kostar 2 100 gas (kall) eller 100 gas (varm, redan åtkomst i denna transaktion).
Du betalar inte gas i "gasenheter". Du betalar i ETH. Totalkostnaden är:
totalCost = gasUsed * gasPrice
Efter EIP-1559 (London-uppgraderingen) blev gasprissättningen ett tvådelat system:
totalCost = gasUsed * (baseFee + priorityFee)
- baseFee: Sätts av protokollet baserat på nätverksbelastning. Bränns (förstörs).
- priorityFee (dricks): Går till valideraren. Högre dricks = snabbare inkludering.
- maxFeePerGas: Det maximala du är villig att betala per gasenhet.
- maxPriorityFeePerGas: Det maximala drickset per gasenhet.
Om baseFee + priorityFee > maxFeePerGas väntar din transaktion tills baseFee sjunker. Det är därför transaktioner "fastnar" vid hög belastning.
Den praktiska implikationen för webbutvecklare: att läsa data är gratis. Att skriva data kostar pengar. Detta är den enskilt viktigaste arkitekturella skillnaden mellan Web2 och Web3. Varje SELECT är gratis. Varje INSERT, UPDATE, DELETE kostar riktiga pengar. Designa dina dApps därefter.
Transaktioner#
En transaktion är en signerad datastruktur. Här är fälten som spelar roll:
interface Transaction {
// Vem som tar emot denna transaktion — en EOA-adress eller en kontraktsadress
to: string; // 20-byte hex-adress, eller null för kontrakts-deploy
// Hur mycket ETH att skicka (i wei)
value: bigint; // Kan vara 0n för rena kontraktsanrop
// Kodat funktionsanropsdata, eller tomt för vanliga ETH-överföringar
data: string; // Hex-kodade bytes, "0x" för enkla överföringar
// Sekventiell räknare, förhindrar replay-attacker
nonce: number; // Måste exakt motsvara avsändarens nuvarande nonce
// Gasgräns — maximalt gas denna tx kan konsumera
gasLimit: bigint;
// EIP-1559 avgiftsparametrar
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
// Kedjeidentifierare (1 = mainnet, 11155111 = Sepolia, 137 = Polygon)
chainId: number;
}Livscykeln för en transaktion#
-
Konstruktion: Din app bygger transaktionsobjektet. Om du anropar en kontraktsfunktion innehåller
data-fältet det ABI-kodade funktionsanropet (mer om detta nedan). -
Signering: Den privata nyckeln signerar den RLP-kodade transaktionen och producerar signaturkomponenterna
v,r,s. Detta bevisar att avsändaren godkände denna specifika transaktion. Avsändaradressen härleds från signaturen — den finns inte explicit i transaktionen. -
Utsändning: Den signerade transaktionen skickas till en RPC-nod via
eth_sendRawTransaction. Noden validerar den (korrekt nonce, tillräckligt saldo, giltig signatur) och lägger till den i sin mempool. -
Mempool: Transaktionen ligger i en pool av väntande transaktioner. Validerare väljer transaktioner att inkludera i nästa block, generellt med preferens för högre dricks. Det är här front-running sker — andra aktörer kan se din väntande transaktion och skicka sin egen med högre dricks för att exekvera före din.
-
Inkludering: En validerare inkluderar din transaktion i ett block. EVM exekverar den. Om den lyckas appliceras tillståndsändringar. Om den revertar rullas tillståndsändringar tillbaka — men du betalar fortfarande för gasen som förbrukades fram till revert-punkten.
-
Finalitet: På proof-of-stake Ethereum blir ett block "finaliserat" efter två epoker (~12,8 minuter). Före finalitet är kedjereorganiseringar teoretiskt möjliga (men sällsynta). De flesta appar behandlar 1-2 blockbekräftelser som "tillräckligt bra" för icke-kritiska operationer.
Så här ser det ut att skicka en enkel ETH-överföring med 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"), // Konverterar "0.1" till wei (100000000000000000n)
});
console.log("Tx hash:", tx.hash);
// Vänta på inkludering i ett 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 = lyckades, 0 = revertOch samma sak med 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);Notera skillnaden: ethers returnerar ett TransactionResponse-objekt med en .wait()-metod. Viem returnerar bara hashen — du använder ett separat publicClient.waitForTransactionReceipt({ hash })-anrop för att vänta på bekräftelse. Denna separation av ansvar är avsiktlig i viems design.
Smarta kontrakt#
Ett smart kontrakt är deployad bytekod plus beständig lagring vid en specifik adress. När du "anropar" ett kontrakt skickar du en transaktion (eller gör ett skrivskyddat anrop) med data-fältet satt till ett kodat funktionsanrop.
Bytekod och ABI#
Bytekoden är den kompilerade EVM-koden. Du interagerar inte med den direkt. Det är vad EVM exekverar.
ABI:n (Application Binary Interface) är en JSON-beskrivning av kontraktets gränssnitt. Den talar om för ditt klientbibliotek hur funktionsanrop ska kodas och returvärden avkodas. Tänk på det som en OpenAPI-spec för ett kontrakt.
Här är ett fragment av en ERC-20-token-ABI:
const ERC20_ABI = [
// Skrivskyddade funktioner (view/pure — ingen gaskostnad vid externt anrop)
"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)",
// Tillståndsändrande funktioner (kräver en transaktion, kostar 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 (emitteras under exekvering, lagras i transaktionsloggar)
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;Ethers.js accepterar detta "mänskligt läsbara ABI"-format. Viem kan också använda det, men ofta arbetar du med den fullständiga JSON-ABI:n som genereras av Solidity-kompilatorn. Båda är ekvivalenta — det mänskligt läsbara formatet är bara bekvämare för vanliga gränssnitt.
Hur funktionsanrop kodas#
Det här är delen som de flesta tutorials hoppar över, och det är delen som kommer att spara dig timmar av felsökning.
När du anropar transfer("0xBob...", 1000000) sätts data-fältet i transaktionen till:
0xa9059cbb // Funktionsselektor
0000000000000000000000000xBob...000000000000000000000000 // address, paddad till 32 bytes
00000000000000000000000000000000000000000000000000000000000f4240 // uint256 belopp (1000000 i hex)
Funktionsselektorn är de första 4 byten av Keccak-256-hashen av funktionssignaturen:
keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = första 4 byten = 0xa9059cbb
De återstående byten är de ABI-kodade argumenten, var och en paddad till 32 bytes. Detta kodningsschema är deterministiskt — samma funktionsanrop producerar alltid samma calldata.
Varför spelar detta roll? För när du ser rå transaktionsdata på Etherscan och det börjar med 0xa9059cbb, vet du att det är ett transfer-anrop. När din transaktion revertar och felmeddelandet bara är en hex-blob kan du avkoda det med ABI:n. Och när du bygger transaktionsbatcher eller interagerar med multicall-kontrakt kommer du att koda calldata manuellt.
Så här kodar och avkodar du manuellt med ethers.js:
import { ethers } from "ethers";
const iface = new ethers.Interface(ERC20_ABI);
// Koda ett funktionsanrop
const calldata = iface.encodeFunctionData("transfer", [
"0xBobAddress...",
1000000n,
]);
console.log(calldata);
// 0xa9059cbb000000000000000000000000bob...000000000000000000000000000f4240
// Avkoda calldata tillbaka till funktionsnamn och argument
const decoded = iface.parseTransaction({ data: calldata });
console.log(decoded.name); // "transfer"
console.log(decoded.args[0]); // "0xBobAddress..."
console.log(decoded.args[1]); // 1000000n (BigInt)
// Avkoda en funktions returdata
const returnData = "0x0000000000000000000000000000000000000000000000000000000000000001";
const result = iface.decodeFunctionResult("transfer", returnData);
console.log(result[0]); // trueStorage-platser#
Kontraktslagring är ett nyckel-värde-lager där både nycklar och värden är 32 bytes. Solidity tilldelar lagringsplatser sekventiellt med start från 0. Den första deklarerade tillståndsvariabeln hamnar i plats 0, nästa i plats 1, och så vidare. Mappningar och dynamiska arrayer använder ett hashbaserat schema.
Du kan läsa valfritt kontrakts lagring direkt, även om variabeln är markerad som private i Solidity. "Private" betyder bara att andra kontrakt inte kan läsa den — vem som helst kan läsa den via eth_getStorageAt:
// Läsa lagringsplats 0 i ett kontrakt
const slot0 = await provider.getStorage(
"0xContractAddress...",
0
);
console.log(slot0); // Rått 32-byte hex-värdeDet är så blockutforskare visar "internt" kontraktstillstånd. Det finns ingen åtkomstkontroll på lagringsläsningar. Integritet på en publik blockkedja är fundamentalt begränsad.
Events och loggar#
Events är kontraktets sätt att emittera strukturerad data som lagras i transaktionsloggar men inte i kontraktslagring. De är billigare än lagringsskriv (375 gas för det första ämnet + 8 gas per byte data, jämfört med 20 000 gas för en lagringsskriv) och de är designade för att kunna sökas effektivt.
Ett event kan ha upp till 3 indexed-parametrar (lagrade som "topics") och valfritt antal icke-indexerade parametrar (lagrade som "data"). Indexerade parametrar kan filtreras på — du kan fråga "ge mig alla Transfer-events där to är den här adressen." Icke-indexerade parametrar kan inte filtreras; du måste hämta alla matchande events och filtrera på klientsidan.
// Lyssna på Transfer-events i realtid med 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);
});
// Söka historiska events
const filter = contract.filters.Transfer(null, "0xMyAddress..."); // from=valfri, to=specifik
const events = await contract.queryFilter(filter, 19000000, 19100000); // blockintervall
for (const event of events) {
console.log("From:", event.args.from);
console.log("Value:", event.args.value.toString());
}Samma sak med 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"),
});
// Historiska loggar
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);
}
// Realtidsövervakning
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}`);
}
},
});
// Anropa unwatch() för att sluta lyssnaLäsa on-chain-data#
Det är här Ethereum blir praktiskt för webbutvecklare. Du behöver inte köra en nod. Du behöver inte mina. Du behöver inte ens en plånbok. Att läsa data från Ethereum är gratis, tillståndslöst och fungerar via ett enkelt JSON-RPC-API.
JSON-RPC: Ethereums HTTP-API#
Varje Ethereum-nod exponerar ett JSON-RPC-API. Det är bokstavligen HTTP POST med JSON-kroppar. Det finns inget blockkedjespecifikt med transportlagret.
// Det här är vad ditt bibliotek gör under huven
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" }Det där är ett rått eth_call. Det simulerar en transaktionsexekvering utan att faktiskt skicka in den. Ingen gaskostnad. Ingen tillståndsändring. Läser bara returvärdet. Det är så view- och pure-funktioner fungerar utifrån — de använder eth_call istället för eth_sendRawTransaction.
De två kritiska RPC-metoderna#
eth_call: Simulerar exekvering. Gratis. Ingen tillståndsändring. Används för alla läsoperationer — kontrollera saldon, läsa priser, anropa view-funktioner. Kan anropas på valfritt historiskt block genom att ange ett blocknummer istället för "latest".
eth_sendRawTransaction: Skickar en signerad transaktion för inkludering i ett block. Kostar gas. Ändrar tillstånd (om lyckad). Används för alla skrivoperationer — överföringar, godkännanden, swappar, mintningar.
Allt annat i JSON-RPC-API:t är antingen en variant av dessa två eller en hjälpmetod (eth_blockNumber, eth_getTransactionReceipt, eth_getLogs, etc.).
Providers: Din gateway till kedjan#
Du kör inte din egen nod. Nästan ingen gör det för applikationsutveckling. Istället använder du en providertjänst:
- Alchemy: Den mest populära. Bra dashboard, webhook-stöd, förbättrade API:er för NFT:er och tokenmetadata. Gratis nivå: ~300M beräkningsenheter/månad.
- Infura: Originalet. Ägs av ConsenSys. Pålitlig. Gratis nivå: 100K förfrågningar/dag.
- QuickNode: Bra för multi-chain. Något annorlunda prismodell.
- Publika RPC-endpoints:
https://rpc.ankr.com/eth,https://cloudflare-eth.com. Gratis men hastighetsbegränsade och ibland opålitliga. Bra för utveckling, farligt för produktion. - Tenderly: Utmärkt för simulering och felsökning. Deras RPC inkluderar en inbyggd transaktionssimulator.
För produktion, konfigurera alltid minst två providers som reservalternativ. RPC-driftstopp är verkligt och det kommer att hända vid sämsta möjliga tidpunkt.
import { ethers } from "ethers";
// ethers.js v6 reserv-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,
},
]);Läsa kontraktstillstånd gratis#
Det här är kraftdraget som de flesta Web2-utvecklare inte inser: du kan läsa vilken publik data som helst från vilket kontrakt som helst på Ethereum utan att betala något, utan plånbok, och utan annan autentisering än en API-nyckel för din RPC-provider.
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
// ERC-20-gränssnitt — bara läsfunktionerna
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 // Notera: provider, inte signer. Skrivskyddad.
);
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 (INTE 18!)
console.log(`Total supply: ${ethers.formatUnits(totalSupply, decimals)}`);
// Kontrollera en specifik adress saldo
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Balance: ${ethers.formatUnits(balance, decimals)} USDC`);Ingen plånbok. Ingen gas. Ingen transaktion. Bara ett JSON-RPC eth_call under huven. Det här är identiskt i koncept med att göra en GET-förfrågan till ett REST-API. Blockkedjan är databasen, kontraktet är API:t, och eth_call är din SELECT-fråga.
ethers.js v6#
ethers.js är jQuery av Web3 — det var det första biblioteket de flesta utvecklare lärde sig, och det är fortfarande det mest använda. Version 6 är en betydande förbättring jämfört med v5, med inbyggt BigInt-stöd (äntligen), ESM-moduler och ett renare API.
De tre kärnabstraktionerna#
Provider: En skrivskyddad anslutning till blockkedjan. Kan anropa view-funktioner, hämta block, läsa loggar. Kan inte signera eller skicka transaktioner.
import { ethers } from "ethers";
// Anslut till en nod
const provider = new ethers.JsonRpcProvider("https://...");
// Grundläggande förfrågningar
const blockNumber = await provider.getBlockNumber();
const balance = await provider.getBalance("0xAddress...");
const block = await provider.getBlock(blockNumber);
const txCount = await provider.getTransactionCount("0xAddress...");Signer: En abstraktion över en privat nyckel. Kan signera transaktioner och meddelanden. En Signer är alltid ansluten till en Provider.
// Från en privat nyckel (serversidan, skript)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
// Från en webbläsarplånbok (klientsidan)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
// Hämta adressen
const address = await signer.getAddress();Contract: En JavaScript-proxy för ett deployat kontrakt. Metoder på Contract-objektet motsvarar funktioner i ABI:n. View-funktioner returnerar värden. Tillståndsändrande funktioner returnerar en TransactionResponse.
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
// Läs (gratis, returnerar värde direkt)
const balance = await usdc.balanceOf("0xSomeAddress...");
// balance är en bigint: 1000000000n (1000 USDC med 6 decimaler)
// För att skriva, anslut med en signer
const usdcWithSigner = usdc.connect(signer);
// Skriv (kostar gas, returnerar TransactionResponse)
const tx = await usdcWithSigner.transfer("0xRecipient...", 1000000n);
const receipt = await tx.wait(); // Vänta på blockinkludering
if (receipt.status === 0) {
throw new Error("Transaction reverted");
}TypeChain för typsäkerhet#
Råa ABI-interaktioner är strängtypade. Du kan felstava ett funktionsnamn, skicka fel argumenttyper, eller misstolka returvärden. TypeChain genererar TypeScript-typer från dina ABI-filer:
// Utan TypeChain — ingen typkontroll
const balance = await contract.balanceOf("0x...");
// balance är 'any'. Ingen autokomplettering. Lätt att använda fel.
// Med TypeChain — full typsäkerhet
import { USDC__factory } from "./typechain";
const usdc = USDC__factory.connect(USDC_ADDRESS, provider);
const balance = await usdc.balanceOf("0x...");
// balance är BigNumber. Autokomplettering fungerar. Typfel fångas vid kompilering.För nya projekt, överväg att använda viems inbyggda typinferens från ABI:er istället. Det uppnår samma resultat utan ett separat kodgenereringssteg.
Lyssna på events#
Realtids-eventströmning är kritisk för responsiva dApps. ethers.js använder WebSocket-providers för detta:
// WebSocket för realtids-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);
// Lyssna på alla Transfer-events
contract.on("Transfer", (from, to, value, event) => {
console.log(`Transfer: ${from} -> ${to}`);
console.log(`Amount: ${ethers.formatUnits(value, 6)} USDC`);
});
// Lyssna på överföringar TILL en specifik adress
const filter = contract.filters.Transfer(null, "0xMyAddress...");
contract.on(filter, (from, to, value) => {
console.log(`Inkommande överföring: ${ethers.formatUnits(value, 6)} USDC från ${from}`);
});
// Städa upp när klart
contract.removeAllListeners();WAGMI + Viem: Den moderna stacken#
WAGMI (We're All Gonna Make It) är ett React hooks-bibliotek för Ethereum. Viem är den underliggande TypeScript-klienten det använder. Tillsammans har de till stor del ersatt ethers.js + web3-react som standardstacken för frontend dApp-utveckling.
Varför skiftet? Tre anledningar: full TypeScript-inferens från ABI:er (ingen kodgenerering behövs), mindre bundlestorlek, och React hooks som hanterar den röriga asynkrona tillståndshanteringen av plånboksinteraktioner.
Komma igång#
// 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>
);
}Läsa kontraktsdata#
useReadContract är hooken du kommer att använda mest. Den wrappar eth_call med React Query-cachning, återhämtning och laddnings-/feltillstånd:
"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>Laddar...</span>;
if (error) return <span>Fel: {error.message}</span>;
// balance är typad som bigint eftersom ABI:n anger uint256
return <span>{formatUnits(balance ?? 0n, 6)} USDC</span>;
}Notera as const på ABI:n. Det här är kritiskt. Utan det förlorar TypeScript de literala typerna och balance blir unknown istället för bigint. Hela typinferenssystemet beror på const assertions.
Skriva till kontrakt#
useWriteContract hanterar hela livscykeln: plånboksuppmaningen, signering, utsändning och bekräftelsespårning.
"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 ? "Bekräfta i plånboken..." : "Skicka 100 USDC"}
</button>
{hash && <p>Transaktion: {hash}</p>}
{isConfirming && <p>Väntar på bekräftelse...</p>}
{isSuccess && <p>Överföring bekräftad!</p>}
{error && <p>Fel: {error.message}</p>}
</div>
);
}Övervaka events#
useWatchContractEvent sätter upp en WebSocket-prenumeration för realtidsövervakning av events:
"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>
);
}Anslut plånboksmönster#
Att ansluta en användares plånbok är "inloggningen" i Web3. Fast det är inte inloggning. Det finns ingen session, ingen cookie, inget tillstånd på serversidan. Plånboksanslutningen ger din app tillstånd att läsa användarens adress och begära transaktionssignaturer. Det är allt.
EIP-1193 Provider-gränssnittet#
Varje plånbok exponerar ett standardgränssnitt definierat av EIP-1193. Det är ett objekt med en request-metod:
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 injicerar detta som window.ethereum. Andra plånböcker injicerar antingen sin egen egenskap eller använder också window.ethereum (vilket orsakar konflikter — "plånbokskriget"-problemet, delvis löst av EIP-6963).
// Lågnivå-plånboksinteraktion (du bör inte göra detta direkt, men det är nyttigt att förstå)
// Begär kontoåtkomst
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
console.log("Ansluten adress:", accounts[0]);
// Hämta den aktuella kedjan
const chainId = await window.ethereum.request({
method: "eth_chainId",
});
console.log("Chain ID:", parseInt(chainId, 16)); // "0x1" -> 1 (mainnet)
// Lyssna på kontoändringar (användaren byter konto i MetaMask)
window.ethereum.on("accountsChanged", (accounts: string[]) => {
if (accounts.length === 0) {
console.log("Plånbok frånkopplad");
} else {
console.log("Bytte till:", accounts[0]);
}
});
// Lyssna på kedjeändringar (användaren byter nätverk)
window.ethereum.on("chainChanged", (chainId: string) => {
// Den rekommenderade metoden är att ladda om sidan
window.location.reload();
});EIP-6963: Upptäckt av flera plånböcker#
Det gamla window.ethereum-tillvägagångssättet går sönder när användare har flera plånböcker installerade. Vilken får window.ethereum? Den som injicerar sist? Först? Det är ett race condition.
EIP-6963 fixar detta med ett upptäcktsprotokoll baserat på webbläsarevents:
// Upptäcka alla tillgängliga plånböcker
interface EIP6963ProviderDetail {
info: {
uuid: string;
name: string;
icon: string;
rdns: string; // Omvänt domännamn, t.ex. "io.metamask"
};
provider: EIP1193Provider;
}
const wallets: EIP6963ProviderDetail[] = [];
window.addEventListener("eip6963:announceProvider", (event: CustomEvent) => {
wallets.push(event.detail);
});
// Begär att alla plånböcker ska annonsera sig
window.dispatchEvent(new Event("eip6963:requestProvider"));
// Nu innehåller 'wallets' alla installerade plånböcker med deras namn och ikoner
// Du kan visa ett plånboksval-UIWAGMI hanterar allt detta åt dig. När du använder injected()-connectorn använder den automatiskt EIP-6963 om det finns tillgängligt och faller tillbaka till window.ethereum.
WalletConnect#
WalletConnect är ett protokoll som kopplar samman mobilplånböcker med desktop-dApps via en reläserver. Användaren skannar en QR-kod med sin mobilplånbok, vilket upprättar en krypterad anslutning. Transaktionsförfrågningar vidarebefordras från din dApp till deras telefon.
Med WAGMI är det bara ytterligare en connector:
import { useConnect } from "wagmi";
function ConnectWallet() {
const { connect, connectors, isPending } = useConnect();
return (
<div>
{connectors.map((connector) => (
<button
key={connector.uid}
onClick={() => connect({ connector })}
disabled={isPending}
>
{connector.name}
</button>
))}
</div>
);
}Det här renderar en knapp för varje tillgänglig connector — injicerad (MetaMask, etc.) och WalletConnect. WAGMI hanterar hela flödet: QR-kodvisning, sessionsupprättande, kontoidentifiering.
Hantera anslutningens livscykel#
"use client";
import { useAccount, useConnect, useDisconnect } from "wagmi";
function WalletStatus() {
const { address, isConnected, chain } = useAccount();
const { connect, connectors } = useConnect();
const { disconnect } = useDisconnect();
if (isConnected) {
return (
<div>
<p>Ansluten: {address}</p>
<p>Kedja: {chain?.name}</p>
<button onClick={() => disconnect()}>Koppla från</button>
</div>
);
}
return (
<div>
{connectors.map((connector) => (
<button key={connector.uid} onClick={() => connect({ connector })}>
Anslut med {connector.name}
</button>
))}
</div>
);
}NFT-metadata: Vad som faktiskt finns on-chain#
De flesta NFT:er lagrar inte bilden on-chain. Det skulle kosta tiotusentals dollar i gas. Istället lagrar de en URI som pekar mot metadata.
ERC-721 tokenURI-mönstret#
const NFT_ABI = [
"function tokenURI(uint256 tokenId) view returns (string)",
"function ownerOf(uint256 tokenId) view returns (address)",
] as const;
const nft = new ethers.Contract(NFT_ADDRESS, NFT_ABI, provider);
// Hämta metadata-URI:n
const tokenURI = await nft.tokenURI(42n);
console.log(tokenURI);
// Typiskt: "ipfs://QmYx6GsYAKnNzZ9A6NvEKV9nf1VaDzJrqDR23Y8YSkebLU/42"
// Eller: "https://api.example.com/tokens/42"
// Eller: "data:application/json;base64,eyJuYW1lIjoi..."
// Hämta metadata
let metadata;
if (tokenURI.startsWith("ipfs://")) {
const httpUrl = tokenURI.replace("ipfs://", "https://ipfs.io/ipfs/");
metadata = await fetch(httpUrl).then((r) => r.json());
} else if (tokenURI.startsWith("data:")) {
const base64 = tokenURI.split(",")[1];
metadata = JSON.parse(atob(base64));
} else {
metadata = await fetch(tokenURI).then((r) => r.json());
}
console.log(metadata);
// {
// "name": "My NFT #42",
// "description": "A unique digital collectible",
// "image": "ipfs://QmImage.../42.png",
// "attributes": [
// { "trait_type": "Background", "value": "Blue" },
// { "trait_type": "Rarity", "value": "Legendary" }
// ]
// }De tre lagringsmönstren#
-
On-chain-metadata (sällsynt, dyr): Allt lagras i kontraktet.
tokenURIreturnerar endata:URI med base64-kodad JSON. Svarta Cats, Loot och Nouns gör detta. Förhindrar att metadata försvinner, men kostar betydligt mer att minta. -
IPFS-baserad (vanligt, decentraliserad): Metadata och bilder lagras på IPFS.
tokenURIreturneraripfs://.... Innehåll är adresserat med hash, så det kan inte ändras efter mintning. Men någon måste pinna filerna — om alla pinning-noder försvinner kan data gå förlorad. -
Centraliserad server (vanligt, riskabelt): Metadata lagras på en vanlig server.
tokenURIreturnerarhttps://api.example.com/.... Skaparen kan ändra metadata när som helst. Om servern stängs ned blir din NFT en broken link. Många storsäljande kollektioner gör detta.
Det är viktigt att veta vilken modell en NFT använder. För en handelsplats eller portfölj-visning behöver du hantera alla tre fallen.
Frekvent misstag och gotchas#
Decimalhantering#
ETH har 18 decimaler. USDC har 6. WBTC har 8. Anta ALDRIG 18 decimaler.
// FEL — förutsätter 18 decimaler
const balance = ethers.formatEther(rawBalance);
// RÄTT — använd tokens faktiska decimaler
const decimals = await token.decimals();
const balance = ethers.formatUnits(rawBalance, decimals);Att blanda ihop decimaler är hur du visar att någon har 1 000 000 000 tokens istället för 1 000.
BigInt-aritmetik#
Token-belopp är bigint. Du kan inte blanda dem med reguljära number.
// FEL — tyst förlust av precision
const amount = Number(rawBalance) * 1.5;
// RÄTT — bigint-aritmetik
const amount = (rawBalance * 150n) / 100n;Division trunkeras: 7n / 2n = 3n, inte 3.5. Ordna dina operationer med multiplikation först.
Nonce-hantering#
Varje transaktion från en EOA måste ha exakt rätt nonce. Skickar du nonce 5 två gånger ersätter den andra den första (om den har högre gas). Hoppar du till nonce 7 utan att skicka 6 hänger sig transaktion 7 för alltid.
// Manuell nonce-hantering för flera transaktioner
let nonce = await provider.getTransactionCount(address, "pending");
const tx1 = await wallet.sendTransaction({ ...params1, nonce: nonce++ });
const tx2 = await wallet.sendTransaction({ ...params2, nonce: nonce++ });
const tx3 = await wallet.sendTransaction({ ...params3, nonce: nonce++ });Bibliotek hanterar nonces automatiskt för enstaka transaktioner. Du behöver bara hantera det manuellt om du skickar flera transaktioner i snabb sekvens.
Gasestimering#
Estimeringsfunktioner kan ljuga. Inte avsiktligt — de simulerar på det aktuella tillståndet, men tillståndet kan ändras innan din transaktion körs.
// Estimera gas
const estimated = await contract.transfer.estimateGas(to, amount);
// Lägg till en buffert (20% är vanligt)
const gasLimit = (estimated * 120n) / 100n;
const tx = await contract.transfer(to, amount, { gasLimit });Utan buffert kan din transaktion misslyckas med "out of gas" om tillståndet ändras något mellan estimering och exekvering.
Ethereum-utveckling som webbutvecklare handlar inte om att lära sig en helt ny paradigm. Det handlar om att förstå en specifik uppsättning begränsningar (gas kostar pengar, skrivningar kräver signaturer, tillstånd är globalt) och använda verktyg som gör dessa begränsningar hanterbara. ethers.js, viem och WAGMI är mogna bibliotek som abstraherar de svåra delarna utan att dölja dem.
Börja med att läsa data. Det är gratis, det kräver ingen plånbok, och det ger dig en känsla för hur blockkedjan faktiskt fungerar. Bygg sedan en komponent som visar ett tokensaldo. Sedan en som låter en användare ansluta sin plånbok. Sedan en som skickar en transaktion. Varje steg bygger på det föregående, och inget av dem kräver att du förstår konsensusalgoritmer eller kryptografi på låg nivå.
Den tekniska modellen är enklare än ekosystemet får det att verka.