Ethereum Cho Lập Trình Viên Web: Smart Contract Không Cần Hype
Các khái niệm Ethereum mà mọi lập trình viên web cần biết: accounts, transactions, smart contracts, ABI encoding, ethers.js, WAGMI, và đọc dữ liệu on-chain mà không cần chạy node riêng.
Hầu hết nội dung "Ethereum cho lập trình viên" rơi vào hai loại: những phép so sánh đơn giản hóa quá mức không giúp bạn xây dựng được gì, hoặc những đặc tả giao thức chuyên sâu giả định bạn đã biết Merkle Patricia Trie là gì. Không loại nào hữu ích nếu bạn là một lập trình viên web muốn đọc số dư token, cho phép người dùng ký giao dịch, hoặc hiển thị metadata NFT trong ứng dụng React.
Bài viết này là nền tảng thực tế ở giữa. Tôi sẽ giải thích chính xác điều gì xảy ra khi frontend của bạn giao tiếp với Ethereum, các thành phần chuyển động là gì, và cách mà các công cụ hiện đại (ethers.js, viem, WAGMI) ánh xạ tới những khái niệm bạn đã hiểu từ việc xây dựng ứng dụng web.
Không có phép ẩn dụ về máy bán hàng tự động. Không có "hãy tưởng tượng một thế giới nơi..." Chỉ có mô hình kỹ thuật và code.
Mô Hình Tư Duy#
Ethereum là một máy trạng thái được sao chép. Mọi node trong mạng duy trì một bản sao giống hệt nhau của trạng thái — một kho key-value khổng lồ ánh xạ các địa chỉ tới dữ liệu tài khoản. Khi bạn "gửi một giao dịch", bạn đang đề xuất một sự chuyển đổi trạng thái. Nếu đủ validators đồng ý nó hợp lệ, trạng thái được cập nhật. Đó là tất cả.
Bản thân trạng thái khá đơn giản. Nó là một ánh xạ từ các địa chỉ 20 byte tới các đối tượng tài khoản. Mỗi tài khoản có bốn trường:
- nonce: Số lượng giao dịch tài khoản này đã gửi (đối với EOA) hoặc số lượng contract nó đã tạo (đối với tài khoản contract). Điều này ngăn chặn các cuộc tấn công replay.
- balance: Số lượng ETH, tính bằng wei (1 ETH = 10^18 wei). Luôn là một số nguyên lớn.
- codeHash: Hash của bytecode EVM. Đối với ví thông thường (EOA), đây là hash của byte rỗng. Đối với contract, đây là hash của code đã được triển khai.
- storageRoot: Root hash của trie lưu trữ của tài khoản. Chỉ contract mới có lưu trữ có ý nghĩa.
Có hai loại tài khoản, và sự phân biệt này quan trọng cho mọi thứ tiếp theo:
Tài Khoản Sở Hữu Bên Ngoài (EOA) được điều khiển bởi private key. Đây là những gì MetaMask quản lý. Chúng có thể khởi tạo giao dịch. Chúng không có code. Khi ai đó nói "ví", họ đang nói về EOA.
Tài Khoản Contract được điều khiển bởi code của chúng. Chúng không thể khởi tạo giao dịch — chúng chỉ có thể thực thi khi được gọi. Chúng có code và lưu trữ. Khi ai đó nói "smart contract", họ đang nói về loại này. Code là bất biến sau khi triển khai (với một số ngoại lệ thông qua proxy pattern, đó là một cuộc thảo luận hoàn toàn khác).
Hiểu biết then chốt: mọi thay đổi trạng thái trên Ethereum bắt đầu với một EOA ký giao dịch. Contract có thể gọi các contract khác, nhưng chuỗi thực thi luôn bắt đầu với một con người (hoặc bot) có private key.
Gas: Tính Toán Có Giá#
Mọi thao tác trong EVM đều tốn gas. Cộng hai số tốn 3 gas. Lưu trữ một word 32 byte tốn 20.000 gas (lần đầu) hoặc 5.000 gas (cập nhật). Đọc lưu trữ tốn 2.100 gas (cold) hoặc 100 gas (warm, đã được truy cập trong giao dịch này).
Bạn không trả gas bằng "đơn vị gas". Bạn trả bằng ETH. Tổng chi phí là:
totalCost = gasUsed * gasPrice
Sau EIP-1559 (bản nâng cấp London), giá gas trở thành hệ thống hai phần:
totalCost = gasUsed * (baseFee + priorityFee)
- baseFee: Được thiết lập bởi giao thức dựa trên tình trạng tắc nghẽn mạng. Bị đốt (bị hủy).
- priorityFee (tiền tip): Được trả cho validator. Tip cao hơn = được đưa vào nhanh hơn.
- maxFeePerGas: Mức tối đa bạn sẵn sàng trả cho mỗi đơn vị gas.
- maxPriorityFeePerGas: Mức tip tối đa cho mỗi đơn vị gas.
Nếu baseFee + priorityFee > maxFeePerGas, giao dịch của bạn sẽ chờ cho đến khi baseFee giảm. Đây là lý do giao dịch "bị kẹt" trong thời gian tắc nghẽn cao.
Ý nghĩa thực tế cho lập trình viên web: đọc dữ liệu miễn phí. Ghi dữ liệu tốn tiền. Đây là sự khác biệt kiến trúc quan trọng nhất giữa Web2 và Web3. Mọi SELECT đều miễn phí. Mọi INSERT, UPDATE, DELETE đều tốn tiền thực. Hãy thiết kế dApp của bạn cho phù hợp.
Giao Dịch#
Một giao dịch là một cấu trúc dữ liệu đã được ký. Đây là các trường quan trọng:
interface Transaction {
// Ai nhận giao dịch này — địa chỉ EOA hoặc địa chỉ contract
to: string; // Địa chỉ hex 20 byte, hoặc null cho việc triển khai contract
// Bao nhiêu ETH gửi đi (tính bằng wei)
value: bigint; // Có thể là 0n cho các lệnh gọi contract thuần túy
// Dữ liệu lệnh gọi hàm được mã hóa, hoặc rỗng cho chuyển ETH đơn thuần
data: string; // Byte được mã hóa hex, "0x" cho chuyển khoản đơn giản
// Bộ đếm tuần tự, ngăn chặn tấn công replay
nonce: number; // Phải bằng chính xác nonce hiện tại của người gửi
// Giới hạn gas — gas tối đa giao dịch này có thể tiêu thụ
gasLimit: bigint;
// Tham số phí EIP-1559
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
// Định danh chuỗi (1 = mainnet, 11155111 = Sepolia, 137 = Polygon)
chainId: number;
}Vòng Đời Của Một Giao Dịch#
-
Xây dựng: Ứng dụng của bạn tạo đối tượng giao dịch. Nếu bạn đang gọi một hàm contract, trường
datachứa lệnh gọi hàm được mã hóa ABI (sẽ nói thêm bên dưới). -
Ký: Private key ký giao dịch được mã hóa RLP, tạo ra các thành phần chữ ký
v,r,s. Điều này chứng minh người gửi đã ủy quyền cho giao dịch cụ thể này. Địa chỉ người gửi được suy ra từ chữ ký — nó không nằm trực tiếp trong giao dịch. -
Phát sóng: Giao dịch đã ký được gửi tới một RPC node thông qua
eth_sendRawTransaction. Node xác thực nó (nonce đúng, số dư đủ, chữ ký hợp lệ) và thêm vào mempool. -
Mempool: Giao dịch nằm trong pool các giao dịch đang chờ. Validators chọn các giao dịch để đưa vào block tiếp theo, thường ưu tiên tip cao hơn. Đây là nơi front-running xảy ra — các bên khác có thể thấy giao dịch đang chờ của bạn và gửi giao dịch của họ với tip cao hơn để thực thi trước bạn.
-
Đưa vào block: Một validator đưa giao dịch của bạn vào một block. EVM thực thi nó. Nếu thành công, các thay đổi trạng thái được áp dụng. Nếu bị revert, các thay đổi trạng thái được hoàn tác — nhưng bạn vẫn phải trả gas đã tiêu thụ cho đến điểm revert.
-
Finality: Trên Ethereum proof-of-stake, một block trở nên "finalized" sau hai epoch (~12.8 phút). Trước khi finality, việc tổ chức lại chuỗi về mặt lý thuyết là có thể (dù hiếm). Hầu hết ứng dụng coi 1-2 xác nhận block là "đủ tốt" cho các thao tác không quan trọng.
Đây là ví dụ gửi chuyển khoản ETH đơn giản với 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 = revertVà tương tự với 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);Lưu ý sự khác biệt: ethers trả về một đối tượng TransactionResponse với phương thức .wait(). Viem chỉ trả về hash — bạn sử dụng lệnh gọi riêng publicClient.waitForTransactionReceipt({ hash }) để chờ xác nhận. Sự tách biệt trách nhiệm này là có chủ đích trong thiết kế của viem.
Smart Contract#
Một smart contract là bytecode đã triển khai cộng với lưu trữ bền vững tại một địa chỉ cụ thể. Khi bạn "gọi" một contract, bạn đang gửi một giao dịch (hoặc thực hiện lệnh gọi chỉ đọc) với trường data được đặt thành lệnh gọi hàm đã mã hóa.
Bytecode và ABI#
Bytecode là code EVM đã biên dịch. Bạn không tương tác trực tiếp với nó. Đó là những gì EVM thực thi.
ABI (Application Binary Interface) là mô tả JSON về giao diện của contract. Nó cho thư viện client của bạn biết cách mã hóa lệnh gọi hàm và giải mã giá trị trả về. Hãy coi nó như đặc tả OpenAPI cho một contract.
Đây là một phần của ABI token 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 chấp nhận định dạng "human-readable ABI" này. Viem cũng có thể sử dụng nó, nhưng thường bạn sẽ làm việc với ABI JSON đầy đủ được tạo bởi trình biên dịch Solidity. Cả hai đều tương đương — định dạng human-readable chỉ tiện lợi hơn cho các interface phổ biến.
Cách Mã Hóa Lệnh Gọi Hàm#
Đây là phần mà hầu hết tutorial bỏ qua, và đây là phần sẽ giúp bạn tiết kiệm hàng giờ debug.
Khi bạn gọi transfer("0xBob...", 1000000), trường data của giao dịch được đặt thành:
0xa9059cbb // Function selector
0000000000000000000000000xBob...000000000000000000000000 // address, padded to 32 bytes
00000000000000000000000000000000000000000000000000000000000f4240 // uint256 amount (1000000 in hex)
Function selector là 4 byte đầu tiên của hash Keccak-256 của chữ ký hàm:
keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = first 4 bytes = 0xa9059cbb
Các byte còn lại là các tham số được mã hóa ABI, mỗi tham số được đệm đến 32 byte. Sơ đồ mã hóa này mang tính xác định — cùng một lệnh gọi hàm luôn tạo ra cùng một calldata.
Tại sao điều này quan trọng? Bởi vì khi bạn thấy dữ liệu giao dịch thô trên Etherscan và nó bắt đầu bằng 0xa9059cbb, bạn biết đó là lệnh gọi transfer. Khi giao dịch của bạn revert và thông báo lỗi chỉ là một blob hex, bạn có thể giải mã nó bằng ABI. Và khi bạn đang xây dựng batch giao dịch hoặc tương tác với contract multicall, bạn sẽ mã hóa calldata thủ công.
Đây là cách mã hóa và giải mã thủ công với 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 Slot#
Lưu trữ contract là một kho key-value nơi cả key và value đều là 32 byte. Solidity gán các slot lưu trữ tuần tự bắt đầu từ 0. Biến trạng thái đầu tiên được khai báo nằm ở slot 0, biến tiếp theo ở slot 1, và cứ tiếp tục. Mapping và mảng động sử dụng sơ đồ dựa trên hash.
Bạn có thể đọc lưu trữ của bất kỳ contract nào trực tiếp, ngay cả khi biến được đánh dấu private trong Solidity. "Private" chỉ có nghĩa là các contract khác không thể đọc nó — bất kỳ ai cũng có thể đọc thông qua eth_getStorageAt:
// Reading storage slot 0 of a contract
const slot0 = await provider.getStorage(
"0xContractAddress...",
0
);
console.log(slot0); // Raw 32-byte hex valueĐây là cách các block explorer hiển thị trạng thái "nội bộ" của contract. Không có kiểm soát truy cập nào trên việc đọc lưu trữ. Quyền riêng tư trên blockchain công khai về cơ bản là giới hạn.
Event và Log#
Event là cách contract phát ra dữ liệu có cấu trúc được lưu trong log giao dịch nhưng không trong lưu trữ contract. Chúng rẻ hơn so với ghi lưu trữ (375 gas cho topic đầu tiên + 8 gas mỗi byte dữ liệu, so với 20.000 gas cho ghi lưu trữ) và chúng được thiết kế để truy vấn hiệu quả.
Một event có thể có tối đa 3 tham số indexed (lưu dưới dạng "topic") và bất kỳ số lượng tham số non-indexed nào (lưu dưới dạng "data"). Tham số indexed có thể được lọc — bạn có thể hỏi "cho tôi tất cả event Transfer nơi to là địa chỉ này." Tham số non-indexed không thể lọc; bạn phải fetch tất cả event phù hợp và lọc phía client.
// 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());
}Tương tự với 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 listeningĐọc Dữ Liệu On-Chain#
Đây là nơi Ethereum trở nên thực tế cho lập trình viên web. Bạn không cần chạy một node. Bạn không cần đào. Bạn thậm chí không cần ví. Đọc dữ liệu từ Ethereum là miễn phí, không cần quyền, và hoạt động thông qua một JSON-RPC API đơn giản.
JSON-RPC: HTTP API Của Ethereum#
Mọi node Ethereum đều expose một JSON-RPC API. Nó đúng nghĩa là HTTP POST với body JSON. Không có gì đặc biệt về blockchain ở tầng transport.
// 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" }Đó là một eth_call thô. Nó mô phỏng thực thi giao dịch mà không thực sự gửi nó. Không tốn gas. Không thay đổi trạng thái. Chỉ đọc giá trị trả về. Đây là cách các hàm view và pure hoạt động từ bên ngoài — chúng sử dụng eth_call thay vì eth_sendRawTransaction.
Hai Phương Thức RPC Quan Trọng#
eth_call: Mô phỏng thực thi. Miễn phí. Không thay đổi trạng thái. Dùng cho tất cả thao tác đọc — kiểm tra số dư, đọc giá, gọi hàm view. Có thể gọi trên bất kỳ block lịch sử nào bằng cách chỉ định số block thay vì "latest".
eth_sendRawTransaction: Gửi giao dịch đã ký để đưa vào block. Tốn gas. Thay đổi trạng thái (nếu thành công). Dùng cho tất cả thao tác ghi — chuyển khoản, phê duyệt, swap, mint.
Mọi thứ khác trong JSON-RPC API đều là biến thể của hai phương thức này hoặc phương thức tiện ích (eth_blockNumber, eth_getTransactionReceipt, eth_getLogs, v.v.).
Provider: Cổng Vào Chuỗi Của Bạn#
Bạn không chạy node riêng. Hầu như không ai làm vậy cho phát triển ứng dụng. Thay vào đó, bạn sử dụng dịch vụ provider:
- Alchemy: Phổ biến nhất. Dashboard tuyệt vời, hỗ trợ webhook, API nâng cao cho NFT và metadata token. Gói miễn phí: ~300M đơn vị tính toán/tháng.
- Infura: Bản gốc. Thuộc ConsenSys. Đáng tin cậy. Gói miễn phí: 100K request/ngày.
- QuickNode: Tốt cho multi-chain. Mô hình giá hơi khác.
- Endpoint RPC công khai:
https://rpc.ankr.com/eth,https://cloudflare-eth.com. Miễn phí nhưng giới hạn rate và đôi khi không ổn định. Ổn cho phát triển, nguy hiểm cho production. - Tenderly: Tuyệt vời cho mô phỏng và debug. RPC của họ bao gồm trình mô phỏng giao dịch tích hợp.
Đối với production, luôn cấu hình ít nhất hai provider làm dự phòng. Downtime RPC là thực tế và nó sẽ xảy ra vào thời điểm tệ nhất.
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,
},
]);Đọc Trạng Thái Contract Miễn Phí#
Đây là sức mạnh mà hầu hết lập trình viên Web2 không nhận ra: bạn có thể đọc bất kỳ dữ liệu công khai nào từ bất kỳ contract nào trên Ethereum mà không phải trả gì, không cần ví, và không cần xác thực nào ngoài API key cho provider RPC của bạn.
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`);Không ví. Không gas. Không giao dịch. Chỉ là một eth_call JSON-RPC ở bên dưới. Điều này giống hệt về khái niệm với việc gửi request GET tới REST API. Blockchain là cơ sở dữ liệu, contract là API, và eth_call là truy vấn SELECT của bạn.
ethers.js v6#
ethers.js là jQuery của Web3 — nó là thư viện đầu tiên mà hầu hết lập trình viên học, và vẫn là thư viện được sử dụng rộng rãi nhất. Phiên bản 6 là cải tiến đáng kể so với v5, với hỗ trợ BigInt native (cuối cùng cũng có), ESM module, và API sạch hơn.
Ba Trừu Tượng Cốt Lõi#
Provider: Kết nối chỉ đọc tới blockchain. Có thể gọi hàm view, fetch block, đọc log. Không thể ký hoặc gửi giao dịch.
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: Một trừu tượng trên private key. Có thể ký giao dịch và message. Một Signer luôn được kết nối với Provider.
// From a private key (server-side, scripts)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
// From a browser wallet (client-side)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
// Get the address
const address = await signer.getAddress();Contract: Một JavaScript proxy cho contract đã triển khai. Các phương thức trên đối tượng Contract tương ứng với các hàm trong ABI. Hàm view trả về giá trị. Hàm thay đổi trạng thái trả về 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 Cho Type Safety#
Tương tác ABI thô là kiểu chuỗi. Bạn có thể viết sai tên hàm, truyền sai kiểu tham số, hoặc hiểu sai giá trị trả về. TypeChain tạo kiểu TypeScript từ các file ABI của bạn:
// 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.Với dự án mới, hãy cân nhắc sử dụng suy luận kiểu tích hợp của viem từ ABI thay thế. Nó đạt được kết quả tương tự mà không cần bước tạo code riêng.
Lắng Nghe Event#
Streaming event thời gian thực rất quan trọng cho dApp phản hồi nhanh. ethers.js sử dụng WebSocket provider cho việc này:
// 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: Stack Hiện Đại#
WAGMI (We're All Gonna Make It) là một thư viện React hooks cho Ethereum. Viem là TypeScript client nền tảng mà nó sử dụng. Cùng nhau, chúng đã thay thế ethers.js + web3-react như stack tiêu chuẩn cho phát triển frontend dApp.
Tại sao có sự chuyển đổi? Ba lý do: suy luận TypeScript đầy đủ từ ABI (không cần codegen), kích thước bundle nhỏ hơn, và React hooks xử lý quản lý trạng thái async phức tạp của tương tác ví.
Thiết Lập#
// 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>
);
}Đọc Dữ Liệu Contract#
useReadContract là hook bạn sẽ sử dụng nhiều nhất. Nó bọc eth_call với caching React Query, refetching, và trạng thái 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>;
}Lưu ý as const trên ABI. Điều này rất quan trọng. Nếu không có nó, TypeScript mất các kiểu literal và balance trở thành unknown thay vì bigint. Toàn bộ hệ thống suy luận kiểu phụ thuộc vào const assertion.
Ghi Vào Contract#
useWriteContract xử lý toàn bộ vòng đời: prompt ví, ký, phát sóng, và theo dõi xác nhận.
"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>
);
}Theo Dõi Event#
useWatchContractEvent thiết lập subscription WebSocket cho giám sát event thời gian thực:
"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>
);
}Mẫu Kết Nối Ví#
Kết nối ví của người dùng là "đăng nhập" của Web3. Ngoại trừ nó không phải đăng nhập. Không có session, không cookie, không trạng thái phía server. Kết nối ví cho ứng dụng của bạn quyền đọc địa chỉ người dùng và yêu cầu chữ ký giao dịch. Chỉ vậy thôi.
Giao Diện Provider EIP-1193#
Mọi ví đều expose một giao diện tiêu chuẩn được định nghĩa bởi EIP-1193. Nó là một đối tượng với phương thức 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 inject cái này dưới dạng window.ethereum. Các ví khác hoặc inject thuộc tính riêng hoặc cũng sử dụng window.ethereum (gây ra xung đột — vấn đề "chiến tranh ví", được giải quyết một phần bởi 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: Khám Phá Đa Ví#
Cách tiếp cận window.ethereum cũ gặp vấn đề khi người dùng cài nhiều ví. Ví nào nhận được window.ethereum? Ví cuối cùng inject? Ví đầu tiên? Đó là race condition.
EIP-6963 sửa lỗi này bằng giao thức khám phá dựa trên event trình duyệt:
// 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 xử lý tất cả điều này cho bạn. Khi bạn sử dụng connector injected(), nó tự động sử dụng EIP-6963 nếu có và fallback về window.ethereum.
WalletConnect#
WalletConnect là một giao thức kết nối ví di động với dApp desktop thông qua relay server. Người dùng quét mã QR bằng ví di động, thiết lập kết nối mã hóa. Yêu cầu giao dịch được chuyển tiếp từ dApp của bạn tới điện thoại của họ.
Với WAGMI, nó chỉ là một connector khác:
import { walletConnect } from "wagmi/connectors";
const connector = walletConnect({
projectId: "YOUR_PROJECT_ID", // Get from cloud.walletconnect.com
showQrModal: true,
});Xử Lý Chuyển Đổi Chain#
Người dùng thường ở sai mạng. dApp của bạn trên Mainnet, họ kết nối tới Sepolia. Hoặc họ trên Polygon và bạn cần Mainnet. WAGMI cung cấp 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 và Metadata#
NFT không lưu hình ảnh on-chain. Blockchain lưu một URI trỏ tới file JSON metadata, file này lại chứa URL tới hình ảnh. Mẫu tiêu chuẩn, được định nghĩa bởi hàm tokenURI của ERC-721:
Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"
File JSON đó tuân theo một schema tiêu chuẩn:
{
"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 và URL#
Địa chỉ IPFS sử dụng Content Identifier (CID) — hash của chính nội dung. ipfs://QmXyz... có nghĩa "nội dung có hash là QmXyz...". Đây là lưu trữ được đánh địa chỉ theo nội dung: URI được dẫn xuất từ nội dung, vì vậy nội dung không bao giờ có thể thay đổi mà không thay đổi URI. Đây là đảm bảo bất biến mà NFT dựa vào (khi chúng thực sự sử dụng IPFS — nhiều cái sử dụng URL tập trung thay thế, đó là dấu hiệu cảnh báo).
Để hiển thị nội dung IPFS trong trình duyệt, bạn cần gateway dịch URI IPFS sang 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,
};
}Dịch Vụ Pinning#
IPFS là mạng ngang hàng. Nội dung chỉ khả dụng khi ai đó đang lưu trữ ("pinning") nó. Nếu bạn upload hình ảnh NFT lên IPFS rồi tắt node, nội dung sẽ biến mất.
Dịch vụ pinning giữ nội dung của bạn luôn khả dụng:
- Pinata: Phổ biến nhất. API đơn giản. Gói miễn phí rộng rãi (1GB). Gateway chuyên dụng cho tải nhanh hơn.
- NFT.Storage: Miễn phí, được hỗ trợ bởi Protocol Labs (người tạo IPFS). Được thiết kế đặc biệt cho NFT metadata. Sử dụng Filecoin cho lưu trữ dài hạn.
- Web3.Storage: Tương tự NFT.Storage, đa dụng hơn.
// 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
}Vấn Đề Index#
Đây là bí mật bẩn của phát triển blockchain: bạn không thể truy vấn dữ liệu lịch sử hiệu quả từ một RPC node.
Muốn tất cả event Transfer của một token trong năm vừa qua? Bạn sẽ cần quét hàng triệu block với eth_getLogs, phân trang theo chunk 2.000-10.000 block (tối đa thay đổi tùy provider). Đó là hàng ngàn lệnh gọi RPC. Sẽ mất vài phút đến vài giờ và tiêu hao quota API của bạn.
Muốn tất cả token thuộc một địa chỉ cụ thể? Không có lệnh gọi RPC đơn lẻ nào cho việc này. Bạn cần quét mọi event Transfer cho mọi contract ERC-20, theo dõi số dư. Điều đó không khả thi.
Muốn tất cả NFT trong một ví? Cùng vấn đề. Bạn cần quét mọi event Transfer ERC-721 trên mọi contract NFT.
Blockchain là cấu trúc dữ liệu tối ưu cho ghi. Nó xuất sắc trong xử lý giao dịch mới. Nó tệ trong trả lời truy vấn lịch sử. Đây là sự không khớp cơ bản giữa những gì UI dApp cần và những gì chain cung cấp nguyên bản.
Giao Thức The Graph#
The Graph là giao thức index phi tập trung. Bạn viết một "subgraph" — một schema và tập hợp event handler — và The Graph index chain và phục vụ dữ liệu qua 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;
}Sự đánh đổi: The Graph thêm độ trễ (thường 1-2 block sau chain head) và thêm một dependency. Mạng phi tập trung cũng có chi phí index (bạn trả bằng token GRT). Với dự án nhỏ hơn, dịch vụ hosted (Subgraph Studio) là miễn phí.
API Nâng Cao Alchemy và Moralis#
Nếu bạn không muốn duy trì subgraph, cả Alchemy và Moralis đều cung cấp API đã được index sẵn trả lời các truy vấn phổ biến trực tiếp:
// 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}`);
}Các API này là độc quyền và tập trung. Bạn đang đánh đổi phi tập trung lấy trải nghiệm phát triển. Với hầu hết dApp, đó là đánh đổi xứng đáng. Người dùng của bạn không quan tâm liệu portfolio view đến từ subgraph hay từ cơ sở dữ liệu Alchemy. Họ quan tâm rằng nó tải trong 200ms thay vì 30 giây.
Những Sai Lầm Phổ Biến#
Sau khi ship nhiều dApp production và debug code của các team khác, đây là những sai lầm tôi thấy lặp đi lặp lại. Mọi sai lầm đều đã "cắn" cá nhân tôi.
BigInt Ở Mọi Nơi#
Ethereum xử lý các số rất lớn. Số dư ETH tính bằng wei (10^18). Tổng cung token có thể là 10^27 hoặc cao hơn. JavaScript Number chỉ có thể biểu diễn an toàn số nguyên đến 2^53 - 1 (khoảng 9 * 10^15). Không đủ cho lượng 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" — correctQuy tắc cho BigInt trong code dApp:
- Không bao giờ chuyển đổi lượng wei sang
Number. Sử dụngBigIntở mọi nơi, chuyển sang chuỗi dễ đọc chỉ khi hiển thị. - Không bao giờ dùng
Math.floor,Math.round, v.v. trên BigInt. Chúng không hoạt động. Sử dụng phép chia nguyên:amount / 10n ** 6n. - JSON không hỗ trợ BigInt. Nếu bạn serialize trạng thái có BigInt, bạn cần serializer tùy chỉnh:
JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v). - Sử dụng hàm format của thư viện.
ethers.formatEther(),ethers.formatUnits(),formatEther(),formatUnits()của viem. Chúng xử lý chuyển đổi chính xác.
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"Thao Tác Ví Bất Đồng Bộ#
Mọi tương tác ví đều bất đồng bộ và có thể thất bại theo cách ứng dụng cần xử lý uyển chuyển:
// 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.");
}
}Những bẫy bất đồng bộ chính:
- Prompt ví là blocking phía người dùng.
awaittrong code có thể mất 30 giây trong khi người dùng đọc chi tiết giao dịch trong MetaMask. Đừng hiển thị loading spinner khiến họ nghĩ có gì đó hỏng. - Người dùng có thể chuyển tài khoản giữa tương tác. Bạn yêu cầu phê duyệt từ Tài khoản A, người dùng chuyển sang Tài khoản B, rồi phê duyệt. Bây giờ Tài khoản B đã phê duyệt nhưng bạn sắp gửi giao dịch từ Tài khoản A. Luôn kiểm tra lại tài khoản đã kết nối trước các thao tác quan trọng.
- Mẫu ghi hai bước phổ biến. Nhiều thao tác DeFi yêu cầu
approve+execute. Người dùng cần ký hai giao dịch. Nếu họ phê duyệt nhưng không thực thi, bạn cần kiểm tra trạng thái allowance và bỏ qua bước phê duyệt lần sau.
Lỗi Không Khớp Mạng#
Lỗi này lãng phí thời gian debug nhiều hơn bất kỳ vấn đề nào khác. Contract của bạn trên Mainnet. Ví trên Sepolia. Provider RPC trỏ tới Polygon. Ba mạng khác nhau, ba trạng thái khác nhau, ba blockchain hoàn toàn không liên quan. Và thông báo lỗi thường không hữu ích — "execution reverted" hoặc "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 Trong DeFi#
Khi bạn gửi swap trên DEX, giao dịch đang chờ của bạn hiển thị trong mempool. Bot có thể thấy giao dịch của bạn, front-run bằng cách đẩy giá lên, để giao dịch của bạn thực thi ở giá tệ hơn, rồi bán ngay sau đó kiếm lời. Đây gọi là "sandwich attack."
Là lập trình viên frontend, bạn không thể ngăn chặn hoàn toàn, nhưng có thể giảm thiểu:
// 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
);Với giao dịch giá trị cao, hãy cân nhắc sử dụng Flashbots Protect RPC, gửi giao dịch trực tiếp tới block builder thay vì mempool công khai. Điều này ngăn chặn sandwich attack hoàn toàn vì bot không bao giờ thấy giao dịch đang chờ của bạn:
// Using Flashbots Protect as your RPC endpoint
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");Nhầm Lẫn Decimal#
Không phải tất cả token đều có 18 decimal. USDC và USDT có 6. WBTC có 8. Một số token có 0, 2, hoặc decimal tùy ý. Luôn đọc decimals() từ contract trước khi format số lượng:
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"Lỗi Ước Lượng Gas#
Khi estimateGas thất bại, thường có nghĩa giao dịch sẽ revert. Nhưng thông báo lỗi thường chỉ là "cannot estimate gas" mà không cho biết tại sao. Sử dụng eth_call để mô phỏng giao dịch và lấy lý do revert thực tế:
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;
}
}Tổng Hợp Tất Cả#
Đây là một React component hoàn chỉnh, tối giản kết nối ví, đọc số dư token, và gửi chuyển khoản. Đây là bộ khung của mọi 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>
);
}Hướng Đi Tiếp Theo#
Bài viết này đã đề cập các khái niệm và công cụ thiết yếu cho lập trình viên web bắt đầu với Ethereum. Mỗi lĩnh vực có chiều sâu hơn nhiều:
- Solidity: Nếu bạn muốn viết contract, không chỉ tương tác với chúng. Tài liệu chính thức và khóa học của Patrick Collins là điểm khởi đầu tốt nhất.
- Tiêu chuẩn ERC: ERC-20 (token fungible), ERC-721 (NFT), ERC-1155 (multi-token), ERC-4626 (vault được token hóa). Mỗi cái định nghĩa một interface tiêu chuẩn mà tất cả contract trong danh mục đó thực hiện.
- Layer 2: Arbitrum, Optimism, Base, zkSync. Cùng trải nghiệm phát triển, chi phí gas thấp hơn, giả định tin cậy hơi khác. Code ethers.js và viem của bạn hoạt động giống hệt — chỉ thay đổi chain ID và RPC URL.
- Account Abstraction (ERC-4337): Bước tiến tiếp theo của UX ví. Ví smart contract hỗ trợ tài trợ gas, khôi phục xã hội, và giao dịch hàng loạt. Đây là hướng mà mẫu "kết nối ví" đang tiến tới.
- MEV và thứ tự giao dịch: Nếu bạn đang xây dựng DeFi, hiểu Maximal Extractable Value không phải tùy chọn. Tài liệu Flashbots là nguồn tham khảo chính thức.
Hệ sinh thái blockchain phát triển nhanh, nhưng các nền tảng trong bài viết này — tài khoản, giao dịch, mã hóa ABI, lệnh gọi RPC, index event — không thay đổi từ 2015 và sẽ không thay đổi sớm. Học chúng thật kỹ và mọi thứ khác chỉ là bề mặt API.