面向 Web 开发者的以太坊:抛开炒作看智能合约
Web 开发者必须了解的以太坊概念:账户、交易、智能合约、ABI 编码、ethers.js、WAGMI,以及如何不运行节点就读取链上数据。
大多数"面向开发者的以太坊"内容都落入两个极端:要么是过于简化的类比,对实际开发毫无帮助;要么是深入的协议规范,默认你已经知道什么是 Merkle Patricia Trie。如果你只是一个想读取代币余额、让用户签署交易,或者在 React 应用里展示 NFT 元数据的 Web 开发者,这两种内容都派不上用场。
这篇文章是务实的中间路线。我会精确地解释前端与以太坊交互时到底发生了什么,有哪些核心组件,以及现代工具链(ethers.js、viem、WAGMI)如何映射到你在 Web 应用开发中已经理解的概念。
没有自动售货机的比喻。没有"想象一个世界……"之类的铺垫。只有技术模型和代码。
心智模型#
以太坊是一台复制状态机。网络中的每个节点都维护着一份完全相同的状态副本——一个巨大的键值存储,将地址映射到账户数据。当你"发送一笔交易"时,你是在提议一次状态转换。如果足够多的验证者认为它有效,状态就会更新。就这么简单。
状态本身很直观。它是从 20 字节地址到账户对象的映射。每个账户有四个字段:
- nonce:该账户已发送了多少笔交易(对于 EOA)或已创建了多少个合约(对于合约账户)。这可以防止重放攻击。
- balance:以 wei 为单位的 ETH 数量(1 ETH = 10^18 wei)。始终是大整数。
- codeHash:EVM 字节码的哈希值。对于普通钱包(EOA),这是空字节的哈希。对于合约,这是已部署代码的哈希。
- storageRoot:账户存储 trie 的根哈希。只有合约才有有意义的存储。
账户分为两种类型,这个区分对后面所有内容都至关重要:
外部拥有账户(EOA) 由私钥控制。MetaMask 管理的就是这种账户。它们可以发起交易,没有代码。当有人说"钱包"时,他们指的就是 EOA。
合约账户 由其代码控制。它们无法主动发起交易——只能在被调用时执行。它们有代码和存储。当有人说"智能合约"时,指的就是这个。代码一旦部署就不可更改(通过代理模式有一些例外,但那是另一个话题了)。
关键洞察:以太坊上的每次状态变更都始于一个 EOA 签署一笔交易。合约可以调用其他合约,但执行链的起点必定是一个拥有私钥的人(或机器人)。
Gas:计算有价格#
EVM 中的每个操作都消耗 gas。两数相加消耗 3 gas。存储一个 32 字节的字消耗 20,000 gas(首次)或 5,000 gas(更新)。读取存储消耗 2,100 gas(冷访问)或 100 gas(热访问,即本次交易中已经访问过)。
你支付 gas 的单位不是"gas 单位",而是 ETH。总费用为:
totalCost = gasUsed * gasPrice
在 EIP-1559(伦敦升级)之后,gas 定价变成了双重结构:
totalCost = gasUsed * (baseFee + priorityFee)
- baseFee:由协议根据网络拥堵程度设定。会被销毁(destroy)。
- priorityFee(小费):归验证者所有。小费越高,交易被打包得越快。
- maxFeePerGas:你愿意为每单位 gas 支付的最大金额。
- maxPriorityFeePerGas:你愿意支付的每单位 gas 最大小费。
如果 baseFee + priorityFee > maxFeePerGas,你的交易会等待直到 baseFee 下降。这就是交易在网络拥堵时"卡住"的原因。
对 Web 开发者而言,实际意义是:**读取数据免费,写入数据花钱。**这是 Web2 和 Web3 之间最重要的架构差异。每个 SELECT 都免费。每个 INSERT、UPDATE、DELETE 都要花真金白银。请据此设计你的 dApp。
交易#
交易是一个签名的数据结构。以下是重要的字段:
interface Transaction {
// Who receives this transaction — an EOA address or a contract address
to: string; // 20-byte hex address, or null for contract deployment
// How much ETH to send (in wei)
value: bigint; // Can be 0n for pure contract calls
// Encoded function call data, or empty for plain ETH transfers
data: string; // Hex-encoded bytes, "0x" for simple transfers
// Sequential counter, prevents replay attacks
nonce: number; // Must exactly equal sender's current nonce
// Gas limit — maximum gas this tx can consume
gasLimit: bigint;
// EIP-1559 fee parameters
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
// Chain identifier (1 = mainnet, 11155111 = Sepolia, 137 = Polygon)
chainId: number;
}交易的生命周期#
-
构建:你的应用构建交易对象。如果你正在调用合约函数,
data字段包含 ABI 编码的函数调用(下面会详细讲)。 -
签名:私钥对 RLP 编码的交易进行签名,产生
v、r、s签名组件。这证明了发送者授权了这笔特定的交易。发送者地址是从签名中推导出来的——它不会显式出现在交易中。 -
广播:签名后的交易通过
eth_sendRawTransaction发送到 RPC 节点。节点验证它(正确的 nonce、足够的余额、有效的签名)并将其加入内存池。 -
内存池:交易在待处理交易池中等待。验证者选择将哪些交易包含在下一个区块中,通常优先选择小费更高的。这就是抢跑攻击发生的地方——其他参与者可以看到你的待处理交易,并提交一笔小费更高的交易以抢先执行。
-
打包:验证者将你的交易打包进一个区块。EVM 执行它。如果成功,状态变更被应用。如果回退,状态变更被撤销——但你仍然要为回退之前消耗的 gas 付费。
-
最终性:在权益证明的以太坊上,一个区块在两个 epoch(约 12.8 分钟)后变为"已确认"。在最终确认之前,链重组在理论上是可能的(虽然罕见)。大多数应用将 1-2 个区块确认视为非关键操作的"足够安全"。
下面是使用 ethers.js v6 发送简单 ETH 转账的代码:
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 = revert使用 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);注意区别:ethers 返回一个带有 .wait() 方法的 TransactionResponse 对象。Viem 只返回哈希——你需要使用单独的 publicClient.waitForTransactionReceipt({ hash }) 调用来等待确认。这种关注点分离是 viem 设计中刻意为之的。
智能合约#
智能合约是部署在特定地址上的字节码加持久化存储。当你"调用"一个合约时,你发送的交易(或只读调用)的 data 字段被设置为编码后的函数调用。
字节码与 ABI#
字节码是编译后的 EVM 代码。你不会直接与它交互。它是 EVM 执行的内容。
ABI(应用二进制接口)是合约接口的 JSON 描述。它告诉你的客户端库如何编码函数调用和解码返回值。可以把它理解为合约的 OpenAPI 规范。
以下是一个 ERC-20 代币 ABI 的片段:
const ERC20_ABI = [
// Read-only functions (view/pure — no gas cost when called externally)
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address owner) view returns (uint256)",
"function allowance(address owner, address spender) view returns (uint256)",
// State-changing functions (require a transaction, cost gas)
"function transfer(address to, uint256 amount) returns (bool)",
"function approve(address spender, uint256 amount) returns (bool)",
"function transferFrom(address from, address to, uint256 amount) returns (bool)",
// Events (emitted during execution, stored in transaction logs)
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;ethers.js 接受这种"人类可读 ABI"格式。Viem 也可以使用它,但通常你会使用 Solidity 编译器生成的完整 JSON ABI。两者是等价的——人类可读格式只是对常用接口更方便。
函数调用如何编码#
这是大多数教程跳过的部分,也是能帮你节省数小时调试时间的部分。
当你调用 transfer("0xBob...", 1000000) 时,交易的 data 字段会被设置为:
0xa9059cbb // Function selector
0000000000000000000000000xBob...000000000000000000000000 // address, padded to 32 bytes
00000000000000000000000000000000000000000000000000000000000f4240 // uint256 amount (1000000 in hex)
函数选择器是函数签名的 Keccak-256 哈希的前 4 个字节:
keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = first 4 bytes = 0xa9059cbb
剩余字节是 ABI 编码的参数,每个参数填充到 32 字节。这种编码方案是确定性的——相同的函数调用总是产生相同的 calldata。
为什么这很重要?因为当你在 Etherscan 上看到原始交易数据以 0xa9059cbb 开头时,你就知道这是一个 transfer 调用。当你的交易回退而错误信息只是一个十六进制的 blob 时,你可以使用 ABI 来解码它。当你构建交易批处理或与 multicall 合约交互时,你会手动编码 calldata。
下面是使用 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]); // true存储槽#
合约存储是一个键值存储,键和值都是 32 字节。Solidity 从 0 开始依次分配存储槽。第一个声明的状态变量放在槽 0,下一个放在槽 1,以此类推。映射和动态数组使用基于哈希的方案。
你可以直接读取任何合约的存储,即使变量在 Solidity 中被标记为 private。"Private"只意味着其他合约不能读取它——任何人都可以通过 eth_getStorageAt 来读取:
// Reading storage slot 0 of a contract
const slot0 = await provider.getStorage(
"0xContractAddress...",
0
);
console.log(slot0); // Raw 32-byte hex value区块浏览器就是这样展示合约的"内部"状态的。存储读取没有访问控制。公链上的隐私从根本上是有限的。
事件与日志#
事件是合约发出的结构化数据,存储在交易日志中而不是合约存储中。它们比存储写入便宜(第一个 topic 375 gas + 每字节数据 8 gas,而存储写入需要 20,000 gas),而且它们被设计为可高效查询的。
一个事件最多可以有 3 个 indexed 参数(存储为"topics")和任意数量的非索引参数(存储为"data")。索引参数可以用于过滤——你可以查询"给我所有 to 为某个地址的 Transfer 事件"。非索引参数不能被过滤;你必须获取所有匹配的事件然后在客户端过滤。
// 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());
}使用 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读取链上数据#
这是以太坊对 Web 开发者真正变得实用的地方。你不需要运行节点。不需要挖矿。甚至不需要钱包。从以太坊读取数据是免费的、无需许可的,并且通过简单的 JSON-RPC API 即可完成。
JSON-RPC:以太坊的 HTTP API#
每个以太坊节点都暴露一个 JSON-RPC API。它就是带 JSON 请求体的 HTTP POST。传输层没有任何区块链特有的东西。
// 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" }这是一个原始的 eth_call。它模拟交易执行而不实际提交。没有 gas 费用。没有状态变更。只是读取返回值。这就是 view 和 pure 函数从外部调用时的工作方式——它们使用 eth_call 而不是 eth_sendRawTransaction。
两个关键的 RPC 方法#
eth_call:模拟执行。免费。不改变状态。用于所有读操作——查询余额、读取价格、调用 view 函数。可以通过指定区块号而非"latest"来在任何历史区块上调用。
eth_sendRawTransaction:提交一笔签名交易以打包进区块。消耗 gas。改变状态(如果成功)。用于所有写操作——转账、授权、兑换、铸造。
JSON-RPC API 中的其他一切要么是这两个方法的变体,要么是工具方法(eth_blockNumber、eth_getTransactionReceipt、eth_getLogs 等)。
Provider:通往链的网关#
你不会自己运行节点。几乎没有人在应用开发中这样做。相反,你使用 Provider 服务:
- Alchemy:最受欢迎的。优秀的仪表盘、webhook 支持、增强的 NFT 和代币元数据 API。免费套餐:约每月 3 亿计算单元。
- Infura:元老级。ConsenSys 旗下。稳定可靠。免费套餐:每天 10 万请求。
- QuickNode:多链支持好。计价模式略有不同。
- 公共 RPC 端点:
https://rpc.ankr.com/eth、https://cloudflare-eth.com。免费但有速率限制,偶尔不稳定。开发用可以,生产环境危险。 - Tenderly:模拟和调试能力出色。其 RPC 自带交易模拟器。
在生产环境中,至少配置两个 Provider 作为备选。RPC 宕机是真实存在的,而且总是在最糟糕的时候发生。
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,
},
]);免费读取合约状态#
这是大多数 Web2 开发者没有意识到的强大能力:你可以从以太坊上任何合约读取任何公开数据,不用付费,不需要钱包,除了 RPC Provider 的 API 密钥之外不需要任何认证。
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`);不需要钱包。不消耗 gas。不产生交易。底层只是一个 JSON-RPC eth_call。这在概念上与向 REST API 发送 GET 请求完全一样。区块链是数据库,合约是 API,eth_call 就是你的 SELECT 查询。
ethers.js v6#
ethers.js 是 Web3 的 jQuery——它是大多数开发者最先学习的库,至今仍是使用最广泛的。v6 版本相比 v5 有了显著改进,包括原生 BigInt 支持(终于),ESM 模块,以及更简洁的 API。
三个核心抽象#
Provider:与区块链的只读连接。可以调用 view 函数、获取区块、读取日志。不能签名或发送交易。
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:对私钥的抽象。可以签署交易和消息。Signer 总是连接到一个 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:已部署合约的 JavaScript 代理。Contract 对象上的方法对应 ABI 中的函数。View 函数返回值。状态变更函数返回 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 实现类型安全#
原始 ABI 交互是字符串类型化的。你可能拼错函数名、传递错误的参数类型或误读返回值。TypeChain 从你的 ABI 文件生成 TypeScript 类型:
// 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.对于新项目,考虑使用 viem 内置的 ABI 类型推断。它可以达到同样的效果,而不需要单独的代码生成步骤。
监听事件#
实时事件流对响应式 dApp 至关重要。ethers.js 使用 WebSocket provider 来实现:
// 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:现代技术栈#
WAGMI(We're All Gonna Make It)是一个用于以太坊的 React hooks 库。Viem 是它底层使用的 TypeScript 客户端。它们在很大程度上已经取代了 ethers.js + web3-react,成为前端 dApp 开发的标准技术栈。
为什么会有这种转变?三个原因:从 ABI 完全推断 TypeScript 类型(不需要代码生成),更小的包体积,以及处理钱包交互复杂异步状态管理的 React hooks。
初始化配置#
// 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>
);
}读取合约数据#
useReadContract 是你最常用的 hook。它将 eth_call 包装上 React Query 的缓存、重新获取以及加载/错误状态:
"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>;
}注意 ABI 上的 as const。这一点至关重要。没有它,TypeScript 会丢失字面量类型,balance 会变成 unknown 而不是 bigint。整个类型推断系统都依赖于 const 断言。
写入合约#
useWriteContract 处理完整的生命周期:钱包弹窗、签名、广播和确认跟踪。
"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>
);
}监听事件#
useWatchContractEvent 建立一个 WebSocket 订阅用于实时事件监控:
"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>
);
}连接钱包的模式#
连接用户钱包是 Web3 的"登录"。但它其实不是登录。没有会话,没有 cookie,没有服务端状态。钱包连接只是给你的应用权限来读取用户的地址并请求交易签名。仅此而已。
EIP-1193 Provider 接口#
每个钱包都暴露一个由 EIP-1193 定义的标准接口。它是一个带有 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 将其注入为 window.ethereum。其他钱包要么注入自己的属性,要么也使用 window.ethereum(这会导致冲突——即"钱包大战"问题,由 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:多钱包发现#
旧的 window.ethereum 方式在用户安装了多个钱包时就会出问题。哪个钱包获得 window.ethereum?最后注入的?第一个?这是一个竞争条件。
EIP-6963 通过基于浏览器事件的发现协议解决了这个问题:
// 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 帮你处理了所有这些。当你使用 injected() 连接器时,它会自动使用 EIP-6963(如果可用)并回退到 window.ethereum。
WalletConnect#
WalletConnect 是一种通过中继服务器将移动端钱包连接到桌面端 dApp 的协议。用户用手机钱包扫描二维码,建立一个加密连接。交易请求从你的 dApp 中继到他们的手机。
使用 WAGMI 时,它只是另一个连接器:
import { walletConnect } from "wagmi/connectors";
const connector = walletConnect({
projectId: "YOUR_PROJECT_ID", // Get from cloud.walletconnect.com
showQrModal: true,
});处理链切换#
用户经常连接到错误的网络。你的 dApp 在主网,他们连接到 Sepolia。或者他们在 Polygon 而你需要主网。WAGMI 提供了 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 与元数据#
NFT 不在链上存储图片。区块链存储的是一个指向 JSON 元数据文件的 URI,而这个 JSON 又包含一个指向图片的 URL。这是由 ERC-721 的 tokenURI 函数定义的标准模式:
Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"
这个 JSON 文件遵循标准格式:
{
"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 与 URL#
IPFS 地址使用内容标识符(CID)——内容本身的哈希。ipfs://QmXyz... 意思是"哈希为 QmXyz... 的内容"。这是内容寻址存储:URI 由内容推导而来,因此内容不可能在不改变 URI 的情况下被修改。这就是 NFT 所依赖的不可变性保证(前提是它们确实使用 IPFS——很多使用中心化 URL,这是一个危险信号)。
要在浏览器中展示 IPFS 内容,你需要一个将 IPFS URI 转换为 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,
};
}固定服务#
IPFS 是一个点对点网络。内容只有在有人托管("固定")它的情况下才保持可用。如果你把 NFT 图片上传到 IPFS 然后关掉你的节点,内容就会消失。
固定服务让你的内容保持可用:
- Pinata:最受欢迎。简单的 API。慷慨的免费套餐(1GB)。专用网关加速加载。
- NFT.Storage:免费,由 Protocol Labs(IPFS 的创建者)支持。专为 NFT 元数据设计。使用 Filecoin 进行长期持久化。
- Web3.Storage:类似 NFT.Storage,更通用。
// 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
}索引问题#
这是区块链开发的公开秘密:你无法从 RPC 节点高效地查询历史数据。
想要获取某个代币过去一年的所有 Transfer 事件?你需要扫描数百万个区块,使用 eth_getLogs,以 2,000-10,000 个区块为一批进行分页(最大值因 Provider 而异)。那是成千上万次 RPC 调用。需要数分钟到数小时,还会耗尽你的 API 配额。
想要获取某个地址拥有的所有代币?没有单一的 RPC 调用可以做到这一点。你需要扫描每一个 ERC-20 合约的每一个 Transfer 事件并跟踪余额。这是不可行的。
想要获取一个钱包里的所有 NFT?同样的问题。你需要扫描每一个 NFT 合约的每一个 ERC-721 Transfer 事件。
区块链是一个写优化的数据结构。它擅长处理新交易。它在回答历史查询方面很糟糕。这就是 dApp UI 需求与链原生提供能力之间的根本错位。
The Graph 协议#
The Graph 是一个去中心化的索引协议。你编写一个"子图"——一个 schema 和一组事件处理器——The Graph 会索引链上数据并通过 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;
}权衡是:The Graph 增加了延迟(通常落后链头 1-2 个区块)以及额外的依赖。去中心化网络还有索引成本(你需要用 GRT 代币付费)。对于小型项目,托管服务(Subgraph Studio)是免费的。
Alchemy 和 Moralis 增强 API#
如果你不想维护子图,Alchemy 和 Moralis 都提供了预索引的 API,可以直接回答常见查询:
// 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}`);
}这些 API 是专有且中心化的。你在用去中心化换开发者体验。对于大多数 dApp 来说,这是值得的权衡。你的用户不在乎他们的投资组合视图来自子图还是 Alchemy 的数据库。他们在乎的是加载时间是 200 毫秒还是 30 秒。
常见陷阱#
在发布了几个生产 dApp 并调试了其他团队的代码之后,这些是我反复看到的错误。每一个都坑过我自己。
BigInt 无处不在#
以太坊处理的是非常大的数字。ETH 余额以 wei 为单位(10^18)。代币供应量可以达到 10^27 甚至更高。JavaScript 的 Number 只能安全表示 2^53 - 1 以内的整数(约 9 * 10^15)。这对 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" — correctdApp 代码中使用 BigInt 的规则:
- 永远不要将 wei 数量转换为
Number。全程使用BigInt,只在展示时转换为人类可读的字符串。 - 永远不要对 BigInt 使用
Math.floor、Math.round等方法。它们不起作用。使用整数除法:amount / 10n ** 6n。 - JSON 不支持 BigInt。如果你序列化包含 BigInt 的状态,你需要自定义序列化器:
JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v)。 - 使用库的格式化函数。
ethers.formatEther()、ethers.formatUnits()、viem的formatEther()、formatUnits()。它们能正确处理转换。
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"异步钱包操作#
每次钱包交互都是异步的,并且可能以你的应用需要优雅处理的方式失败:
// 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.");
}
}关键的异步陷阱:
- 钱包弹窗在用户端是阻塞的。你代码中的
await可能等待 30 秒,用户在 MetaMask 中阅读交易详情。不要显示一个让他们以为出了问题的加载动画。 - 用户可能在交互中途切换账户。你从账户 A 请求授权,用户切换到账户 B,然后批准。现在是账户 B 授权了,但你即将从账户 A 发送交易。在关键操作前务必重新检查已连接的账户。
- 两步写入模式很常见。许多 DeFi 操作需要
approve+execute。用户需要签署两笔交易。如果他们授权了但没有执行,你需要检查 allowance 状态并在下次跳过授权步骤。
网络不匹配错误#
这个问题比任何其他问题都浪费更多的调试时间。你的合约在主网。你的钱包在 Sepolia。你的 RPC Provider 指向 Polygon。三个不同的网络,三个不同的状态,三条完全无关的区块链。而错误信息通常毫无帮助——"execution reverted"或"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 };
}DeFi 中的抢跑攻击#
当你在 DEX 上提交一笔兑换时,你的待处理交易在内存池中是可见的。一个机器人可以看到你的交易,通过推高价格来抢先执行,让你的交易以更差的价格成交,然后立即卖出获利。这被称为"三明治攻击"。
作为前端开发者,你无法完全阻止这种情况,但可以缓解它:
// 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
);对于高价值交易,考虑使用 Flashbots Protect RPC,它直接将交易发送给区块构建者而不是公开的内存池。这完全防止了三明治攻击,因为机器人永远看不到你的待处理交易:
// Using Flashbots Protect as your RPC endpoint
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");小数精度混乱#
不是所有代币都有 18 位小数。USDC 和 USDT 有 6 位。WBTC 有 8 位。有些代币有 0、2 或任意小数位数。在格式化金额之前,务必从合约读取 decimals():
async function formatTokenAmount(
tokenAddress: string,
rawAmount: bigint,
provider: ethers.Provider
): Promise<string> {
const contract = new ethers.Contract(
tokenAddress,
["function decimals() view returns (uint8)", "function symbol() view returns (string)"],
provider
);
const [decimals, symbol] = await Promise.all([
contract.decimals(),
contract.symbol(),
]);
return `${ethers.formatUnits(rawAmount, decimals)} ${symbol}`;
}
// formatTokenAmount(USDC, 1000000n, provider) → "1.0 USDC"
// formatTokenAmount(WETH, 1000000000000000000n, provider) → "1.0 WETH"
// formatTokenAmount(WBTC, 100000000n, provider) → "1.0 WBTC"Gas 估算失败#
当 estimateGas 失败时,通常意味着交易会回退。但错误信息往往只是"cannot estimate gas",没有说明原因。使用 eth_call 模拟交易以获取实际的回退原因:
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;
}
}完整示例#
下面是一个完整的、最小化的 React 组件,连接钱包、读取代币余额并发送转账。这是每个 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>
);
}后续学习方向#
这篇文章涵盖了 Web 开发者入门以太坊所需的核心概念和工具。每个领域都有更深入的内容:
- Solidity:如果你想编写合约而不仅仅是与之交互。官方文档和 Patrick Collins 的课程是最好的起点。
- ERC 标准:ERC-20(同质化代币)、ERC-721(NFT)、ERC-1155(多代币)、ERC-4626(代币化金库)。每个标准都定义了该类别所有合约实现的标准接口。
- Layer 2:Arbitrum、Optimism、Base、zkSync。相同的开发体验,更低的 gas 费用,略有不同的信任假设。你的 ethers.js 和 viem 代码完全一样——只需更改 chain ID 和 RPC URL。
- 账户抽象(ERC-4337):钱包用户体验的下一步进化。支持 gas 代付、社交恢复和批量交易的智能合约钱包。这就是"连接钱包"模式的发展方向。
- MEV 和交易排序:如果你在构建 DeFi 应用,理解最大可提取价值(Maximal Extractable Value)不是可选的。Flashbots 文档是权威资源。
区块链生态发展很快,但这篇文章中的基础知识——账户、交易、ABI 编码、RPC 调用、事件索引——自 2015 年以来就没有改变过,短期内也不会改变。把这些学扎实,其他的都只是 API 表层。