Ethereum untuk Web Developer: Smart Contracts Tanpa Hype
Konsep Ethereum yang perlu dipahami setiap web developer: accounts, transactions, smart contracts, ABI encoding, ethers.js, WAGMI, dan membaca data on-chain tanpa menjalankan node sendiri.
Sebagian besar konten "Ethereum untuk developer" terbagi dalam dua kategori: analogi yang terlalu disederhanakan yang tidak membantu Anda membangun apa pun, atau spesifikasi protokol mendalam yang mengasumsikan Anda sudah tahu apa itu Merkle Patricia Trie. Keduanya tidak berguna jika Anda seorang web developer yang ingin membaca saldo token, membiarkan pengguna menandatangani transaksi, atau menampilkan metadata NFT di aplikasi React.
Posting ini adalah jalan tengah yang praktis. Saya akan menjelaskan persis apa yang terjadi ketika frontend Anda berkomunikasi dengan Ethereum, apa saja bagian yang bergerak, dan bagaimana tooling modern (ethers.js, viem, WAGMI) memetakan ke konsep yang sudah Anda pahami dari membangun aplikasi web.
Tanpa metafora tentang mesin penjual otomatis. Tanpa "bayangkan dunia di mana..." Hanya model teknis dan kodenya.
Model Mental#
Ethereum adalah mesin state yang direplikasi. Setiap node di jaringan memelihara salinan identik dari state — penyimpanan key-value besar yang memetakan alamat ke data akun. Ketika Anda "mengirim transaksi", Anda mengusulkan transisi state. Jika cukup banyak validator setuju itu valid, state diperbarui. Itu saja.
State-nya sendiri cukup sederhana. Ini adalah pemetaan dari alamat 20-byte ke objek akun. Setiap akun memiliki empat field:
- nonce: Berapa banyak transaksi yang sudah dikirim akun ini (untuk EOA) atau berapa banyak kontrak yang telah dibuat (untuk akun kontrak). Ini mencegah serangan replay.
- balance: Jumlah ETH, dalam satuan wei (1 ETH = 10^18 wei). Selalu bilangan bulat besar.
- codeHash: Hash dari bytecode EVM. Untuk dompet biasa (EOA), ini adalah hash dari byte kosong. Untuk kontrak, ini hash dari kode yang di-deploy.
- storageRoot: Root hash dari storage trie akun. Hanya kontrak yang memiliki storage yang bermakna.
Ada dua jenis akun, dan perbedaan ini penting untuk semua yang mengikuti:
Externally Owned Accounts (EOA) dikontrol oleh private key. Inilah yang dikelola MetaMask. Mereka bisa memulai transaksi. Mereka tidak punya kode. Ketika seseorang berkata "dompet", yang mereka maksud adalah EOA.
Contract Accounts dikontrol oleh kode mereka. Mereka tidak bisa memulai transaksi — mereka hanya bisa mengeksekusi sebagai respons terhadap pemanggilan. Mereka punya kode dan storage. Ketika seseorang berkata "smart contract", yang mereka maksud adalah ini. Kodenya immutable setelah di-deploy (dengan beberapa pengecualian melalui pola proxy, yang merupakan diskusi tersendiri).
Wawasan kritis: setiap perubahan state di Ethereum dimulai dengan EOA yang menandatangani transaksi. Kontrak bisa memanggil kontrak lain, tapi rantai eksekusi selalu dimulai dengan manusia (atau bot) yang memiliki private key.
Gas: Komputasi Ada Harganya#
Setiap operasi di EVM memakan biaya gas. Menambahkan dua angka memakan 3 gas. Menyimpan word 32-byte memakan 20.000 gas (pertama kali) atau 5.000 gas (pembaruan). Membaca storage memakan 2.100 gas (cold) atau 100 gas (warm, sudah diakses di transaksi ini).
Anda tidak membayar gas dalam "unit gas." Anda membayar dalam ETH. Total biayanya adalah:
totalCost = gasUsed * gasPrice
Setelah EIP-1559 (upgrade London), harga gas menjadi sistem dua bagian:
totalCost = gasUsed * (baseFee + priorityFee)
- baseFee: Diatur oleh protokol berdasarkan kepadatan jaringan. Dibakar (dihancurkan).
- priorityFee (tip): Pergi ke validator. Tip lebih tinggi = inklusi lebih cepat.
- maxFeePerGas: Maksimum yang bersedia Anda bayar per unit gas.
- maxPriorityFeePerGas: Tip maksimum per unit gas.
Jika baseFee + priorityFee > maxFeePerGas, transaksi Anda menunggu sampai baseFee turun. Inilah mengapa transaksi "terjebak" saat kepadatan tinggi.
Implikasi praktis untuk web developer: membaca data gratis. Menulis data memakan biaya. Ini adalah perbedaan arsitektur terpenting antara Web2 dan Web3. Setiap SELECT gratis. Setiap INSERT, UPDATE, DELETE memakan uang nyata. Rancang dApp Anda sesuai dengan ini.
Transaksi#
Transaksi adalah struktur data yang ditandatangani. Berikut field yang penting:
interface Transaction {
// Siapa yang menerima transaksi ini — alamat EOA atau alamat kontrak
to: string; // alamat hex 20-byte, atau null untuk deployment kontrak
// Berapa banyak ETH yang dikirim (dalam wei)
value: bigint; // Bisa 0n untuk pemanggilan kontrak murni
// Data pemanggilan fungsi yang di-encode, atau kosong untuk transfer ETH biasa
data: string; // Byte yang di-encode hex, "0x" untuk transfer sederhana
// Counter berurutan, mencegah serangan replay
nonce: number; // Harus sama persis dengan nonce pengirim saat ini
// Batas gas — gas maksimum yang bisa dikonsumsi tx ini
gasLimit: bigint;
// Parameter biaya EIP-1559
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
// Identifier chain (1 = mainnet, 11155111 = Sepolia, 137 = Polygon)
chainId: number;
}Siklus Hidup Transaksi#
-
Konstruksi: Aplikasi Anda membangun objek transaksi. Jika Anda memanggil fungsi kontrak, field
databerisi pemanggilan fungsi yang di-encode ABI (lebih lanjut tentang ini di bawah). -
Penandatanganan: Private key menandatangani transaksi yang di-encode RLP, menghasilkan komponen tanda tangan
v,r,s. Ini membuktikan pengirim mengotorisasi transaksi spesifik ini. Alamat pengirim diturunkan dari tanda tangan — tidak secara eksplisit ada di transaksi. -
Broadcasting: Transaksi yang ditandatangani dikirim ke node RPC melalui
eth_sendRawTransaction. Node memvalidasinya (nonce benar, saldo cukup, tanda tangan valid) dan menambahkannya ke mempool-nya. -
Mempool: Transaksi berada di kumpulan transaksi tertunda. Validator memilih transaksi untuk dimasukkan ke blok berikutnya, umumnya memilih tip yang lebih tinggi. Di sinilah front-running terjadi — aktor lain bisa melihat transaksi tertunda Anda dan mengirim milik mereka dengan tip lebih tinggi untuk dieksekusi sebelum milik Anda.
-
Inklusi: Validator memasukkan transaksi Anda ke dalam blok. EVM mengeksekusinya. Jika berhasil, perubahan state diterapkan. Jika revert, perubahan state dibatalkan — tapi Anda tetap membayar gas yang dikonsumsi sampai titik revert.
-
Finalitas: Di Ethereum proof-of-stake, blok menjadi "finalized" setelah dua epoch (~12,8 menit). Sebelum finalitas, reorganisasi chain secara teoritis mungkin (meskipun jarang). Kebanyakan aplikasi memperlakukan 1-2 konfirmasi blok sebagai "cukup baik" untuk operasi non-kritis.
Berikut cara mengirim transfer ETH sederhana dengan 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"), // Mengonversi "0.1" ke wei (100000000000000000n)
});
console.log("Hash tx:", tx.hash);
// Tunggu inklusi di blok
const receipt = await tx.wait();
console.log("Nomor blok:", receipt.blockNumber);
console.log("Gas terpakai:", receipt.gasUsed.toString());
console.log("Status:", receipt.status); // 1 = sukses, 0 = revertDan yang sama dengan 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("Hash tx:", hash);Perhatikan perbedaannya: ethers mengembalikan objek TransactionResponse dengan metode .wait(). Viem hanya mengembalikan hash — Anda menggunakan panggilan terpisah publicClient.waitForTransactionReceipt({ hash }) untuk menunggu konfirmasi. Pemisahan concern ini disengaja dalam desain viem.
Smart Contracts#
Smart contract adalah bytecode yang di-deploy plus storage persisten di alamat tertentu. Ketika Anda "memanggil" kontrak, Anda mengirim transaksi (atau membuat panggilan read-only) dengan field data diatur ke pemanggilan fungsi yang di-encode.
Bytecode dan ABI#
Bytecode adalah kode EVM yang dikompilasi. Anda tidak berinteraksi dengannya secara langsung. Ini yang dieksekusi EVM.
ABI (Application Binary Interface) adalah deskripsi JSON dari antarmuka kontrak. Ini memberi tahu pustaka klien Anda cara meng-encode pemanggilan fungsi dan men-decode nilai kembalian. Bayangkan sebagai spesifikasi OpenAPI untuk kontrak.
Berikut fragmen ABI token ERC-20:
const ERC20_ABI = [
// Fungsi read-only (view/pure — tanpa biaya gas saat dipanggil secara eksternal)
"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)",
// Fungsi yang mengubah state (memerlukan transaksi, memakan 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 (diemit selama eksekusi, disimpan di log transaksi)
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;Ethers.js menerima format "human-readable ABI" ini. Viem juga bisa menggunakannya, tapi sering kali Anda akan bekerja dengan ABI JSON lengkap yang dihasilkan oleh compiler Solidity. Keduanya setara — format human-readable hanya lebih nyaman untuk antarmuka umum.
Bagaimana Pemanggilan Fungsi Di-encode#
Ini adalah bagian yang kebanyakan tutorial lewatkan, dan bagian yang akan menghemat berjam-jam debugging Anda.
Ketika Anda memanggil transfer("0xBob...", 1000000), field data dari transaksi diatur ke:
0xa9059cbb // Function selector
0000000000000000000000000xBob...000000000000000000000000 // address, dipad ke 32 byte
00000000000000000000000000000000000000000000000000000000000f4240 // uint256 amount (1000000 dalam hex)
Function selector adalah 4 byte pertama dari hash Keccak-256 dari tanda tangan fungsi:
keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = 4 byte pertama = 0xa9059cbb
Byte yang tersisa adalah argumen yang di-encode ABI, masing-masing dipad ke 32 byte. Skema encoding ini deterministik — pemanggilan fungsi yang sama selalu menghasilkan calldata yang sama.
Mengapa ini penting? Karena ketika Anda melihat data transaksi mentah di Etherscan dan dimulai dengan 0xa9059cbb, Anda tahu itu pemanggilan transfer. Ketika transaksi Anda revert dan pesan error hanya blob hex, Anda bisa men-decode-nya menggunakan ABI. Dan ketika Anda membangun batch transaksi atau berinteraksi dengan kontrak multicall, Anda akan meng-encode calldata secara manual.
Berikut cara meng-encode dan men-decode secara manual dengan ethers.js:
import { ethers } from "ethers";
const iface = new ethers.Interface(ERC20_ABI);
// Encode pemanggilan fungsi
const calldata = iface.encodeFunctionData("transfer", [
"0xBobAddress...",
1000000n,
]);
console.log(calldata);
// 0xa9059cbb000000000000000000000000bob...000000000000000000000000000f4240
// Decode calldata kembali ke nama fungsi dan argumen
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 data kembalian fungsi
const returnData = "0x0000000000000000000000000000000000000000000000000000000000000001";
const result = iface.decodeFunctionResult("transfer", returnData);
console.log(result[0]); // trueSlot Storage#
Storage kontrak adalah penyimpanan key-value di mana key dan value keduanya 32 byte. Solidity menetapkan slot storage secara berurutan mulai dari 0. Variable state pertama yang dideklarasikan masuk ke slot 0, berikutnya ke slot 1, dan seterusnya. Mapping dan array dinamis menggunakan skema berbasis hash.
Anda bisa membaca storage kontrak mana pun secara langsung, bahkan jika variable ditandai private di Solidity. "Private" hanya berarti kontrak lain tidak bisa membacanya — siapa saja bisa membacanya melalui eth_getStorageAt:
// Membaca slot storage 0 dari kontrak
const slot0 = await provider.getStorage(
"0xContractAddress...",
0
);
console.log(slot0); // Nilai hex 32-byte mentahBeginilah cara block explorer menampilkan state kontrak "internal". Tidak ada kontrol akses pada pembacaan storage. Privasi di blockchain publik pada dasarnya terbatas.
Events dan Logs#
Events adalah cara kontrak memancarkan data terstruktur yang disimpan di log transaksi tapi tidak di storage kontrak. Mereka lebih murah dari penulisan storage (375 gas untuk topik pertama + 8 gas per byte data, vs 20.000 gas untuk penulisan storage) dan dirancang untuk di-query secara efisien.
Event bisa memiliki hingga 3 parameter indexed (disimpan sebagai "topics") dan jumlah parameter non-indexed yang tidak terbatas (disimpan sebagai "data"). Parameter indexed bisa difilter — Anda bisa bertanya "berikan saya semua event Transfer di mana to adalah alamat ini." Parameter non-indexed tidak bisa difilter; Anda harus mengambil semua event yang cocok dan memfilter di sisi klien.
// Mendengarkan event Transfer secara real-time dengan 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)} token`);
console.log("Blok:", event.log.blockNumber);
console.log("Hash tx:", event.log.transactionHash);
});
// Mengquery event historis
const filter = contract.filters.Transfer(null, "0xMyAddress..."); // from=any, to=spesifik
const events = await contract.queryFilter(filter, 19000000, 19100000); // rentang blok
for (const event of events) {
console.log("Dari:", event.args.from);
console.log("Nilai:", event.args.value.toString());
}Yang sama dengan 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"),
});
// Log historis
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("Dari:", log.args.from);
console.log("Ke:", log.args.to);
console.log("Nilai:", log.args.value);
}
// Pemantauan real-time
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}`);
}
},
});
// Panggil unwatch() untuk berhenti mendengarkanMembaca Data On-Chain#
Di sinilah Ethereum menjadi praktis untuk web developer. Anda tidak perlu menjalankan node. Anda tidak perlu menambang. Anda bahkan tidak perlu dompet. Membaca data dari Ethereum itu gratis, tanpa izin, dan berfungsi melalui API JSON-RPC sederhana.
JSON-RPC: API HTTP dari Ethereum#
Setiap node Ethereum mengekspos API JSON-RPC. Ini benar-benar HTTP POST dengan body JSON. Tidak ada yang spesifik blockchain tentang lapisan transport.
// Inilah yang dilakukan pustaka Anda di balik layar
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" }Itu adalah eth_call mentah. Ini mensimulasikan eksekusi transaksi tanpa benar-benar mengirimkannya. Tanpa biaya gas. Tanpa perubahan state. Hanya membaca nilai kembalian. Beginilah fungsi view dan pure bekerja dari luar — mereka menggunakan eth_call alih-alih eth_sendRawTransaction.
Dua Metode RPC Kritis#
eth_call: Mensimulasikan eksekusi. Gratis. Tanpa perubahan state. Digunakan untuk semua operasi baca — memeriksa saldo, membaca harga, memanggil fungsi view. Bisa dipanggil pada blok historis mana pun dengan menentukan nomor blok alih-alih "latest".
eth_sendRawTransaction: Mengirim transaksi yang ditandatangani untuk inklusi di blok. Memakan gas. Mengubah state (jika berhasil). Digunakan untuk semua operasi tulis — transfer, approval, swap, mint.
Semua hal lain di API JSON-RPC adalah varian dari kedua ini atau metode utilitas (eth_blockNumber, eth_getTransactionReceipt, eth_getLogs, dll.).
Provider: Gerbang Anda ke Chain#
Anda tidak menjalankan node sendiri. Hampir tidak ada yang melakukannya untuk pengembangan aplikasi. Sebagai gantinya, Anda menggunakan layanan provider:
- Alchemy: Yang paling populer. Dashboard yang bagus, dukungan webhook, API yang ditingkatkan untuk NFT dan metadata token. Tier gratis: ~300M unit komputasi/bulan.
- Infura: Yang asli. Dimiliki ConsenSys. Reliable. Tier gratis: 100K request/hari.
- QuickNode: Bagus untuk multi-chain. Model harga yang sedikit berbeda.
- Endpoint RPC publik:
https://rpc.ankr.com/eth,https://cloudflare-eth.com. Gratis tapi rate-limited dan terkadang tidak reliable. Baik untuk pengembangan, berbahaya untuk produksi. - Tenderly: Sangat baik untuk simulasi dan debugging. RPC mereka menyertakan simulator transaksi bawaan.
Untuk produksi, selalu konfigurasikan setidaknya dua provider sebagai fallback. Downtime RPC nyata dan akan terjadi di saat terburuk.
import { ethers } from "ethers";
// Provider fallback ethers.js v6
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,
},
]);Membaca State Kontrak Secara Gratis#
Inilah langkah kuat yang kebanyakan developer Web2 tidak sadari: Anda bisa membaca data publik apa pun dari kontrak mana pun di Ethereum tanpa membayar apa pun, tanpa dompet, dan tanpa autentikasi selain API key untuk provider RPC Anda.
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
// Antarmuka ERC-20 — hanya fungsi baca
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 // Catatan: provider, bukan 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(`Desimal: ${decimals}`); // 6 (BUKAN 18!)
console.log(`Total supply: ${ethers.formatUnits(totalSupply, decimals)}`);
// Periksa saldo alamat tertentu
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Saldo: ${ethers.formatUnits(balance, decimals)} USDC`);Tanpa dompet. Tanpa gas. Tanpa transaksi. Hanya eth_call JSON-RPC di balik layar. Ini identik secara konsep dengan membuat request GET ke REST API. Blockchain adalah database-nya, kontrak adalah API-nya, dan eth_call adalah query SELECT Anda.
ethers.js v6#
ethers.js adalah jQuery-nya Web3 — ini adalah pustaka pertama yang dipelajari kebanyakan developer, dan masih yang paling banyak digunakan. Versi 6 adalah peningkatan signifikan dari v5, dengan dukungan BigInt native (akhirnya), modul ESM, dan API yang lebih bersih.
Tiga Abstraksi Inti#
Provider: Koneksi read-only ke blockchain. Bisa memanggil fungsi view, mengambil blok, membaca log. Tidak bisa menandatangani atau mengirim transaksi.
import { ethers } from "ethers";
// Terhubung ke node
const provider = new ethers.JsonRpcProvider("https://...");
// Query dasar
const blockNumber = await provider.getBlockNumber();
const balance = await provider.getBalance("0xAddress...");
const block = await provider.getBlock(blockNumber);
const txCount = await provider.getTransactionCount("0xAddress...");Signer: Abstraksi atas private key. Bisa menandatangani transaksi dan pesan. Signer selalu terhubung ke Provider.
// Dari private key (sisi server, skrip)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
// Dari dompet browser (sisi klien)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
// Dapatkan alamat
const address = await signer.getAddress();Contract: Proxy JavaScript untuk kontrak yang di-deploy. Metode pada objek Contract sesuai dengan fungsi di ABI. Fungsi view mengembalikan nilai. Fungsi yang mengubah state mengembalikan TransactionResponse.
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
// Baca (gratis, mengembalikan nilai langsung)
const balance = await usdc.balanceOf("0xSomeAddress...");
// balance adalah bigint: 1000000000n (1000 USDC dengan 6 desimal)
// Untuk menulis, hubungkan dengan signer
const usdcWithSigner = usdc.connect(signer);
// Tulis (memakan gas, mengembalikan TransactionResponse)
const tx = await usdcWithSigner.transfer("0xRecipient...", 1000000n);
const receipt = await tx.wait(); // Tunggu inklusi blok
if (receipt.status === 0) {
throw new Error("Transaksi di-revert");
}TypeChain untuk Keamanan Tipe#
Interaksi ABI mentah bertipe string. Anda bisa salah mengeja nama fungsi, mengoper tipe argumen yang salah, atau salah menafsirkan nilai kembalian. TypeChain menghasilkan tipe TypeScript dari file ABI Anda:
// Tanpa TypeChain — tanpa pengecekan tipe
const balance = await contract.balanceOf("0x...");
// balance adalah 'any'. Tanpa autocomplete. Mudah disalahgunakan.
// Dengan TypeChain — keamanan tipe penuh
import { USDC__factory } from "./typechain";
const usdc = USDC__factory.connect(USDC_ADDRESS, provider);
const balance = await usdc.balanceOf("0x...");
// balance adalah BigNumber. Autocomplete berfungsi. Error tipe tertangkap saat kompilasi.Untuk proyek baru, pertimbangkan menggunakan inferensi tipe bawaan viem dari ABI sebagai gantinya. Ini mencapai hasil yang sama tanpa langkah code generation terpisah.
Mendengarkan Events#
Streaming event real-time sangat penting untuk dApp yang responsif. ethers.js menggunakan provider WebSocket untuk ini:
// WebSocket untuk event real-time
const wsProvider = new ethers.WebSocketProvider("wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wsProvider);
// Dengarkan semua event Transfer
contract.on("Transfer", (from, to, value, event) => {
console.log(`Transfer: ${from} -> ${to}`);
console.log(`Jumlah: ${ethers.formatUnits(value, 6)} USDC`);
});
// Dengarkan transfer KE alamat tertentu
const filter = contract.filters.Transfer(null, "0xMyAddress...");
contract.on(filter, (from, to, value) => {
console.log(`Transfer masuk: ${ethers.formatUnits(value, 6)} USDC dari ${from}`);
});
// Bersihkan saat selesai
contract.removeAllListeners();WAGMI + Viem: Stack Modern#
WAGMI (We're All Gonna Make It) adalah pustaka React hooks untuk Ethereum. Viem adalah klien TypeScript yang mendasarinya. Bersama-sama, mereka sebagian besar menggantikan ethers.js + web3-react sebagai stack standar untuk pengembangan dApp frontend.
Mengapa perpindahan? Tiga alasan: inferensi TypeScript penuh dari ABI (tanpa codegen), ukuran bundle lebih kecil, dan React hooks yang menangani manajemen state async yang rumit dari interaksi dompet.
Pengaturan#
// 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>
);
}Membaca Data Kontrak#
useReadContract adalah hook yang paling sering Anda gunakan. Ini membungkus eth_call dengan caching React Query, refetching, dan state 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>Memuat...</span>;
if (error) return <span>Error: {error.message}</span>;
// balance bertipe bigint karena ABI bilang uint256
return <span>{formatUnits(balance ?? 0n, 6)} USDC</span>;
}Perhatikan as const pada ABI. Ini kritis. Tanpanya, TypeScript kehilangan tipe literal dan balance menjadi unknown alih-alih bigint. Seluruh sistem inferensi tipe bergantung pada const assertion.
Menulis ke Kontrak#
useWriteContract menangani seluruh siklus hidup: prompt dompet, penandatanganan, broadcasting, dan pelacakan konfirmasi.
"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 ? "Konfirmasi di dompet..." : "Kirim 100 USDC"}
</button>
{hash && <p>Transaksi: {hash}</p>}
{isConfirming && <p>Menunggu konfirmasi...</p>}
{isSuccess && <p>Transfer dikonfirmasi!</p>}
{error && <p>Error: {error.message}</p>}
</div>
);
}Memantau Events#
useWatchContractEvent mengatur langganan WebSocket untuk pemantauan event real-time:
"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>
);
}Pola Koneksi Dompet#
Menghubungkan dompet pengguna adalah "login" dari Web3. Kecuali itu bukan login. Tidak ada session, tidak ada cookie, tidak ada state sisi server. Koneksi dompet memberi aplikasi Anda izin untuk membaca alamat pengguna dan meminta tanda tangan transaksi. Itu saja.
Antarmuka Provider EIP-1193#
Setiap dompet mengekspos antarmuka standar yang didefinisikan oleh EIP-1193. Ini adalah objek dengan metode 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 menyuntikkan ini sebagai window.ethereum. Dompet lain menyuntikkan properti mereka sendiri atau juga menggunakan window.ethereum (yang menyebabkan konflik — masalah "perang dompet", sebagian diselesaikan oleh EIP-6963).
// Interaksi dompet level rendah (Anda tidak seharusnya melakukan ini langsung, tapi berguna untuk dipahami)
// Minta akses akun
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
console.log("Alamat terhubung:", accounts[0]);
// Dapatkan chain saat ini
const chainId = await window.ethereum.request({
method: "eth_chainId",
});
console.log("Chain ID:", parseInt(chainId, 16)); // "0x1" -> 1 (mainnet)
// Dengarkan perubahan akun (pengguna berganti akun di MetaMask)
window.ethereum.on("accountsChanged", (accounts: string[]) => {
if (accounts.length === 0) {
console.log("Dompet terputus");
} else {
console.log("Beralih ke:", accounts[0]);
}
});
// Dengarkan perubahan chain (pengguna berganti jaringan)
window.ethereum.on("chainChanged", (chainId: string) => {
// Pendekatan yang disarankan adalah memuat ulang halaman
window.location.reload();
});EIP-6963: Penemuan Multi-Dompet#
Pendekatan window.ethereum lama rusak ketika pengguna memiliki beberapa dompet terinstal. Yang mana mendapat window.ethereum? Yang terakhir menyuntikkan? Yang pertama? Ini adalah race condition.
EIP-6963 memperbaiki ini dengan protokol penemuan berbasis browser events:
// Menemukan semua dompet yang tersedia
interface EIP6963ProviderDetail {
info: {
uuid: string;
name: string;
icon: string;
rdns: string; // Nama domain terbalik, mis., "io.metamask"
};
provider: EIP1193Provider;
}
const wallets: EIP6963ProviderDetail[] = [];
window.addEventListener("eip6963:announceProvider", (event: CustomEvent) => {
wallets.push(event.detail);
});
// Minta semua dompet mengumumkan diri
window.dispatchEvent(new Event("eip6963:requestProvider"));
// Sekarang 'wallets' berisi semua dompet terinstal dengan nama dan ikon
// Anda bisa menampilkan UI pemilihan dompetWAGMI menangani semua ini untuk Anda. Ketika Anda menggunakan connector injected(), secara otomatis menggunakan EIP-6963 jika tersedia dan fallback ke window.ethereum.
WalletConnect#
WalletConnect adalah protokol yang menghubungkan dompet mobile ke dApp desktop melalui relay server. Pengguna memindai kode QR dengan dompet mobile mereka, membentuk koneksi terenkripsi. Request transaksi diteruskan dari dApp Anda ke ponsel mereka.
Dengan WAGMI, ini hanya connector lain:
import { walletConnect } from "wagmi/connectors";
const connector = walletConnect({
projectId: "YOUR_PROJECT_ID", // Dapatkan dari cloud.walletconnect.com
showQrModal: true,
});Menangani Perpindahan Chain#
Pengguna sering berada di jaringan yang salah. dApp Anda di Mainnet, mereka terhubung ke Sepolia. Atau mereka di Polygon dan Anda butuh Mainnet. WAGMI menyediakan 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>Silakan hubungkan dompet Anda</p>;
if (chain.id !== mainnet.id) {
return (
<div>
<p>Silakan beralih ke Ethereum Mainnet</p>
<button
onClick={() => switchChain({ chainId: mainnet.id })}
disabled={isPending}
>
{isPending ? "Beralih..." : "Ganti Jaringan"}
</button>
</div>
);
}
return <>{children}</>;
}IPFS dan Metadata#
NFT tidak menyimpan gambar on-chain. Blockchain menyimpan URI yang mengarah ke file JSON metadata, yang kemudian berisi URL ke gambar. Pola standar, didefinisikan oleh fungsi tokenURI ERC-721:
Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"
File JSON itu mengikuti skema standar:
{
"name": "Cool NFT #42",
"description": "NFT yang sangat keren",
"image": "ipfs://QmImageHash...",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Rarity", "value": "Legendary" }
]
}CID IPFS vs URL#
Alamat IPFS menggunakan Content Identifiers (CID) — hash dari konten itu sendiri. ipfs://QmXyz... berarti "konten yang hash-nya adalah QmXyz...". Ini adalah penyimpanan content-addressed: URI diturunkan dari konten, jadi konten tidak pernah bisa berubah tanpa mengubah URI. Ini adalah jaminan immutabilitas yang diandalkan NFT (ketika mereka benar-benar menggunakan IPFS — banyak menggunakan URL terpusat, yang merupakan tanda bahaya).
Untuk menampilkan konten IPFS di browser, Anda memerlukan gateway yang menerjemahkan URI IPFS ke HTTP:
function ipfsToHttp(uri: string): string {
if (uri.startsWith("ipfs://")) {
const cid = uri.replace("ipfs://", "");
return `https://ipfs.io/ipfs/${cid}`;
// Atau gunakan gateway khusus:
// return `https://YOUR_PROJECT.mypinata.cloud/ipfs/${cid}`;
}
return uri;
}
// Mengambil metadata NFT
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,
};
}Layanan Pinning#
IPFS adalah jaringan peer-to-peer. Konten hanya tetap tersedia selama seseorang meng-host ("pinning") itu. Jika Anda mengunggah gambar NFT ke IPFS lalu mematikan node Anda, konten menghilang.
Layanan pinning menjaga konten Anda tetap tersedia:
- Pinata: Yang paling populer. API sederhana. Tier gratis yang murah hati (1GB). Gateway khusus untuk loading lebih cepat.
- NFT.Storage: Gratis, didukung Protocol Labs (pembuat IPFS). Dirancang khusus untuk metadata NFT. Menggunakan Filecoin untuk persistensi jangka panjang.
- Web3.Storage: Mirip NFT.Storage, lebih umum.
// Mengunggah ke 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}`; // Mengembalikan CID
}Masalah Pengindeksan#
Inilah rahasia kotor pengembangan blockchain: Anda tidak bisa secara efisien melakukan query data historis dari node RPC.
Ingin semua event Transfer untuk token di tahun lalu? Anda perlu memindai jutaan blok dengan eth_getLogs, membagi dalam chunk 2.000-10.000 blok (maksimum bervariasi per provider). Itu ribuan panggilan RPC. Akan memakan waktu menit hingga jam dan menghabiskan kuota API Anda.
Ingin semua token yang dimiliki alamat tertentu? Tidak ada satu panggilan RPC untuk ini. Anda perlu memindai setiap event Transfer untuk setiap kontrak ERC-20, melacak saldo. Itu tidak feasible.
Ingin semua NFT di dompet? Masalah yang sama. Anda perlu memindai setiap event Transfer ERC-721 di setiap kontrak NFT.
Blockchain adalah struktur data yang dioptimasi untuk tulis. Sangat baik dalam memproses transaksi baru. Sangat buruk dalam menjawab query historis. Ini adalah ketidaksesuaian fundamental antara apa yang dibutuhkan UI dApp dan apa yang disediakan chain secara native.
Protokol The Graph#
The Graph adalah protokol pengindeksan terdesentralisasi. Anda menulis "subgraph" — skema dan set event handler — dan The Graph mengindeks chain dan menyajikan data melalui API GraphQL.
# Skema subgraph (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")
}// Melakukan query subgraph dari 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;
}Trade-off-nya: The Graph menambahkan latensi (biasanya 1-2 blok di belakang head chain) dan dependensi tambahan. Jaringan terdesentralisasi juga memiliki biaya pengindeksan (Anda membayar dalam token GRT). Untuk proyek yang lebih kecil, layanan yang di-host (Subgraph Studio) gratis.
API yang Ditingkatkan Alchemy dan Moralis#
Jika Anda tidak ingin memelihara subgraph, Alchemy dan Moralis menawarkan API yang sudah diindeks yang menjawab query umum secara langsung:
// Alchemy: Dapatkan semua saldo token ERC-20 untuk alamat
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"],
}),
}
);
// Mengembalikan SEMUA saldo token ERC-20 dalam satu panggilan
// vs. memindai setiap kemungkinan balanceOf() kontrak ERC-20// Alchemy: Dapatkan semua NFT yang dimiliki alamat
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}`);
}API ini proprietary dan terpusat. Anda menukar desentralisasi dengan pengalaman developer. Untuk kebanyakan dApp, itu trade-off yang layak. Pengguna Anda tidak peduli apakah tampilan portofolio mereka berasal dari subgraph atau database Alchemy. Mereka peduli bahwa itu dimuat dalam 200ms alih-alih 30 detik.
Jebakan Umum#
Setelah mengirimkan beberapa dApp produksi dan men-debug kode tim lain, ini adalah kesalahan yang saya lihat berulang kali. Setiap satu telah menggigit saya secara pribadi.
BigInt di Mana-mana#
Ethereum berurusan dengan angka yang sangat besar. Saldo ETH dalam wei (10^18). Supply token bisa 10^27 atau lebih. JavaScript Number hanya bisa merepresentasikan integer secara aman hingga 2^53 - 1 (sekitar 9 * 10^15). Itu tidak cukup untuk jumlah wei.
// SALAH — kehilangan presisi secara diam-diam
const balance = 1000000000000000000; // 1 ETH dalam wei
const double = balance * 2;
console.log(double); // 2000000000000000000 — tampak benar, tapi...
const largeBalance = 99999999999999999999; // ~100 ETH
console.log(largeBalance); // 100000000000000000000 — SALAH! Dibulatkan ke atas.
console.log(largeBalance === 100000000000000000000); // true — korupsi data
// BENAR — gunakan BigInt
const balance = 1000000000000000000n;
const double = balance * 2n;
console.log(double.toString()); // "2000000000000000000" — benar
const largeBalance = 99999999999999999999n;
console.log(largeBalance.toString()); // "99999999999999999999" — benarAturan untuk BigInt di kode dApp:
- Jangan pernah konversi jumlah wei ke
Number. GunakanBigIntdi mana-mana, konversi ke string yang mudah dibaca hanya untuk tampilan. - Jangan gunakan
Math.floor,Math.round, dll. pada BigInt. Mereka tidak berfungsi. Gunakan pembagian integer:amount / 10n ** 6n. - JSON tidak mendukung BigInt. Jika Anda men-serialize state yang menyertakan BigInt, Anda butuh serializer kustom:
JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v). - Gunakan fungsi formatting pustaka.
ethers.formatEther(),ethers.formatUnits(),formatEther()danformatUnits()dariviem. Mereka menangani konversi dengan benar.
import { formatUnits, parseUnits } from "viem";
// Tampilan: BigInt → string yang mudah dibaca
const weiAmount = 1500000000000000000n; // 1.5 ETH
const display = formatUnits(weiAmount, 18); // "1.5"
// Input: string yang mudah dibaca → BigInt
const userInput = "1.5";
const wei = parseUnits(userInput, 18); // 1500000000000000000n
// USDC punya 6 desimal, bukan 18
const usdcAmount = 100000000n; // 100 USDC
const usdcDisplay = formatUnits(usdcAmount, 6); // "100.0"Operasi Dompet Async#
Setiap interaksi dompet bersifat async dan bisa gagal dengan cara yang perlu ditangani aplikasi Anda secara anggun:
// Pengguna bisa menolak prompt dompet apa pun
try {
const tx = await writeContract({
address: contractAddress,
abi: ERC20_ABI,
functionName: "approve",
args: [spenderAddress, amount],
});
} catch (error) {
if (error.code === 4001) {
// Pengguna menolak transaksi di dompet mereka
// Ini normal — bukan error yang perlu dilaporkan
showToast("Transaksi dibatalkan");
} else if (error.code === -32603) {
// Error JSON-RPC internal — sering berarti transaksi akan revert
showToast("Transaksi akan gagal. Periksa saldo Anda.");
} else {
// Error tidak terduga
console.error("Error transaksi:", error);
showToast("Terjadi kesalahan. Silakan coba lagi.");
}
}Jebakan async utama:
- Prompt dompet memblokir di sisi pengguna.
awaitdi kode Anda bisa memakan 30 detik sementara pengguna membaca detail transaksi di MetaMask. Jangan tampilkan loading spinner yang membuat mereka berpikir ada yang rusak. - Pengguna bisa berganti akun di tengah interaksi. Anda meminta approval dari Akun A, pengguna beralih ke Akun B, lalu menyetujui. Sekarang Akun B yang menyetujui tapi Anda akan mengirim transaksi dari Akun A. Selalu periksa ulang akun yang terhubung sebelum operasi kritis.
- Pola tulis dua langkah itu umum. Banyak operasi DeFi memerlukan
approve+execute. Pengguna perlu menandatangani dua transaksi. Jika mereka menyetujui tapi tidak mengeksekusi, Anda perlu memeriksa state allowance dan melewati langkah approval di waktu berikutnya.
Error Ketidaksesuaian Jaringan#
Yang satu ini membuang lebih banyak waktu debugging dari masalah lain mana pun. Kontrak Anda di Mainnet. Dompet Anda di Sepolia. Provider RPC Anda mengarah ke Polygon. Tiga jaringan berbeda, tiga state berbeda, tiga blockchain yang sama sekali tidak terkait. Dan pesan error-nya biasanya tidak membantu — "execution reverted" atau "contract not found."
// Pengecekan chain defensif
import { useAccount, useChainId } from "wagmi";
function useRequireChain(requiredChainId: number) {
const chainId = useChainId();
const { isConnected } = useAccount();
if (!isConnected) {
return { ready: false, error: "Silakan hubungkan dompet Anda" };
}
if (chainId !== requiredChainId) {
return {
ready: false,
error: `Silakan beralih ke ${getChainName(requiredChainId)}. Anda di ${getChainName(chainId)}.`,
};
}
return { ready: true, error: null };
}Front-Running di DeFi#
Ketika Anda mengirim swap di DEX, transaksi tertunda Anda terlihat di mempool. Bot bisa melihat perdagangan Anda, melakukan front-run dengan mendorong harga naik, membiarkan perdagangan Anda dieksekusi pada harga yang lebih buruk, lalu menjual segera setelahnya untuk keuntungan. Ini disebut "serangan sandwich."
Sebagai developer frontend, Anda tidak bisa mencegah ini sepenuhnya, tapi bisa memitigasinya:
// Mengatur toleransi slippage pada swap bergaya Uniswap
const amountOutMin = expectedOutput * 995n / 1000n; // Toleransi slippage 0.5%
// Menggunakan deadline untuk mencegah transaksi tertunda yang bertahan lama
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20 menit
await router.swapExactTokensForTokens(
amountIn,
amountOutMin, // Output minimum yang diterima — revert jika mendapat kurang
[tokenA, tokenB],
userAddress,
deadline, // Revert jika tidak dieksekusi dalam 20 menit
);Untuk transaksi bernilai tinggi, pertimbangkan menggunakan Flashbots Protect RPC, yang mengirim transaksi langsung ke block builder alih-alih mempool publik. Ini mencegah serangan sandwich sepenuhnya karena bot tidak pernah melihat transaksi tertunda Anda:
// Menggunakan Flashbots Protect sebagai endpoint RPC Anda
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");Kebingungan Desimal#
Tidak semua token punya 18 desimal. USDC dan USDT punya 6. WBTC punya 8. Beberapa token punya 0, 2, atau desimal arbitrer. Selalu baca decimals() dari kontrak sebelum memformat jumlah:
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"Kegagalan Estimasi Gas#
Ketika estimateGas gagal, biasanya berarti transaksi akan revert. Tapi pesan error-nya sering hanya "cannot estimate gas" tanpa indikasi mengapa. Gunakan eth_call untuk mensimulasikan transaksi dan mendapat alasan revert yang sebenarnya:
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; // Tidak ada error — transaksi akan berhasil
} catch (error) {
// Decode alasan revert
if (error.data) {
// String revert umum
if (error.data.startsWith("0x08c379a0")) {
// Error(string) — revert standar dengan pesan
const reason = decodeAbiParameters(
[{ type: "string" }],
`0x${error.data.slice(10)}`
);
return `Revert: ${reason[0]}`;
}
}
return error.message;
}
}Menyatukan Semuanya#
Berikut komponen React lengkap dan minimal yang menghubungkan dompet, membaca saldo token, dan mengirim transfer. Ini adalah kerangka dari setiap 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("");
// Baca saldo — hanya berjalan ketika address didefinisikan
const { data: balance, refetch: refetchBalance } = useReadContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "balanceOf",
args: address ? [address] : undefined,
query: { enabled: !!address },
});
// Tulis: transfer token
const {
writeContract,
data: txHash,
isPending: isSigning,
error: writeError,
} = useWriteContract();
// Tunggu konfirmasi
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
// Refresh saldo setelah konfirmasi
if (isSuccess) {
refetchBalance();
}
if (!isConnected) {
return (
<button onClick={() => connect({ connector: injected() })}>
Hubungkan Dompet
</button>
);
}
return (
<div>
<p>Terhubung: {address}</p>
<p>
Saldo USDC:{" "}
{balance !== undefined ? formatUnits(balance, 6) : "Memuat..."}
</p>
<div>
<input
placeholder="Alamat penerima (0x...)"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
<input
placeholder="Jumlah (mis., 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
? "Konfirmasi di dompet..."
: isConfirming
? "Mengonfirmasi..."
: "Kirim USDC"}
</button>
</div>
{writeError && <p style={{ color: "red" }}>{writeError.message}</p>}
{isSuccess && <p style={{ color: "green" }}>Transfer dikonfirmasi!</p>}
{txHash && (
<a
href={`https://etherscan.io/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
>
Lihat di Etherscan
</a>
)}
<button onClick={() => disconnect()}>Putuskan Koneksi</button>
</div>
);
}Langkah Selanjutnya#
Posting ini membahas konsep dan tooling esensial untuk web developer yang memasuki Ethereum. Ada lebih banyak kedalaman di setiap area:
- Solidity: Jika Anda ingin menulis kontrak, bukan hanya berinteraksi dengannya. Dokumentasi resmi dan kursus Patrick Collins adalah titik awal terbaik.
- Standar ERC: ERC-20 (token fungible), ERC-721 (NFT), ERC-1155 (multi-token), ERC-4626 (vault yang ditokenisasi). Masing-masing mendefinisikan antarmuka standar yang diimplementasikan semua kontrak dalam kategori itu.
- Layer 2: Arbitrum, Optimism, Base, zkSync. Pengalaman developer yang sama, biaya gas lebih rendah, asumsi kepercayaan sedikit berbeda. Kode ethers.js dan viem Anda berfungsi identik — cukup ganti chain ID dan URL RPC.
- Account Abstraction (ERC-4337): Evolusi berikutnya dari UX dompet. Dompet smart contract yang mendukung gas sponsorship, social recovery, dan transaksi batch. Ke sinilah pola "connect wallet" menuju.
- MEV dan urutan transaksi: Jika Anda membangun DeFi, memahami Maximal Extractable Value bukan opsional. Dokumentasi Flashbots adalah sumber daya kanonik.
Ekosistem blockchain bergerak cepat, tapi fundamental dalam posting ini — akun, transaksi, ABI encoding, panggilan RPC, pengindeksan event — belum berubah sejak 2015 dan tidak akan berubah dalam waktu dekat. Pelajari ini dengan baik dan semuanya hanya permukaan API.