教程区块链区块链技术第14章 前端开发

本页目录

从合约到用户: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 v5ethers.js v6Viem
------------------------
包体积130 KB110 KB21 KB (gzip)
类型安全更好内置严格类型
树摇优化部分更好完美
BigInt混合 (BN.js)原生 BigInt原生 BigInt
Provider/Separation清晰简化 one-shot calls
错误处理基础更好详尽的错误类型
EIP-1559 支持内置内置

14.1.2 ethers.js v6 核心模式

typescript

/**

 * 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:类型原生的极简设计

typescript

/**

 * 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 gas
  • UserRejectedRequestError 而非 Error: user rejected
  • ContractFunctionExecutionError 包含 decodedArgs

14.1.4 前端库选型建议

场景选择
------------
新项目Viem — 更小、更快、更安全
ethers 遗产ethers v6 — 迁移成本低
需要最新功能Viem — 积极维护、新 EIP 支持快
多项目复用统一选 Viem,减少认知负担


14.2 钱包连接:MetaMask、WalletConnect、EIP-6963

钱包是用户进入 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

/**

 * 钱包连接的 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

typescript

/**

 * 账户抽象(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\text{totalFeePerGas} = \text{baseFee} + \text{priorityFee}
typescript

/**

 * 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 状态追踪与乐观更新

typescript

/**

 * 交易状态追踪 + 乐观更新

 */

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 < baseFeegas 价格设定低于网络 baseFee使用最新 baseFee 的 2 倍
nonce too high/lownonce 不正确顺序发送,或用 eth_getTransactionCount
replacement fee too low加速旧交易时新交易小费不够高小费至少增加 10%
intrinsic gas too lowgas 限制 < 21,000至少 21,000 基础费用
execution reverted合约拒绝(无 gas 问题)检查 revert reason 和参数


14.4 事件订阅与前端状态同步

合约不能主动"推送"数据到前端。但事件日志(Event Log)让前端可以通过轮询、WebSocket 或子图保持与合约状态的同步——正确的同步策略直接影响用户体验。


14.4.1 事件监听的三重境界

策略延迟复杂度适用场景
------------------------
手动轮询5-15 秒简单页面,低并发
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 事件监听核心模式

typescript

/**

 * 事件驱动的状态同步模式

 */

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 子图查询

typescript

/**

 * 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 子图
需要复杂筛选子图 GraphQL
需要不依赖中心化索引自建 RPC 轮询
最小化代码复杂度eth_subscribe


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))\text{CID} = \text{Multiformat}(\text{multihash}(\text{content}))
typescript

/**

 * 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.storageIPFS + Filecoin协商5GB 免费DApp 存储
NFT.storageIPFS + Filecoin永久(*)免费NFT 专属
Infura IPFS节点托管付费5GB 免费企业级
公共 Gateway只读不保证免费最简访问

(*) NFT.storage 将文件同时 pin 到 IPFS 和 Filecoin 存储交易,理论上永久免费(受项目资金限制)。

前端上传示例

typescript

/**

 * 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)IPFSArweave
------------------------
支付订阅/月付免费/按 pin一次性
持久化只要付费就存在只要有人 pin 就存在永久
成本0.023/GB/免费0.023/GB/月 | 免费-10/GB$1-5/GB 一次性
访问HTTPipfs:// 或 gateway专用网关
元数据标准ANS-110 等

Permaweb:网页都存在 Arweave 上

Arweave 不仅存文件,还可以托管完整的前端应用(HTML/JS/CSS)。

typescript

/**

 * 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({price} winston ({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 前端去中心化存储的实战建议

场景推荐方案URI 格式
------------------
NFT 小图片 (< 10MB)IPFS (Pinata / Web3.storage)ipfs://<cid>
NFT 大图/视频Arweave (Irys 代付)https://arweave.net/<txId>
DApp 前端托管Arweave Permawebhttps://arweave.net/<txId>
链上元数据 (base64)data URIdata: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

/**

 * 多链配置管理(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

主要跨链桥方案

技术去中心化程度速度费用
------------------------------
Wormhole多重签名验证者
LayerZero预言机 + 中继器极快
Stargate (LayerZero)统一流动性池极快
Hop Protocol流动性网络较高分钟级
AcrossUMA 乐观验证
官方桥(如 Arbitrum Bridge)欺诈证明 / 有效性证明慢(天级退出)高(L1 gas)

14.6.3 前端跨链交互模式

状态等待 UX

跨链交易需要时间:消息从源链传播到目标链可能是几秒到几十分钟。前端 UX 必须管理这个等待状态。

typescript

/**

 * 跨链交易状态追踪

 */

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验证者签名被绕过
Ronin(2022)$622M私钥泄露(社工)
Nomad(2022)$190M初始化错误
Multichain(2023)$126M多签私钥控制问题

启示:跨链桥选择时,验证机制的去中心化程度比"品牌知名度"更重要。



第14章 总结:从合约到用户

三个核心结论

  • 前端是用户与协议的"翻译层":ethers.js v6 和 Viem 两种风格各有利弊,但 Viem 在类型安全、包体积和现代最佳实践上更适合新项目建立工程规范。
  • 钱包连接正在经历范式转移:EIP-6963 终结了 window.ethereum 的混乱,嵌入式钱包和 ERC-4337 智能账户让 Web3 入门门槛趋近于 Web2。
  • 多链是默认,跨链是必需:100+ 活跃链的现实中,前端必须管理链切换、跨链桥状态等待和合约地址映射——这不再是"高级功能"。

前端技术栈速查表

层级推荐工具备选
------------------
链交互Viemethers.js v6
连接层wagmi + viemweb3-react
钱包发现EIP-6963 原生RainbowKit (UI 包装)
状态管理WAGMI hooks自建 Zustand/Redux
查询The Graph 子图自建事件索引
存储IPFS (Pinata)Arweave
跨链LayerZero / Wormhole SDK各桥官方 SDK

UX 审计清单

  • [ ] 连接前显示正确的网络状态
  • [ ] 交易发送后显示 "pending" 状态
  • [ ] 乐观更新:先显示预期结果,再等待确认
  • [ ] 失败时显示可读的 revert 原因
  • [ ] 多链场景下链切换提示清晰
  • [ ] 跨链桥显示进度条和估计时间

桥梁:下一步学什么?

兴趣方向章节
------------
企业联盟链第15章 Hyperledger Fabric
自己造链第16章 迷你链
实战项目第17-19章

评论

0

评论加载中…

发表评论

0/2000