从合约到用户:ethers.js/Viem、钱包连接(EIP-6963/4337)、交易追踪、事件订阅状态同步、去中心化存储与多链架构——理解 DApp 前端的完整拼图。
本章目录:
- 14.1 前端库:ethers.js 与 Viem
- 14.2 钱包连接:MetaMask、WalletConnect、EIP-6963
- 14.3 交易构造、发送与状态追踪
- 14.4 事件订阅与前端状态同步
- 14.5 去中心化存储:IPFS 与 Arweave
- 14.6 多链 DApp 与跨链桥:前端如何跨越多重宇宙
14.1 前端库:ethers.js 与 Viem
graph LR
前端[React/NextJS 前端] --> Viem["类型安全<br/>Viem"]
前端 --> Ethers["传统标准<br/>ethers.js v6"]
前端 --> Web3[Web3.js v4]
Viem --> 节点[节点 RPC<br/>WalletConnect JSON-RPC]
Ethers --> 节点
Web3 --> 节点
节点 --> 合约[智能合约读写<br/>ABI 绑定调用]
节点 --> 事件[事件订阅 / 过滤]
style Viem fill:#c8e6c9
style 节点 fill:#e3f2fd
智能合约的后端是 Solidity,前端是 TypeScript。 ethers.js 是传统标准,但 Viem 正在定义下一代——它更小、更快、更安全、类型更安全。
14.1.1 两代前端库的演进
| 特性 | ethers.js v5 | ethers.js v6 | Viem |
| 包体积 | 130 KB | 110 KB | 21 KB (gzip) |
| BigInt | 混合 (BN.js) | 原生 BigInt | 原生 BigInt |
| Provider/Separation | 清晰 | 简化 | one-shot calls |
14.1.2 ethers.js v6 核心模式
/**
* ethers.js v6:Provider 读取 + Signer 写入
*/
import { ethers } from "ethers"; // 概念性展示,不依赖任何库
// 1. Provider:连接以太坊(只读)
const infuraUrl = "https://mainnet.infura.io/v3/YOUR_KEY";
const provider = new ethers.JsonRpcProvider(infuraUrl);
// 读取
const balance = await provider.getBalance("0xd8dA...");
const blockNumber = await provider.getBlockNumber();
// 2. Signer:签名交易(需要私钥或钱包)
const privateKey = "0x...";
const signer = new ethers.Wallet(privateKey, provider);
// 写入:转账 0.1 ETH
const tx = await signer.sendTransaction({
to: "0xRecipient...",
value: ethers.parseEther("0.1"), // 100000000000000000n
});
const receipt = await tx.wait(); // 等待确认
// 3. 合约交互:自动编码 ABI
const erc20ABI = [
"function balanceOf(address) view returns (uint256)",
"function transfer(address,uint256) returns (bool)",
"event Transfer(address,address,uint256)",
];
const token = new ethers.Contract(tokenAddress, erc20ABI, signer);
const myBalance = await token.balanceOf(signer.address);
const tx2 = await token.transfer("0xFriend...", 1000n);
14.1.3 Viem:类型原生的极简设计
/**
* Viem:现代前端与链交互的范式
* 核心概念:Client = Provider + 可选的 Wallet(Account)
*/
// 纯 TypeScript 模拟,展示 Viem 的设计哲学
// 1. 创建可读的公共 client(无需钱包)
interface PublicClient {
chain: { id: number; name: string };
transport: { url: string };
async getBalance(address: string): Promise<bigint>;
async getBlockNumber(): Promise<bigint>;
async readContract(params: any): Promise<any>;
async getGasPrice(): Promise<bigint>;
}
// 2. 创建可写的 wallet client(需要 Account)
interface WalletClient {
account: { address: string; privateKey?: string };
chain: { id: number };
async sendTransaction(params: any): Promise<string>;
async writeContract(params: any): Promise<string>;
async signMessage(message: string): Promise<string>;
}
// 设计差异对比
function compareEthersVsViem() {
// ethers.js: 一个 Contract 对象,内部持有 provider/signer
// 所有操作都通过 Contract 对象,方便但类型松散
// Viem: 分离到函数级,每个操作独立调用
// 读合约:readContract({ address, abi, functionName, args })
// 写合约:writeContract({ ... })
// 纯粹函数式,更容易 tree-shake 和类型推断
return "Viem wins on: size(21KB), strict types, no class state, explicit function calls";
}
// Viem 风格的合约交互(TypeScript 模拟)
async function viemStyleRead(
client: PublicClient,
tokenAddress: string,
userAddress: string,
): Promise<bigint> {
return client.readContract({
address: tokenAddress as `0x${string}`,
abi: [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ type: "uint256" }],
},
],
functionName: "balanceOf",
args: [userAddress],
});
}
// 对比:读合约(不涉及状态变更)vs 写合约(需要签名)
// 在 Viem 中,这是两个完全独立的函数调用,<FunctionName> 是类型 infer 的
// 错误:如果写合约时用了 readContract,TypeScript 编译期就捕获
console.log(compareEthersVsViem());
Viem 的核心优势:错误类型
Viem 为常见错误提供类型安全的错误对象:
InsufficientFundsError 而非 Error: insufficient funds for gasUserRejectedRequestError 而非 Error: user rejectedContractFunctionExecutionError 包含 decodedArgs
14.1.4 前端库选型建议
| ethers 遗产 | ethers v6 — 迁移成本低 |
| 需要最新功能 | Viem — 积极维护、新 EIP 支持快 |
钱包是用户进入 Web3 的唯一入口。连接质量直接决定用户留存——EIP-6963 修复了多年的"互斥钱包注入"问题,嵌入式钱包(Privy、Coinbase Smart Wallet)正在让 Web3 像 Web2 一样简单。
14.2.1 浏览器钱包的进化
历史:窗口注入的混乱
graph TD
subgraph Old["2023 年之前"]
A[window.ethereum] --> B[MetaMask]
A --> C[Coinbase Wallet]
A --> D["多个钱包互斥覆盖"]
end
subgraph New["EIP-6963 之后"]
E[event: eip6963:announceProvider] --> F[MetaMask]
E --> G[Coinbase]
E --> H[Phantom]
E --> I[Rainbow]
F -.-> J["选择窗口"]
end
style Old fill:#ffebee
style New fill:#e8f5e9
EIP-6963(2023 年 11 月,MetaMask 主导):每个钱包暴露 eip6963:announceProvider 事件,DApp 可以发现并列出所有已安装的钱包,而非猜测 window.ethereum 被谁覆盖。
14.2.2 连接协议的核心流程
/**
* 钱包连接的 TypeScript 模拟
* 展示 EIP-1102, EIP-6963 和现代连接模式
*/
// === 阶段 1: 传统注入(EIP-1102)
interface WindowEthereum {
async request(args: { method: string; params?: any }): Promise<any>;
on(event: string, handler: (args: any) => void): void;
removeListener(event: string, handler: any): void;
selectedAddress: string | null;
isMetaMask?: boolean;
}
async function legacyConnect(): Promise<string[]> {
// 检查 window.ethereum(已被覆盖风险)
const eth = (window as any).ethereum as WindowEthereum;
if (!eth) throw new Error("No wallet installed");
// EIP-1102: 请求用户授权连接
const accounts = await eth.request({
method: "eth_requestAccounts",
params: [],
});
// 返回已授权地址列表
return accounts; // ["0x...", ...]
}
// === 阶段 2: EIP-6963 多钱包发现
interface EIP6963Provider {
info: {
uuid: string; // 唯一标识
name: string; // 钱包名称
icon: string; // base64 icon
rdns: string; // 反向域名(如 com.metamask)
};
provider: WindowEthereum; // 实际 EIP-1193 provider
}
class WalletDiscovery {
private providers: Map<string, EIP6963Provider> = new Map();
discover(): Promise<EIP6963Provider[]> {
return new Promise((resolve) => {
const providers: EIP6963Provider[] = [];
// 监听 announce 事件
const onAnnounce = (event: any) => {
const detail: EIP6963Provider = event.detail;
if (!this.providers.has(detail.info.uuid)) {
this.providers.set(detail.info.uuid, detail);
providers.push(detail);
}
};
window.addEventListener("eip6963:announceProvider", onAnnounce);
// 主动请求所有钱包重新 announce
setTimeout(() => {
window.dispatchEvent(new Event("eip6963:requestProvider"));
}, 100);
// 3 秒后返回收集到的钱包
setTimeout(() => {
window.removeEventListener("eip6963:announceProvider", onAnnounce);
resolve(providers);
}, 3000);
});
}
async connectByUUID(uuid: string): Promise<string[]> {
const p = this.providers.get(uuid);
if (!p) throw new Error("Wallet not discovered");
return p.provider.request({ method: "eth_requestAccounts", params: [] });
}
}
// 示例使用
async function multiWalletDemo() {
const discovery = new WalletDiscovery();
const wallets = await discovery.discover();
console.log("发现钱包:", wallets.map(w => w.info.name));
// 用户选择后连接
const mm = wallets.find(w => w.info.rdns === "io.metamask");
if (mm) {
const accounts = await mm.provider.request({ method: "eth_requestAccounts" });
console.log("MetaMask 地址:", accounts[0]);
}
}
console.log("钱包发现机制已定义");
14.2.3 嵌入式钱包(Embedded Wallets)
| 浏览器扩展 | MetaMask | 安装 → 创建/导入助记词 → 备份 | 高 |
| 移动端 | Trust, 1inch | 安装 App → 创建 | 中 |
| 嵌入式(Web2 登录) | Coinbase Smart Wallet, Privy, Dynamic | 邮箱/社交 → 链上账户自动创建 | 零 |
嵌入式钱包原理
graph LR
User["用户"] --> |"邮箱 / Google / X 登录"| Auth[auth.privy.io / Coinbase]
Auth --> |"返回 JWT + 用户 ID"| KeyGen["分布式密钥派生"]
KeyGen --> |"EOA 或 智能钱包"| Account["链上账户"]
Account --> |"无需 seed phrase"| User
style KeyGen fill:#e3f2fd
密钥的托管模式:
- MPC(多方计算):密钥分片存于不同设备/节点,永远无法完整重建
- Passkey / 生物识别:设备本地 HSM 签名,云端只存公钥
- 账户抽象(ERC-4337):用户不管理 EOA,直接控制链上"智能钱包"
14.2.4 账户抽象与 Smart Wallet
/**
* 账户抽象(ERC-4337)核心:用户的"账户"是一个智能合约
*/
interface SmartWallet {
// 传统 EOA:address = keccak256(publicKey)[12:]
// 4337 智能账户:address = CREATE2 部署的合约地址
address: string;
// 入口点合约(所有 4337 交易都通过它)
entryPoint: "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789";
// 验证逻辑可以是:签名、M-of-N 多签、社交恢复、Session Key...
async validateUserOp(userOp: UserOperation): Promise<ValidationData>;
}
interface UserOperation {
sender: string; // 4337 智能账户地址
nonce: bigint;
initCode?: string; // 首次部署账户的字节码
callData: string; // 实际调用
signature: string; // 签名(不验证 sender 地址,验证自定义逻辑)
paymaster?: string; // 可选的代付方
}
function calculate4337Address(
factory: string,
salt: bigint,
initCodeHash: string,
): string {
// 与 CREATE2 相同公式
return "0x" + BigInt(
"0x" + keccak256("0xFF" + factory + salt.toString(16).padStart(64, '0') + initCodeHash)
).toString(16).slice(-40).padStart(40, '0');
}
// 关键优势:
// 1. 用户无需知道"私钥"——可以邮箱+验证码控制账户
// 2. 批量交易(一次签名,10 次操作)
// 3. 社交恢复(3 个朋友 = 恢复账户)
// 4. 免 gas 支付(paymaster 代付)
console.log("ERC-4337 智能钱包:签名 = 任何可验证逻辑,不限于 secp256k1");
14.2.5 连接状态管理
graph TD
A["未连接"] --> |点击连接| B["请求中"]
B --> |用户确认| C["已连接"]
B --> |用户拒绝| D["拒绝"]
C --> |切换网络| E["切换中"]
C --> |断开| A
C --> |账户切换| C
E --> |成功| C
E --> |不支持| F["错误"]
style A fill:#ffebee
style C fill:#c8e6c9
style D fill:#ffebee
style F fill:#ffebee
14.3 交易构造、发送与状态追踪
一笔交易的旅程:前端构造 → 钱包签名 → RPC 广播 → mempool 等待 → 矿工/PBS 打包 → 区块确认。理解这个流程的每一步,才能写出不丢交易、不错 gas、不卡 UX 的 DApp。
14.3.1 交易对象的基本结构
graph LR
subgraph Tx["EIP-1559 交易对象"]
T1["to: 目标地址"]
T2["data: 0x 调用数据"]
T3["value: ETH 转账金额"]
T4["maxFeePerGas: 每 gas 最高总费"]
T5["maxPriorityFeePerGas: 给矿工/validator 的小费"]
T6["gasLimit: 最大 gas 数量"]
T7["nonce: 发送者交易计数"]
T8["chainId: 网络标识"]
end
T4 --" - "--> T4a[" = baseFee + maxPriorityFee"]
T4a --"但最多只收"--> T4b["baseFee 由网络动态调整\n小费给 validator 决定顺序"]
style Tx fill:#e3f2fd
Gas 价格计算
totalFeePerGas=baseFee+priorityFee
/**
* Gas 价格估算与交易构造
*/
interface TransactionRequest {
to: string;
data: string;
value: bigint;
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
gasLimit: bigint;
nonce: number;
chainId: number;
}
class GasEstimator {
// EIP-1559 的 baseFee 由网络根据区块容量动态调整:
// target = 15M gas/block(上限 30M)
// 如果区块超过 target,baseFee 增加最多 12.5%
// 如果低于,baseFee 减少最多 12.5%
async estimate1559Fees(blockUtilization: number): Promise<{[key: string]: bigint}> {
const currentBaseFee = 20n * 10n**9n; // 20 gwei (示例)
const target = 0.5; // 50% 利用率 = 稳定
// baseFee 变化公式 (简化)
let newBaseFee: bigint;
if (blockUtilization > 0.5) {
// 拥堵:baseFee 增加
const increase = 1n + BigInt(Math.floor((blockUtilization - 0.5) * 1000));
newBaseFee = currentBaseFee * increase / 1000n;
} else {
// 空闲:baseFee 减少
const decrease = 1n - BigInt(Math.floor((0.5 - blockUtilization) * 1000));
newBaseFee = currentBaseFee * decrease / 1000n;
}
const priorityFee = 2n * 10n**9n; // 2 gwei 小费
const maxFee = newBaseFee * 2n + priorityFee; // 余量
return {
baseFee: newBaseFee,
maxPriorityFeePerGas: priorityFee,
maxFeePerGas: maxFee,
};
}
// gas 限制估算(调用节点的 gasEstimate 模拟执行)
async estimateGasLimit(tx: Partial<TransactionRequest>): Promise<bigint> {
// 模拟:简单 ETH 转账 21,000 gas
if (!tx.data || tx.data === '0x') return 21000n;
// 合约调用:通常 50K-500K 不等
return 150000n; // 示例
}
}
// 自动构造交易
async function buildTransaction(args: {
to: string;
value?: bigint;
data?: string;
priority?: 'low' | 'normal' | 'high';
}): Promise<TransactionRequest> {
const estimator = new GasEstimator();
const fees = await estimator.estimate1559Fees(0.7); // 70% 利用率 = 轻度拥堵
const gasLimit = await estimator.estimateGasLimit(args);
// 根据 priority 调整小费
const multiplier = { low: 0.8, normal: 1.0, high: 2.0 }[args.priority || 'normal'];
return {
to: args.to,
data: args.data || '0x',
value: args.value || 0n,
maxFeePerGas: fees.maxFeePerGas * BigInt(Math.floor(multiplier * 100)) / 100n,
maxPriorityFeePerGas: fees.maxPriorityFeePerGas * BigInt(Math.floor(multiplier * 100)) / 100n,
gasLimit,
nonce: 42, // 实际需要 eth_getTransactionCount
chainId: 1,
};
}
console.log("Gas 估算器已定义");
14.3.2 交易生命周期
sequenceDiagram
participant User as 用户
participant DApp as 前端 DApp
participant Wallet as 钱包
participant RPC as RPC 节点
participant Mempool as Mempool
participant Builder as Block Builder
participant Chain as 链
User ->> DApp: 点击 "Swap"
DApp ->> DApp: 构造交易对象
DApp ->> Wallet: eth_sendTransaction
Wallet ->> Wallet: 用户确认 + 签名
Wallet -->> DApp: raw signed tx
DApp ->> RPC: eth_sendRawTransaction
RPC ->> Mempool: 加入 mempool
Mempool -->> RPC: tx hash
RPC -->> DApp: pending 回执
Builder ->> Mempool: SEAL 搜索 MEV
Builder ->> Builder: 构建区块
Builder ->> Chain: 打包入块
Chain -->> Builder: 区块收据
DApp ->> RPC: eth_getTransactionReceipt (轮询)
RPC -->> DApp: receipt (status, gas, logs)
DApp ->> User: 显示结果
Note over DApp: 乐观更新:先显示成功,<br/>等 receipt 最终确认
14.3.3 状态追踪与乐观更新
/**
* 交易状态追踪 + 乐观更新
*/
type TxStatus = "initiated" | "pending" | "success" | "reverted" | "dropped";
interface TransactionState {
hash: string;
status: TxStatus;
confirmations: number;
receipt?: {
gasUsed: bigint;
logs: any[];
status: string; // "0x1" = success, "0x0" = revert
};
}
class TransactionTracker {
private transactions: Map<string, TransactionState> = new Map();
private listeners: Set<(txs: TransactionState[]) => void> = new Set();
// 乐观更新:前端先假设成功
async optimisticallySend(
sendFn: () => Promise<string>, // 返回 txHash
preview: () => void, // 先更新 UI
): Promise<string> {
preview(); // 立即更新UI(乐观)
const hash = await sendFn(); // 但实际还要等待链上确认
this.transactions.set(hash, {
hash,
status: "pending",
confirmations: 0,
});
this.pollForConfirmation(hash);
return hash;
}
private async pollForConfirmation(hash: string) {
for (let i = 0; i < 60; i++) { // 最多等 5 分钟
await new Promise(r => setTimeout(r, 5000));
try {
const receipt = await this.fetchReceipt(hash);
if (receipt) {
const status: TxStatus = receipt.status === "0x1" ? "success" : "reverted";
this.transactions.set(hash, {
hash,
status,
confirmations: 1, // 后续可增加
receipt,
});
this.notifyListeners();
if (status === "reverted") {
// 回滚乐观更新
this.rollbackPreview(hash);
}
return;
}
} catch {
// 继续轮询
}
}
// 超时:标记为 dropped
this.transactions.set(hash, { hash, status: "dropped", confirmations: 0 });
}
private fetchReceipt(hash: string): Promise<any> {
// 实际调用 eth_getTransactionReceipt
return Promise.resolve({ gasUsed: 145000n, logs: [], status: "0x1" });
}
private rollbackPreview(hash: string) {
console.log("Transaction reverted, rolling back UI:", hash);
}
private notifyListeners() {
const txs = Array.from(this.transactions.values());
for (const cb of this.listeners) cb(txs);
}
subscribe(cb: (txs: TransactionState[]) => void) {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
}
// React 风格的自定义 hook(概念性展示)
function useSendTransaction() {
const [status, setStatus] = React.useState<TxStatus>("initiated");
async function send(tx: TransactionRequest) {
setStatus("pending");
try {
// 实际签名广播
const hash = "0x...";
setStatus("success");
return hash;
} catch (e) {
setStatus("dropped");
throw e;
}
}
return { send, status };
}
14.3.4 常见错误诊断
insufficient funds | 余额不足以支付 gas + value | 检查 ETH 余额 |
maxFeePerGas < baseFee | gas 价格设定低于网络 baseFee | 使用最新 baseFee 的 2 倍 |
nonce too high/low | nonce 不正确 | 顺序发送,或用 eth_getTransactionCount |
replacement fee too low | 加速旧交易时新交易小费不够高 | 小费至少增加 10% |
intrinsic gas too low | gas 限制 < 21,000 | 至少 21,000 基础费用 |
execution reverted | 合约拒绝(无 gas 问题) | 检查 revert reason 和参数 |
14.4 事件订阅与前端状态同步
合约不能主动"推送"数据到前端。但事件日志(Event Log)让前端可以通过轮询、WebSocket 或子图保持与合约状态的同步——正确的同步策略直接影响用户体验。
14.4.1 事件监听的三重境界
| WebSocket 订阅 | 实时 | 中 | 实时 DApp,高频更新 |
| The Graph 子图(Subgraph) | 5-30 秒 | 较高 | 需要复杂查询/聚合 |
graph TB
subgraph Polling["手动轮询"]
P1[setInterval 5s] --> P2[eth_call getBalance]
P2 --> P3["更新 React state"]
end
subgraph WebSocket["WebSocket"]
W1[eth_subscribe] --> W2[eth_newFilter]
W2 --> W3["推送事件到前端"]
W3 --> W4["通过 Redux/Zustand 分发"]
end
subgraph TheGraph["The Graph"]
T1["GraphQL 查询"] --> T2["索引节点聚合"]
T2 --> T3["返回结构化数据"]
end
Polling --"简单" --> WebSocket
WebSocket --"复杂查询" --> TheGraph
style Polling fill:#ffebee
style WebSocket fill:#e8f5e9
style TheGraph fill:#fff3e0
14.4.2 事件监听核心模式
/**
* 事件驱动的状态同步模式
*/
interface EthersContract {
on(event: string, handler: (...args: any[]) => void): void;
removeAllListeners(event?: string): void;
queryFilter(event: string, fromBlock: number, toBlock: number): Promise<any[]>;
}
class EventSyncManager {
private contract: EthersContract;
private lastBlock: number = 0;
private handlers: Map<string, Function[]> = new Map();
private active: boolean = true;
constructor(contract: EthersContract) {
this.contract = contract;
}
// 实时事件监听器
subscribe(eventName: string, handler: (event: any) => void) {
this.contract.on(eventName, (...args) => {
if (!this.active) return;
const parsed = this.parseEvent(eventName, args);
handler(parsed);
});
// 必须清理:避免内存泄漏
return () => this.contract.removeAllListeners(eventName);
}
// 历史回溯(当用户首次打开页面时)
async backfill(eventName: string, fromBlock: number): Promise<any[]> {
const events = await this.contract.queryFilter(eventName, fromBlock, 'latest');
return events.map(e => this.parseEvent(eventName, e.args));
}
// 组合:先回溯历史 + 再监听新事件
async sync(eventName: string, fromBlock: number, handler: (e: any) => void) {
const historical = await this.backfill(eventName, fromBlock);
for (const e of historical) handler(e);
const cleanup = this.subscribe(eventName, handler);
return cleanup;
}
private parseEvent(name: string, rawArgs: any[]): any {
return { name, args: rawArgs, timestamp: Date.now() };
}
// 组件卸载时清理!
destroy() {
this.active = false;
this.contract.removeAllListeners();
}
}
// React 键实践
function useContractSync(contract: EthersContract, eventName: string) {
const [events, setEvents] = React.useState<any[]>([]);
const manager = new EventSyncManager(contract);
React.useEffect(() => {
// 清理函数:组件卸载时取消所有监听
return manager.sync(eventName, 0, (e: any) => {
setEvents(prev => [...prev, e]);
});
}, [contract, eventName]);
return events;
}
14.4.3 The Graph 子图查询
/**
* GraphQL 查询:获取聚合数据而不手动同步
*/
interface SubgraphQuery {
// 查询 Aave 的所有借贷池活动和 TVL
query: string;
endpoint: string; // 如 https://api.thegraph.com/subgraphs/name/aave/protocol-v3
}
// 比手动解析事件更强大的查询能力
const AAVE_OVERVIEW_QUERY = `
{
reserves(first: 10, orderBy: totalATokenSupply, orderDirection: desc) {
id
name
symbol
decimals
totalATokenSupply
totalBorrowed
liquidityRate
variableBorrowRate
}
}
`;
// 对比:
// 手动监听 Pool.Borrow 事件 → 需要在前端做聚合和
// GraphQL 查询 → 索引器已做好聚合,前端直接消费
// 劣势:The Graph 的索引有 5-30s 延迟,不是实时的
三种策略的权衡矩阵
| 需要最新数据(< 5s) | 直接 eth_call 或 WebSocket |
| 需要历史聚合(过去 30 天 TVL) | The Graph 子图 |
14.5 去中心化存储:IPFS 与 Arweave
区块链存储 1GB 数据需要数百 ETH。元数据、图片、前端静态文件必须放在链下存储。IPFS(内容寻址,可能丢失)和 Arweave(一次付费,永久保存)是两大标准。
14.5.1 内容寻址 vs 位置寻址
传统 URL(HTTPS):通过位置访问。
https://somesite.com/image.jpg —— 文件在这个服务器的这个路径上,服务器关闭 = 文件消失。
IPFS:通过内容访问。
ipfs://Qmabc.../image.jpg —— 这个 CID(Content Identifier)永远指向这个内容,存储在哪台机器上无关紧要。
CID 的生成
CID=Multiformat(multihash(content))
/**
* IPFS 的内容寻址(简化模拟)
*/
function sha256(data: string): string {
// 简化哈希演示
let h = 0;
for (let i = 0; i < data.length; i++) {
h = ((h << 5) - h + data.charCodeAt(i)) | 0;
}
return h.toString(16).padStart(64, '0');
}
function computeCID(data: string): string {
const hash = sha256(data);
// IPFS 使用 CIDv1 多格式编码:
// 0x12 = sha2-256, 0x20 = 32 字节, 0x01 = CIDv1, 0x55 = raw
return "bafybei" + hash.slice(0, 44); // 简化 base32 前缀
}
// 关键特性:相同数据 => 相同 CID
const cid1 = computeCID("Hello World");
const cid2 = computeCID("Hello World");
console.log("CID 匹配:", cid1 === cid2, cid1);
// 即使文件名不同,内容一样,CID 也一样
const cid3 = computeCID("Hello World");
console.log("内容相同 CID 相同:", cid1 === cid3);
14.5.2 IPFS 的工作机制
graph LR
subgraph IPFS["IPFS 网络"]
A["上传文件"] --> B["分片: 256KB 块"]
B --> C["计算每个块 CID"]
C --> D["Merkle DAG 链接"]
D --> E["根 CID"]
E --> F["广播到 DHT 节点"]
end
User["用户"] --> |"GET /ipfs/<cid>"| Gateway["Gateway 节点"]
Gateway --> |DHT 查找| P1["节点 P1"] & P2["节点 P2"]
P1 --> |返回分片| Gateway
Gateway --> |重组文件| User
style IPFS fill:#e3f2fd
前端集成路径
| ------ | ------ | ------ | ------ | ------ |
| Pinata | 托管 pinning | 付费长期 | 1GB 免费 | NFT 元数据 |
| Web3.storage | IPFS + Filecoin | 协商 | 5GB 免费 | DApp 存储 |
| NFT.storage | IPFS + Filecoin | 永久(*) | 免费 | NFT 专属 |
| Infura IPFS | 节点托管 | 付费 | 5GB 免费 | 企业级 |
(*) NFT.storage 将文件同时 pin 到 IPFS 和 Filecoin 存储交易,理论上永久免费(受项目资金限制)。
前端上传示例
/**
* IPFS 上传(前端概念模拟)
*/
interface IPFSPinataResponse {
IpfsHash: string; // CID
PinSize: number; // 字节数
Timestamp: string;
}
async function uploadToIPFS(
apiKey: string,
secret: string,
file: File,
): Promise<string> {
const formData = new FormData();
formData.append("file", file);
// Pinata 的 pinning 服务
const res = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
method: "POST",
headers: {
Authorization: `Bearer ${await signJWT(apiKey, secret)}`,
},
body: formData,
});
const data: IPFSPinataResponse = await res.json();
// 返回的 CID 就是 URI: ipfs://{IpfsHash}
return `ipfs://${data.IpfsHash}`;
}
// 选择 Gateway
function resolveIPFSUri(uri: string): string {
if (uri.startsWith("ipfs://")) {
const cid = uri.replace("ipfs://", "");
// 选择可靠的 Gateway
return `https://cloudflare-ipfs.com/ipfs/${cid}`;
// 备选: https://ipfs.io/ipfs/${cid}
// 备选: https://gateway.pinata.cloud/ipfs/${cid}
}
return uri;
}
function signJWT(_apiKey: string, _secret: string): Promise<string> {
return Promise.resolve("mock-jwt-token");
}
console.log("IPFS URI 示例:", resolveIPFSUri("ipfs://Qmdemo.../metadata.json"));
14.5.3 Arweave:一次付费,永久存储
经济模型
| 对比 | 传统云 (AWS S3) | IPFS | Arweave |
| 成本 | 0.023/GB/月∣免费−10/GB | $1-5/GB 一次性 |
| 访问 | HTTP | ipfs:// 或 gateway | 专用网关 |
Permaweb:网页都存在 Arweave 上
Arweave 不仅存文件,还可以托管完整的前端应用(HTML/JS/CSS)。
/**
* Arweave 上传(概念模拟)
*/
interface ArweaveTransaction {
id: string; // 交易 ID 同时作为访问地址
data: string; // 文件内容(base64)
tags: { name: string; value: string }[]; // 元数据标签
reward: bigint; // 存储费用
signature?: string;
}
async function uploadToArweave(
fileContent: string,
fileType: string,
): Promise<string> {
const arweave = {
// ANS-110 标准标签
tags: [
{ name: "Content-Type", value: fileType },
{ name: "App-Name", value: "MyDApp" },
{ name: "App-Version", value: "1.0.0" },
],
// 费用 = 数据大小 × 当前 AR 价格 × 永久存储成本模型
async getPrice(dataSize: number): Promise<bigint> {
return BigInt(dataSize) * 10000n; // 简化
},
};
const price = await arweave.getPrice(fileContent.length);
console.log(`Storage cost: pricewinston({Number(price) / 1e12} AR)`);
// 签名并提交到 Arweave 网络
const txId = "abc123...";
return `https://arweave.net/${txId}`; // 永久可访问
}
// NFT 元数据标准:指向 Arweave 永久 URI
const artMetadata = {
name: "Digital Art #1",
description: "...",
image: "https://arweave.net/txId_of_image", // 永久图片
animation_url: "https://arweave.net/txId_of_glb", // 永久 3D 模型
};
console.log("Arweave: 存一次,永远可用");
14.5.4 前端去中心化存储的实战建议
| NFT 小图片 (< 10MB) | IPFS (Pinata / Web3.storage) | ipfs://<cid> |
| NFT 大图/视频 | Arweave (Irys 代付) | https://arweave.net/<txId> |
| DApp 前端托管 | Arweave Permaweb | https://arweave.net/<txId> |
| 链上元数据 (base64) | data URI | data:application/json;base64,... |
| 临时测试 | Pinata 免费层 | ipfs://<cid> |
graph LR
subgraph Storage["存储方案决策树"]
Q1{"数据大小?"}
Q2{"持久性要求?"}
Q3{"预算?"}
end
Q1 --> |< 1MB| Small[IPFS free]
Q1 --> |> 10MB| Large["Arweave 或 Filecoin"]
Q2 --> |临时| Temp["IPFS, 自行 pin"]
Q2 --> |永久| Perm[Arweave / NFT.storage]
Q3 --> |零预算| Free[NFT.storage / Web3.storage]
Q3 --> |可付费| Paid[Pinata / Infura]
style Storage fill:#e3f2fd
14.6 多链 DApp 与跨链桥:前端如何跨越多重宇宙
2024 年,活跃区块链超过 100 条。用户资产分散在以太坊(主网)、Arbitrum(便宜)、Base(社交)、Solana(高性能)。多链 DApp 不是"可选项",是默认配置。
14.6.1 链切换与链无关设计
用户看到的 vs 前端看到的
graph LR
subgraph UX["用户视角"]
U1["使用 DApp"]
U2["需要跨链(如 Arbitrum → Base)]"
U3["期待\"品牌感知\""]
end
subgraph Internal["前端实现"]
I1["检测当前链"]
I2["调用 wallet_switchEthereumChain"]
I3["重新初始化 provider"]
I4["加载该链合约地址映射"]
I5["查询该链状态"]
end
U1 --> I1
U2 --> I2 --> I3 --> I4 --> I5
style UX fill:#e8f5e9
style Internal fill:#e3f2fd
链配置管理
/**
* 多链配置管理(TypeScript 骨架)
*/
interface ChainConfig {
id: number;
name: string;
rpcUrl: string;
currency: { name: string; symbol: string; decimals: number };
explorer: string;
contracts: { [name: string]: string }; // 已部署合约地址映射
isTestnet: boolean;
}
const chains: Record<string, ChainConfig> = {
mainnet: {
id: 1,
name: "Ethereum",
rpcUrl: "https://eth-mainnet.g.alchemy.com/v2/...",
currency: { name: "Ether", symbol: "ETH", decimals: 18 },
explorer: "https://etherscan.io",
contracts: {
token: "0x6B1754...", // DAI on mainnet
aavePool: "0x87870B...",
},
isTestnet: false,
},
arbitrum: {
id: 42161,
name: "Arbitrum One",
rpcUrl: "https://arb-mainnet.g.alchemy.com/v2/...",
currency: { name: "Ether", symbol: "ETH", decimals: 18 },
explorer: "https://arbiscan.io",
contracts: {
token: "0xDA1000...", // DAI on Arbitrum
aavePool: "0x794a61...",
},
isTestnet: false,
},
base: {
id: 8453,
name: "Base",
rpcUrl: "https://base-mainnet.g.alchemy.com/v2/...",
currency: { name: "Ether", symbol: "ETH", decimals: 18 },
explorer: "https://basescan.org",
contracts: {
token: "0x50c572...", // DAI on Base
},
isTestnet: false,
},
sepolia: {
id: 11155111,
name: "Sepolia",
rpcUrl: "https://eth-sepolia.g.alchemy.com/v2/...",
currency: { name: "SepoliaETH", symbol: "ETH", decimals: 18 },
explorer: "https://sepolia.etherscan.io",
contracts: {
token: "0x123abc...",
},
isTestnet: true,
},
};
// 切换链
async function switchChain(targetChainId: number, wallet: any) {
try {
await wallet.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: "0x" + targetChainId.toString(16) }], // 0x1, 0xa4b1 (42161)
});
} catch (error: any) {
// 如果链未安装,需要先添加
if (error.code === 4902) {
const chain = Object.values(chains).find(c => c.id === targetChainId);
await wallet.request({
method: "wallet_addEthereumChain",
params: [{
chainId: "0x" + targetChainId.toString(16),
chainName: chain?.name,
rpcUrls: [chain?.rpcUrl],
nativeCurrency: chain?.currency,
blockExplorerUrls: [chain?.explorer],
}],
});
}
}
}
console.log("多链配置已定义");
14.6.2 跨链桥的原理
跨链桥不是把 ETH"移动"到另一链——原生资产不能离开其原生链。跨链桥通过锁定 + 铸造机制解决:
sequenceDiagram
participant User as 用户
participant Bridge as 跨链桥合约
participant Oracle as 验证网络 / 预言机
participant Target as 目标链合约
User ->> Bridge: 锁定 100 ETH (源链)
Bridge ->> Bridge: 冻结 100 ETH 在合约中
Bridge -->> Oracle: 证明事件(Merkle proof / 多重签名)
Oracle -->> Target: 验证 + 签名
Target ->> Target: 铸造 100 wETH (目标链)
Target -->> User: 在目标链上使用影子资产
Note over Target: "wETH" 是"包装 ETH",<br/>不是原生 ETH!<br/>赎回时销毁 wETH → 释放锁定的原生 ETH
主要跨链桥方案
| ------ | ------ | ------ | ------ | ------ |
| Stargate (LayerZero) | 统一流动性池 | 中 | 极快 | 低 |
| 官方桥(如 Arbitrum Bridge) | 欺诈证明 / 有效性证明 | 高 | 慢(天级退出) | 高(L1 gas) |
14.6.3 前端跨链交互模式
状态等待 UX
跨链交易需要时间:消息从源链传播到目标链可能是几秒到几十分钟。前端 UX 必须管理这个等待状态。
/**
* 跨链交易状态追踪
*/
interface CrossChainTx {
id: string;
sourceChain: number; // 源链 ID
targetChain: number; // 目标链 ID
status: "sending" | "waiting_attestation" | "delivered" | "completed" | "failed";
sourceTxHash: string; // 源链交易哈希
targetTxHash?: string; // 目标链执行哈希(有延迟)
estimatedTime: number; // 秒
progress: number; // 0-100%
}
class CrossChainTracker {
private txs: Map<string, CrossChainTx> = new Map();
private listeners = new Set<(tx: CrossChainTx) => void>();
// 用户发起桥接
async initiateBridge(from: number, to: number, amount: bigint, token: string): Promise<string> {
const txId = crypto.randomUUID(); // 简化 ID
this.txs.set(txId, {
id: txId,
sourceChain: from,
targetChain: to,
status: "sending",
sourceTxHash: "",
estimatedTime: 120, // 2 分钟估计
progress: 0,
});
// 简化流程模拟
setTimeout(() => this.updateStatus(txId, "waiting_attestation", 10), 2000);
setTimeout(() => this.updateStatus(txId, "delivered", 80), 60000);
setTimeout(() => this.updateStatus(txId, "completed", 100), 120000);
return txId;
}
private updateStatus(id: string, status: CrossChainTx["status"], progress: number) {
const tx = this.txs.get(id);
if (!tx) return;
tx.status = status;
tx.progress = progress;
for (const cb of this.listeners) cb({ ...tx });
}
subscribe(txId: string, cb: (tx: CrossChainTx) => void) {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
getStatusText(tx: CrossChainTx): string {
const map: Record<string, string> = {
sending: "正在发送源链交易...",
waiting_attestation: "等待目标链验证...",
delivered: "已到达目标链,确认中...",
completed: "✅ 完成!",
failed: "❌ 失败",
};
return map[tx.status];
}
}
// LayerZero SDK 风格的调用层
interface LZSendParam {
dstEid: number; // 目标端点 ID (Endpoint ID)
to: string; // 目标地址
amount: bigint;
minAmount: bigint; // 防止滑点
extraOptions: string; // 额外选项(gas 空投等)
composeMsg: string; // 可携带额外消息
oftCmd: string; // 命令
}
// 现代跨链桥 SDK 简化高层调用:
// send(srcChain, dstChain, token, amount, recipient)
// 抽象掉 Wormhole VAA / LayerZero 消息包 / 中继器验证 等底层
console.log("跨链架构:锁定-证明-铸造/释放");
14.6.4 桥的风险
跨链桥是历史漏洞重灾区——锁定的巨大 TVL 使其成为攻击首选:
| Wormhole(2022) | $320M | 验证者签名被绕过 |
| Multichain(2023) | $126M | 多签私钥控制问题 |
启示:跨链桥选择时,验证机制的去中心化程度比"品牌知名度"更重要。
第14章 总结:从合约到用户
三个核心结论
- 前端是用户与协议的"翻译层":ethers.js v6 和 Viem 两种风格各有利弊,但 Viem 在类型安全、包体积和现代最佳实践上更适合新项目建立工程规范。
- 钱包连接正在经历范式转移:EIP-6963 终结了
window.ethereum 的混乱,嵌入式钱包和 ERC-4337 智能账户让 Web3 入门门槛趋近于 Web2。
- 多链是默认,跨链是必需:100+ 活跃链的现实中,前端必须管理链切换、跨链桥状态等待和合约地址映射——这不再是"高级功能"。
前端技术栈速查表
| 连接层 | wagmi + viem | web3-react |
| 钱包发现 | EIP-6963 原生 | RainbowKit (UI 包装) |
| 状态管理 | WAGMI hooks | 自建 Zustand/Redux |
| 跨链 | LayerZero / Wormhole SDK | 各桥官方 SDK |
UX 审计清单
- [ ] 连接前显示正确的网络状态
- [ ] 交易发送后显示 "pending" 状态
- [ ] 乐观更新:先显示预期结果,再等待确认
- [ ] 失败时显示可读的 revert 原因
- [ ] 多链场景下链切换提示清晰
- [ ] 跨链桥显示进度条和估计时间
桥梁:下一步学什么?
| 企业联盟链 | 第15章 Hyperledger Fabric |
评论
0评论加载中…