Ethereum dla web developerów: Smart contracty bez hype'u
Koncepcje Ethereum, które każdy web developer powinien znać: konta, transakcje, smart contracty, kodowanie ABI, ethers.js, WAGMI i odczytywanie danych on-chain bez uruchamiania własnego node'a.
Większość treści "Ethereum dla deweloperów" wpada w jedną z dwóch kategorii: uproszczone analogie, które nie pomagają w budowaniu czegokolwiek, lub głębokie specyfikacje protokołu, które zakładają, że już wiesz, czym jest Merkle Patricia Trie. Żadna z nich nie jest użyteczna, jeśli jesteś web developerem, który chce odczytać saldo tokenów, pozwolić użytkownikowi podpisać transakcję lub wyświetlić metadane NFT w aplikacji React.
Ten post to praktyczny środek. Wytłumaczę dokładnie, co się dzieje, gdy twój frontend rozmawia z Ethereum, jakie są ruchome części i jak nowoczesne narzędzia (ethers.js, viem, WAGMI) mapują się na koncepcje, które już rozumiesz z budowania aplikacji webowych.
Żadnych metafor o automatach z napojami. Żadnych "wyobraź sobie świat, w którym..." Tylko model techniczny i kod.
Model mentalny#
Ethereum to zreplikowana maszyna stanów. Każdy node w sieci utrzymuje identyczną kopię stanu — masywny magazyn klucz-wartość mapujący adresy na dane kont. Gdy "wysyłasz transakcję", proponujesz przejście stanu. Jeśli wystarczająco wielu walidatorów uzna je za ważne, stan się aktualizuje. To tyle.
Sam stan jest prosty. To mapowanie z 20-bajtowych adresów na obiekty kont. Każde konto ma cztery pola:
- nonce: Ile transakcji to konto wysłało (dla EOA) lub ile contractów stworzyło (dla kont contractów). Zapobiega atakom replay.
- balance: Ilość ETH, denominowana w wei (1 ETH = 10^18 wei). Zawsze big integer.
- codeHash: Hash bytecode'u EVM. Dla zwykłych portfeli (EOA) to hash pustych bajtów. Dla contractów to hash wdrożonego kodu.
- storageRoot: Root hash trie storage konta. Tylko contracty mają sensowne storage.
Istnieją dwa typy kont i rozróżnienie ma znaczenie dla wszystkiego, co następuje:
Externally Owned Accounts (EOA) są kontrolowane przez klucz prywatny. To jest to, co zarządza MetaMask. Mogą inicjować transakcje. Nie mają kodu. Kiedy ktoś mówi "portfel", ma na myśli EOA.
Contract Accounts są kontrolowane przez swój kod. Nie mogą inicjować transakcji — mogą tylko wykonywać się w odpowiedzi na wywołanie. Mają kod i storage. Kiedy ktoś mówi "smart contract", ma na myśli to. Kod jest niezmienny po wdrożeniu (z pewnymi wyjątkami przez wzorce proxy, co to osobna dyskusja).
Krytyczne spostrzeżenie: każda zmiana stanu na Ethereum zaczyna się od EOA podpisującego transakcję. Contracty mogą wywoływać inne contracty, ale łańcuch wykonania zawsze zaczyna się od człowieka (lub bota) z kluczem prywatnym.
Gas: Obliczenia mają cenę#
Każda operacja w EVM kosztuje gas. Dodanie dwóch liczb kosztuje 3 gas. Zapisanie 32-bajtowego słowa kosztuje 20 000 gas (pierwszy raz) lub 5 000 gas (aktualizacja). Odczyt storage kosztuje 2 100 gas (cold) lub 100 gas (warm, już odczytany w tej transakcji).
Nie płacisz gasu w "jednostkach gasu". Płacisz w ETH. Całkowity koszt to:
totalCost = gasUsed * gasPrice
Po EIP-1559 (London upgrade) cennik gasu stał się dwuczęściowy:
totalCost = gasUsed * (baseFee + priorityFee)
- baseFee: Ustalany przez protokół na podstawie obciążenia sieci. Zostaje spalony (zniszczony).
- priorityFee (napiwek): Trafia do walidatora. Wyższy napiwek = szybsze włączenie.
- maxFeePerGas: Maksimum, ile jesteś gotów zapłacić za jednostkę gasu.
- maxPriorityFeePerGas: Maksymalny napiwek za jednostkę gasu.
Jeśli baseFee + priorityFee > maxFeePerGas, twoja transakcja czeka, aż baseFee spadnie. Dlatego transakcje "zacinają się" podczas dużego obciążenia.
Praktyczne implikacje dla web developerów: odczyt danych jest darmowy. Zapis danych kosztuje pieniądze. To najważniejsza architektoniczna różnica między Web2 a Web3. Każdy SELECT jest darmowy. Każdy INSERT, UPDATE, DELETE kosztuje prawdziwe pieniądze. Projektuj swoje dAppy odpowiednio.
Transakcje#
Transakcja to podpisana struktura danych. Oto pola, które mają znaczenie:
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;
}Cykl życia transakcji#
-
Budowa: Twoja aplikacja buduje obiekt transakcji. Jeśli wywołujesz funkcję contractu, pole
datazawiera zakodowane ABI wywołanie funkcji (więcej o tym poniżej). -
Podpisywanie: Klucz prywatny podpisuje transakcję zakodowaną w RLP, produkując komponenty podpisu
v,r,s. To dowodzi, że nadawca autoryzował tę konkretną transakcję. Adres nadawcy jest wyprowadzony z podpisu — nie jest jawnie w transakcji. -
Broadcast: Podpisana transakcja jest wysyłana do node'a RPC przez
eth_sendRawTransaction. Node ją waliduje (poprawny nonce, wystarczające saldo, ważny podpis) i dodaje do mempoola. -
Mempool: Transakcja siedzi w puli oczekujących transakcji. Walidatorzy wybierają transakcje do włączenia w następny blok, generalnie preferując wyższe napiwki. Tu dzieje się front-running — inni aktorzy mogą zobaczyć twoją oczekującą transakcję i wysłać własną z wyższym napiwkiem, żeby wykonać się przed twoją.
-
Włączenie: Walidator włącza twoją transakcję w blok. EVM ją wykonuje. Jeśli się powiedzie, zmiany stanu są aplikowane. Jeśli się cofnie, zmiany stanu są wycofane — ale nadal płacisz za gas zużyty do punktu cofnięcia.
-
Finalność: Na proof-of-stake Ethereum blok staje się "sfinalizowany" po dwóch epokach (~12,8 minuty). Przed finalnością reorganizacje łańcucha są teoretycznie możliwe (choć rzadkie). Większość aplikacji traktuje 1-2 potwierdzenia bloku jako "wystarczająco dobre" dla niekrytycznych operacji.
Oto jak wygląda wysyłanie prostego transferu ETH z 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 = revertI to samo z 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);Zwróć uwagę na różnicę: ethers zwraca obiekt TransactionResponse z metodą .wait(). Viem zwraca sam hash — używasz osobnego wywołania publicClient.waitForTransactionReceipt({ hash }) do czekania na potwierdzenie. To rozdzielenie odpowiedzialności jest celowe w designie viem.
Smart Contracty#
Smart contract to wdrożony bytecode plus trwałe storage pod określonym adresem. Gdy "wywołujesz" contract, wysyłasz transakcję (lub robisz wywołanie tylko do odczytu) z polem data ustawionym na zakodowane wywołanie funkcji.
Bytecode i ABI#
Bytecode to skompilowany kod EVM. Nie wchodzisz z nim w interakcję bezpośrednio. To jest to, co EVM wykonuje.
ABI (Application Binary Interface) to opis interfejsu contractu w JSON. Mówi twojej bibliotece klienta, jak kodować wywołania funkcji i dekodować zwracane wartości. Pomyśl o tym jak o specyfikacji OpenAPI dla contractu.
Oto fragment ABI tokena ERC-20:
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 akceptuje ten "human-readable ABI" format. Viem też może go używać, ale często pracujesz z pełnym JSON ABI generowanym przez kompilator Solidity. Oba są równoważne — format human-readable jest po prostu wygodniejszy dla popularnych interfejsów.
Jak kodowane są wywołania funkcji#
To jest ta część, którą większość tutoriali pomija, a która zaoszczędzi ci godziny debugowania.
Gdy wywołujesz transfer("0xBob...", 1000000), pole data transakcji jest ustawiane na:
0xa9059cbb // Function selector
0000000000000000000000000xBob...000000000000000000000000 // address, padded to 32 bytes
00000000000000000000000000000000000000000000000000000000000f4240 // uint256 amount (1000000 in hex)
Function selector to pierwsze 4 bajty hasza Keccak-256 sygnatury funkcji:
keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = first 4 bytes = 0xa9059cbb
Pozostałe bajty to argumenty zakodowane ABI, każdy dopełniony do 32 bajtów. Ten schemat kodowania jest deterministyczny — to samo wywołanie funkcji zawsze produkuje te same calldata.
Dlaczego to ma znaczenie? Bo gdy widzisz surowe dane transakcji na Etherscan i zaczynają się od 0xa9059cbb, wiesz, że to wywołanie transfer. Gdy twoja transakcja się cofa i komunikat o błędzie to hex blob, możesz go zdekodować używając ABI. I gdy budujesz batch transakcji lub wchodzisz w interakcję z contractami multicall, będziesz ręcznie kodować calldata.
Oto jak ręcznie kodować i dekodować z 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#
Storage contractu to magazyn klucz-wartość, gdzie zarówno klucze, jak i wartości mają 32 bajty. Solidity przypisuje storage sloty sekwencyjnie od 0. Pierwsza zadeklarowana zmienna stanu trafia do slotu 0, następna do slotu 1 i tak dalej. Mapowania i dynamiczne tablice używają schematu opartego na hashach.
Możesz czytać storage dowolnego contractu bezpośrednio, nawet jeśli zmienna jest oznaczona private w Solidity. "Private" oznacza tylko, że inne contracty nie mogą jej czytać — każdy może ją odczytać przez eth_getStorageAt:
// Reading storage slot 0 of a contract
const slot0 = await provider.getStorage(
"0xContractAddress...",
0
);
console.log(slot0); // Raw 32-byte hex valueTak eksplorery bloków pokazują "wewnętrzny" stan contractu. Nie ma kontroli dostępu na odczytach storage. Prywatność na publicznym blockchainie jest fundamentalnie ograniczona.
Eventy i logi#
Eventy to sposób, w jaki contract emituje strukturalne dane, które są zapisywane w logach transakcji, ale nie w storage contractu. Są tańsze niż zapisy do storage (375 gasu za pierwszy topic + 8 gasu za bajt danych, vs 20 000 gasu za zapis do storage) i są zaprojektowane do wydajnego odpytywania.
Event może mieć do 3 parametrów indexed (zapisywanych jako "topics") i dowolną liczbę parametrów nieindeksowanych (zapisywanych jako "data"). Po parametrach indeksowanych można filtrować — możesz zapytać "daj mi wszystkie eventy Transfer, gdzie to to ten adres". Po parametrach nieindeksowanych nie można filtrować; musisz pobrać wszystkie pasujące eventy i filtrować po stronie klienta.
// 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());
}To samo z 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 listeningOdczyt danych on-chain#
Tu Ethereum staje się praktyczne dla web developerów. Nie musisz uruchamiać node'a. Nie musisz kopać. Nie potrzebujesz nawet portfela. Odczyt danych z Ethereum jest darmowy, bezpermisyjny i działa przez proste JSON-RPC API.
JSON-RPC: HTTP API Ethereum#
Każdy node Ethereum udostępnia JSON-RPC API. To dosłownie HTTP POST z ciałami JSON. Nie ma nic blockchain-specyficznego w warstwie transportowej.
// 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" }To surowe eth_call. Symuluje wykonanie transakcji bez jej faktycznego wysyłania. Brak kosztu gasu. Brak zmiany stanu. Po prostu odczytuje zwracaną wartość. Tak działają funkcje view i pure z zewnątrz — używają eth_call zamiast eth_sendRawTransaction.
Dwie krytyczne metody RPC#
eth_call: Symuluje wykonanie. Darmowe. Brak zmiany stanu. Używane do wszystkich operacji odczytu — sprawdzanie sald, odczyt cen, wywoływanie funkcji view. Może być wywołane na dowolnym historycznym bloku przez podanie numeru bloku zamiast "latest".
eth_sendRawTransaction: Wysyła podpisaną transakcję do włączenia w blok. Kosztuje gas. Zmienia stan (jeśli się powiedzie). Używane do wszystkich operacji zapisu — transfery, zatwierdzenia, swapy, minty.
Wszystko inne w JSON-RPC API to albo wariant tych dwóch, albo metoda narzędziowa (eth_blockNumber, eth_getTransactionReceipt, eth_getLogs, itp.).
Providerzy: Twoja brama do łańcucha#
Nie uruchamiasz własnego node'a. Prawie nikt tego nie robi do developmentu aplikacji. Zamiast tego korzystasz z usługi providera:
- Alchemy: Najpopularniejszy. Świetny dashboard, wsparcie webhooków, ulepszone API dla NFT i metadanych tokenów. Darmowy tier: ~300M compute units/miesiąc.
- Infura: Oryginał. Właściciel ConsenSys. Niezawodny. Darmowy tier: 100K żądań/dzień.
- QuickNode: Dobry do multi-chain. Nieco inny model cenowy.
- Publiczne endpointy RPC:
https://rpc.ankr.com/eth,https://cloudflare-eth.com. Darmowe, ale z limitami rate i okazjonalnie zawodne. W porządku na development, niebezpieczne na produkcję. - Tenderly: Świetny do symulacji i debugowania. Ich RPC zawiera wbudowany symulator transakcji.
Na produkcję zawsze konfiguruj przynajmniej dwóch providerów jako fallback. Przestoje RPC są realne i zdarzą się w najgorszym możliwym momencie.
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,
},
]);Odczyt stanu contractu za darmo#
To jest power move, którego większość deweloperów Web2 nie zdaje sobie sprawy: możesz czytać dowolne publiczne dane z dowolnego contractu na Ethereum bez płacenia czegokolwiek, bez portfela i bez żadnej autentykacji poza kluczem API do providera RPC.
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`);Bez portfela. Bez gasu. Bez transakcji. Tylko JSON-RPC eth_call pod maską. To jest koncepcyjnie identyczne z wykonaniem żądania GET do REST API. Blockchain jest bazą danych, contract jest API, a eth_call to twoje zapytanie SELECT.
ethers.js v6#
ethers.js to jQuery Web3 — to była pierwsza biblioteka, którą większość deweloperów poznała, i nadal jest najszerzej używana. Wersja 6 to znaczące ulepszenie względem v5, z natywnym wsparciem BigInt (w końcu), modułami ESM i czystszym API.
Trzy kluczowe abstrakcje#
Provider: Połączenie tylko do odczytu z blockchainem. Może wywoływać funkcje view, pobierać bloki, czytać logi. Nie może podpisywać ani wysyłać transakcji.
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: Abstrakcja nad kluczem prywatnym. Może podpisywać transakcje i wiadomości. Signer jest zawsze połączony z Providerem.
// 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: JavaScriptowy proxy dla wdrożonego contractu. Metody na obiekcie Contract odpowiadają funkcjom w ABI. Funkcje view zwracają wartości. Funkcje zmieniające stan zwracają 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 dla type safety#
Surowe interakcje ABI są stringly-typed. Możesz źle napisać nazwę funkcji, przekazać złe typy argumentów lub źle zinterpretować wartości zwracane. TypeChain generuje typy TypeScript z twoich plików ABI:
// 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.Dla nowych projektów rozważ użycie wbudowanej inferencji typów viem z ABI. Osiąga ten sam rezultat bez osobnego kroku generacji kodu.
Nasłuchiwanie eventów#
Streamowanie eventów w czasie rzeczywistym jest krytyczne dla responsywnych dAppów. ethers.js używa WebSocket providerów do tego:
// 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: Nowoczesny stos#
WAGMI (We're All Gonna Make It) to biblioteka React hooków dla Ethereum. Viem to leżący pod spodem klient TypeScript. Razem w dużej mierze zastąpiły ethers.js + web3-react jako standardowy stos do frontendowego developmentu dApp.
Dlaczego zmiana? Trzy powody: pełna inferencja typów TypeScript z ABI (bez codegen), mniejszy rozmiar bundle i React hooki obsługujące brudne zarządzanie stanem async interakcji z portfelem.
Konfiguracja#
// 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>
);
}Odczyt danych contractu#
useReadContract to hook, którego będziesz używać najczęściej. Opakowuje eth_call z cachowaniem React Query, refetchingiem i stanami loading/error:
"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>;
}Zwróć uwagę na as const na ABI. To jest krytyczne. Bez tego TypeScript traci literalne typy i balance staje się unknown zamiast bigint. Cały system inferencji typów zależy od const assertions.
Zapis do contractów#
useWriteContract obsługuje pełny cykl życia: prompt portfela, podpisywanie, broadcast i śledzenie potwierdzenia.
"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>
);
}Obserwowanie eventów#
useWatchContractEvent ustawia subskrypcję WebSocket do monitorowania eventów w czasie rzeczywistym:
"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>
);
}Wzorce łączenia portfela#
Łączenie portfela użytkownika to "logowanie" Web3. Tyle że to nie jest logowanie. Nie ma sesji, nie ma cookie, nie ma stanu po stronie serwera. Połączenie portfela daje twojej aplikacji uprawnienia do odczytu adresu użytkownika i żądania podpisów transakcji. To tyle.
Interfejs EIP-1193 Provider#
Każdy portfel udostępnia standardowy interfejs zdefiniowany przez EIP-1193. To obiekt z metodą request:
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 wstrzykuje go jako window.ethereum. Inne portfele albo wstrzykują własną właściwość, albo też używają window.ethereum (co powoduje konflikty — problem "wallet wars", częściowo rozwiązany przez 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: Odkrywanie wielu portfeli#
Stare podejście z window.ethereum psuje się, gdy użytkownicy mają zainstalowanych wiele portfeli. Który dostaje window.ethereum? Ostatni do wstrzyknięcia? Pierwszy? To race condition.
EIP-6963 naprawia to protokołem odkrywania opartym na 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 obsługuje to wszystko za ciebie. Kiedy używasz konektora injected(), automatycznie używa EIP-6963, jeśli jest dostępny, i cofa się do window.ethereum.
Obsługa przełączania sieci#
Użytkownicy często są na złej sieci. Twoja dApp jest na Mainnecie, oni są połączeni z Sepolią. Albo są na Polygon, a ty potrzebujesz Mainnet. WAGMI udostępnia 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 i metadane#
NFT nie przechowują obrazów on-chain. Blockchain przechowuje URI wskazujący na plik JSON z metadanymi, który z kolei zawiera URL do obrazu. Standardowy wzorzec, zdefiniowany przez funkcję tokenURI ERC-721:
Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"
Ten plik JSON podąża za standardowym schematem:
{
"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#
Adresy IPFS używają Content Identifiers (CID) — hashy samej treści. ipfs://QmXyz... oznacza "treść, której hash to QmXyz...". To storage adresowany treścią: URI jest wyprowadzony z treści, więc treść nigdy nie może się zmienić bez zmiany URI. To jest gwarancja niezmienności, na której polegają NFT (gdy faktycznie używają IPFS — wiele używa scentralizowanych URL-i, co jest red flagiem).
Aby wyświetlić treść IPFS w przeglądarce, potrzebujesz gateway, który tłumaczy URI IPFS na 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,
};
}Usługi pinningu#
IPFS to sieć peer-to-peer. Treść jest dostępna tylko tak długo, jak ktoś ją hostuje ("pinuje"). Jeśli wgrasz obraz NFT na IPFS, a potem wyłączysz swój node, treść zniknie.
Usługi pinningu utrzymują twoją treść dostępną:
- Pinata: Najpopularniejszy. Proste API. Hojny darmowy tier (1GB). Dedykowane gateway do szybszego ładowania.
- NFT.Storage: Darmowy, wspierany przez Protocol Labs (twórców IPFS). Zaprojektowany specjalnie do metadanych NFT. Używa Filecoin do długoterminowej trwałości.
- Web3.Storage: Podobny do NFT.Storage, bardziej ogólnego przeznaczenia.
// 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
}Problem indeksowania#
Oto brudny sekret developmentu blockchain: nie możesz wydajnie odpytywać historycznych danych z node'a RPC.
Chcesz wszystkie eventy Transfer dla tokena z ostatniego roku? Musisz przeskanować miliony bloków z eth_getLogs, paginując w kawałkach po 2000-10000 bloków (maksimum różni się w zależności od providera). To tysiące wywołań RPC. Zajmie minuty do godzin i zżre twoją kwotę API.
Chcesz wszystkie tokeny posiadane przez określony adres? Nie ma jednego wywołania RPC do tego. Musiałbyś przeskanować każdy event Transfer dla każdego contractu ERC-20, śledząc salda. To niewykonalne.
Chcesz wszystkie NFT w portfelu? Ten sam problem. Musisz przeskanować każdy event Transfer ERC-721 dla każdego contractu NFT.
Blockchain to struktura danych zoptymalizowana pod zapis. Jest świetny w przetwarzaniu nowych transakcji. Jest okropny w odpowiadaniu na zapytania historyczne. To fundamentalna rozbieżność między tym, czego potrzebują UI dAppów, a co łańcuch natywnie dostarcza.
Protokół The Graph#
The Graph to zdecentralizowany protokół indeksowania. Piszesz "subgraph" — schemat i zestaw handlerów eventów — a The Graph indeksuje łańcuch i serwuje dane przez 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;
}Kompromis: The Graph dodaje opóźnienie (zwykle 1-2 bloki za głowicą łańcucha) i kolejną zależność. Zdecentralizowana sieć ma też koszty indeksowania (płacisz tokenami GRT). Dla mniejszych projektów hostowana usługa (Subgraph Studio) jest darmowa.
Ulepszone API Alchemy i Moralis#
Jeśli nie chcesz utrzymywać subgrapha, zarówno Alchemy, jak i Moralis oferują przed-indeksowane API, które odpowiadają na typowe zapytania bezpośrednio:
// 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}`);
}Te API są własnościowe i scentralizowane. Wymieniasz decentralizację na developer experience. Dla większości dAppów to opłacalny kompromis. Twoi użytkownicy nie obchodzi, czy widok portfolio pochodzi z subgrapha czy z bazy Alchemy. Obchodzi ich, że ładuje się w 200ms, a nie 30 sekund.
Typowe pułapki#
Po wdrożeniu kilku produkcyjnych dAppów i debugowaniu kodu innych zespołów, oto błędy, które widzę wielokrotnie. Każdy z nich osobiście mnie ugryzł.
BigInt wszędzie#
Ethereum operuje na bardzo dużych liczbach. Salda ETH są w wei (10^18). Podaże tokenów mogą wynosić 10^27 lub więcej. JavaScript Number może bezpiecznie reprezentować liczby całkowite tylko do 2^53 - 1 (około 9 * 10^15). To nie wystarcza dla kwot w wei.
// 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" — correctZasady dla BigInt w kodzie dApp:
- Nigdy nie konwertuj kwot wei do
Number. UżywajBigIntwszędzie, konwertuj na czytelne stringi tylko do wyświetlania. - Nigdy nie używaj
Math.floor,Math.round, itp. na BigInt. Nie działają. Używaj dzielenia całkowitego:amount / 10n ** 6n. - JSON nie obsługuje BigInt. Jeśli serializujesz stan zawierający BigInt, potrzebujesz własnego serializera:
JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v). - Używaj funkcji formatujących bibliotek.
ethers.formatEther(),ethers.formatUnits(),formatEther()iformatUnits()z viem. Obsługują konwersję poprawnie.
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"Asynchroniczne operacje portfela#
Każda interakcja z portfelem jest async i może zawieść w sposoby, które twoja aplikacja musi obsługiwać elegancko:
// 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.");
}
}Kluczowe pułapki async:
- Prompty portfela blokują po stronie użytkownika.
awaitw twoim kodzie może trwać 30 sekund, podczas gdy użytkownik czyta szczegóły transakcji w MetaMask. Nie pokazuj spinnera ładowania, który sprawi, że pomyślą, że coś się zepsuło. - Użytkownik może zmienić konto w trakcie interakcji. Żądasz zatwierdzenia z Konta A, użytkownik przełącza się na Konto B, a potem zatwierdza. Teraz Konto B zatwierdziło, ale zaraz wyślesz transakcję z Konta A. Zawsze ponownie sprawdzaj połączone konto przed krytycznymi operacjami.
- Dwuetapowe wzorce zapisu są powszechne. Wiele operacji DeFi wymaga
approve+execute. Użytkownik musi podpisać dwie transakcje. Jeśli zatwierdzi, ale nie wykona, musisz sprawdzić stan allowance i pominąć krok zatwierdzenia następnym razem.
Błędy niezgodności sieci#
Ten marnuje więcej czasu na debugowanie niż jakikolwiek inny problem. Twój contract jest na Mainnecie. Twój portfel jest na Sepolii. Twój provider RPC wskazuje na Polygon. Trzy różne sieci, trzy różne stany, trzy kompletnie niepowiązane blockchainy. A komunikat o błędzie jest zwykle niepomocy — "execution reverted" lub "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 w DeFi#
Gdy wysyłasz swap na DEX, twoja oczekująca transakcja jest widoczna w mempoolu. Bot może zobaczyć twój trade, front-runować go pchając cenę w górę, pozwolić twojemu trade'owi wykonać się po gorszej cenie, a potem natychmiast sprzedać z zyskiem. To nazywa się "sandwich attack".
Jako frontend developer nie możesz temu w pełni zapobiec, ale możesz to złagodzić:
// 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
);Dla transakcji o dużej wartości rozważ użycie Flashbots Protect RPC, które wysyła transakcje bezpośrednio do block builderów zamiast do publicznego mempoola. To zapobiega sandwich attacks całkowicie, bo boty nigdy nie widzą twojej oczekującej transakcji:
// Using Flashbots Protect as your RPC endpoint
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");Zamieszanie z decimalami#
Nie wszystkie tokeny mają 18 decimali. USDC i USDT mają 6. WBTC ma 8. Niektóre tokeny mają 0, 2 lub arbitralne decimale. Zawsze czytaj decimals() z contractu przed formatowaniem kwot:
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"Błędy estymacji gasu#
Gdy estimateGas zawodzi, zwykle oznacza to, że transakcja się cofnie. Ale komunikat o błędzie to często po prostu "cannot estimate gas" bez wskazania dlaczego. Użyj eth_call do symulacji transakcji i uzyskania faktycznego powodu cofnięcia:
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;
}
}Złożenie całości#
Oto kompletny, minimalny komponent React, który łączy portfel, odczytuje saldo tokenów i wysyła transfer. To szkielet każdej 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>
);
}Co dalej#
Ten post pokrył niezbędne koncepcje i narzędzia dla web developerów wchodzących w Ethereum. W każdym obszarze jest dużo więcej głębi:
- Solidity: Jeśli chcesz pisać contracty, nie tylko z nimi wchodzić w interakcję. Oficjalna dokumentacja i kursy Patricka Collinsa to najlepsze punkty startowe.
- Standardy ERC: ERC-20 (tokeny zamienne), ERC-721 (NFT), ERC-1155 (multi-token), ERC-4626 (tokenizowane skarbce). Każdy definiuje standardowy interfejs, który implementują wszystkie contracty w tej kategorii.
- Layer 2: Arbitrum, Optimism, Base, zkSync. To samo developer experience, niższe koszty gasu, nieco inne założenia zaufania. Twój kod ethers.js i viem działa identycznie — zmień tylko chain ID i URL RPC.
- Account Abstraction (ERC-4337): Następna ewolucja UX portfeli. Smart contract portfele wspierające sponsorowanie gasu, social recovery i batch transakcje. To jest kierunek, w którym zmierza wzorzec "connect wallet".
- MEV i kolejność transakcji: Jeśli budujesz DeFi, rozumienie Maximal Extractable Value nie jest opcjonalne. Dokumentacja Flashbots to kanoniczne źródło.
Ekosystem blockchain porusza się szybko, ale fundamenty w tym poście — konta, transakcje, kodowanie ABI, wywołania RPC, indeksowanie eventów — nie zmieniły się od 2015 i nie zmienią się w najbliższym czasie. Naucz się ich dobrze, a wszystko inne to tylko powierzchnia API.