普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月19日首页

从“连接失败”到丝滑登录:我用 ethers.js 连接 MetaMask 的完整踩坑实录

作者 竹林818
2026年4月19日 10:01

背景

上个月,我接手了一个新的 DeFi 项目前端开发。第一个核心功能就是用户钱包连接。团队技术栈是 React + TypeScript,对于 Web3 交互,我们选择了老牌且功能强大的 ethers.js 库。我心想:“连接 MetaMask 嘛,官方文档例子那么多,还不是手到擒来?” 于是,我复制了一段最常见的示例代码,准备十分钟搞定这个功能。然而,现实很快给了我一个教训——从“连接”到“稳定可用”之间,隔着一整条满是坑的沟。

问题分析

我最开始的思路非常简单:检查 window.ethereum 是否存在,如果存在,就用 ethers.providers.Web3Provider 包装它,然后调用 provider.send('eth_requestAccounts', []) 来触发 MetaMask 的授权弹窗。代码跑起来,在已经安装了 MetaMask 的浏览器里,第一次点击确实弹窗了,连接成功了。

但问题接踵而至:

  1. 刷新页面后,连接状态丢失:用户需要重新点击连接并授权,体验极差。
  2. 用户切换了 MetaMask 账户:前端界面上的地址没有自动更新。
  3. 用户切换了网络(比如从以太坊主网切换到 Goerli 测试网):我们的 DApp 需要感知到这个变化,并可能提示用户切换回目标网络。
  4. 用户根本没有安装 MetaMask:页面直接报错,白屏。

最初的代码只处理了“发起连接”这个单一动作,完全没考虑 Web3 应用是“动态的”、“有状态的”。我意识到,我需要构建的不是一个“连接按钮”,而是一个完整的“钱包连接状态管理器”。它需要监听区块链提供者的各种事件,并将状态同步到 React 组件中。

核心实现

第一步:封装一个健壮的钱包连接钩子

我决定创建一个自定义 React Hook useWallet 来集中管理所有钱包状态。首先,要安全地获取 window.ethereum 对象。这里就有第一个坑:TypeScript 不知道 window.ethereum 的类型

// types/global.d.ts
interface Window {
  ethereum?: any; // 为了快速开发,可以先设为 any,更严谨的做法是导入 MetaMask 的 EIP-1193 类型
}

// hooks/useWallet.ts
import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

declare global {
  interface Window {
    ethereum?: any;
  }
}

export const useWallet = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  // 初始化:检查是否已授权
  useEffect(() => {
    if (!window.ethereum) {
      setError('请安装 MetaMask 钱包扩展');
      return;
    }
    const initProvider = new ethers.providers.Web3Provider(window.ethereum, 'any'); // 'any' 允许任何网络
    setProvider(initProvider);

    // 尝试获取已连接的账户
    initProvider.listAccounts().then((accounts) => {
      if (accounts.length > 0) {
        setAccount(accounts[0]);
        setSigner(initProvider.getSigner());
      }
    });

    // 获取当前网络链 ID
    initProvider.getNetwork().then((network) => {
      setChainId(network.chainId);
    });
  }, []);
}

注意new Web3Provider(window.ethereum, 'any') 中的 'any' 参数很重要,它告诉 ethers 我们接受任何网络,这样在监听网络切换时不会抛出错误。

第二步:实现连接与断开连接

连接函数需要处理用户交互和可能的拒绝。

// 在 useWallet 钩子内
const connectWallet = useCallback(async () => {
  if (!provider) {
    setError('Provider 未初始化');
    return;
  }
  setIsConnecting(true);
  setError('');
  try {
    // 这会触发 MetaMask 弹窗
    const accounts = await provider.send('eth_requestAccounts', []);
    const currentAccount = accounts[0];
    setAccount(currentAccount);
    setSigner(provider.getSigner());
    // 连接成功后,再获取一次最新的网络信息
    const network = await provider.getNetwork();
    setChainId(network.chainId);
  } catch (err: any) {
    console.error('连接钱包失败:', err);
    // 用户拒绝连接是最常见的错误
    setError(err.code === 4001 ? '用户拒绝了连接请求' : `连接失败: ${err.message}`);
  } finally {
    setIsConnecting(false);
  }
}, [provider]);

const disconnectWallet = useCallback(() => {
  // 注意:MetaMask 没有真正的“断开连接”API,这里只是清除本地状态
  setAccount('');
  setSigner(null);
  setChainId(0);
  // 在实际项目中,你可能还需要清除相关的应用状态(如用户余额、NFT等)
}, []);

这里有个坑disconnectWallet 并不能让 MetaMask 忘记你的网站授权。真正的“断开”需要用户在 MetaMask 界面手动操作。我们只是在前端清除了状态。

第三步:监听账户与网络变化

这是实现“状态同步”的核心。我们需要监听 window.ethereum 发出的事件。

// 在 useWallet 钩子的 useEffect 中,初始化之后
useEffect(() => {
  if (!window.ethereum) return;

  // 监听账户变更
  const handleAccountsChanged = (accounts: string[]) => {
    console.log('accountsChanged', accounts);
    if (accounts.length === 0) {
      // MetaMask 被锁定或用户主动断开连接了所有账户
      disconnectWallet();
    } else if (accounts[0] !== account) {
      // 用户切换了账户
      setAccount(accounts[0]);
      if (provider) {
        setSigner(provider.getSigner());
      }
    }
  };

  // 监听链 ID 变更(网络切换)
  const handleChainChanged = (_chainId: string) => {
    // 注意:MetaMask 文档建议在链变更时刷新页面,但现代 DApp 通常不这样做
    // 我们只是更新 chainId 状态,组件可以根据新 chainId 做出反应(如提示切换网络)
    console.log('chainChanged', _chainId);
    // chainId 是十六进制字符串,需要转换
    setChainId(parseInt(_chainId, 16));
    // 网络变了,provider 和 signer 实例其实还能用,但某些场景可能需要重置
    if (provider) {
      provider.getNetwork().then(network => setChainId(network.chainId));
    }
  };

  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  // 组件卸载时清除监听
  return () => {
    window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
    window.ethereum?.removeListener('chainChanged', handleChainChanged);
  };
}, [provider, account, disconnectWallet]); // 依赖项要小心,避免重复绑定

关键细节chainChanged 事件回调的参数是十六进制字符串,而 etherschainId 是数字,需要转换。另外,监听器一定要在组件卸载时移除,防止内存泄漏。

第四步:在组件中使用并处理网络不匹配

最后,在组件中集成这个 Hook,并处理一个常见业务逻辑:如果用户不在我们支持的网络上,提示他切换。

// components/WalletConnector.tsx
import React from 'react';
import { useWallet } from '../hooks/useWallet';
import { shortenAddress } from '../utils/address'; // 一个格式化地址的辅助函数

const SUPPORTED_CHAIN_ID = 1; // 假设我们只支持以太坊主网

export const WalletConnector: React.FC = () => {
  const {
    account,
    chainId,
    isConnecting,
    error,
    connectWallet,
    disconnectWallet,
  } = useWallet();

  const isOnSupportedNetwork = chainId === SUPPORTED_CHAIN_ID;

  const handleSwitchNetwork = async () => {
    if (!window.ethereum) return;
    try {
      // 尝试切换到以太坊主网
      await window.ethereum.request({
        method: 'wallet_switchEthereumChain',
        params: [{ chainId: '0x1' }], // 主网的十六进制链ID
      });
    } catch (switchError: any) {
      // 如果用户没有添加该网络,可以尝试添加它
      if (switchError.code === 4902) {
        try {
          await window.ethereum.request({
            method: 'wallet_addEthereumChain',
            params: [{
              chainId: '0x1',
              chainName: 'Ethereum Mainnet',
              nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
              rpcUrls: ['https://mainnet.infura.io/v3/YOUR_INFURA_KEY'],
              blockExplorerUrls: ['https://etherscan.io'],
            }],
          });
        } catch (addError) {
          console.error('添加网络失败', addError);
        }
      }
      console.error('切换网络失败', switchError);
    }
  };

  if (error && !window.ethereum) {
    return <div className="error">未检测到钱包,请安装 MetaMask。</div>;
  }

  return (
    <div className="wallet-connector">
      {!account ? (
        <button onClick={connectWallet} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接钱包'}
        </button>
      ) : (
        <div className="wallet-info">
          {!isOnSupportedNetwork && (
            <div className="network-warning">
              当前网络不受支持。
              <button onClick={handleSwitchNetwork}>切换到主网</button>
            </div>
          )}
          <span className="address">{shortenAddress(account)}</span>
          <button onClick={disconnectWallet} className="disconnect-btn">
            断开
          </button>
        </div>
      )}
      {error && <div className="error">{error}</div>}
    </div>
  );
};

完整代码

考虑到篇幅,这里提供一个整合后的 hooks/useWallet.ts 核心代码概览,以及一个简单的 utils/address.ts

// hooks/useWallet.ts
import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

declare global {
  interface Window {
    ethereum?: any;
  }
}

export const useWallet = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  const disconnectWallet = useCallback(() => {
    setAccount('');
    setSigner(null);
    setChainId(0);
  }, []);

  const connectWallet = useCallback(async () => {
    if (!provider) {
      setError('Provider 未初始化');
      return;
    }
    setIsConnecting(true);
    setError('');
    try {
      const accounts = await provider.send('eth_requestAccounts', []);
      const currentAccount = accounts[0];
      setAccount(currentAccount);
      setSigner(provider.getSigner());
      const network = await provider.getNetwork();
      setChainId(network.chainId);
    } catch (err: any) {
      console.error('连接钱包失败:', err);
      setError(err.code === 4001 ? '用户拒绝了连接请求' : `连接失败: ${err.message}`);
    } finally {
      setIsConnecting(false);
    }
  }, [provider]);

  useEffect(() => {
    if (!window.ethereum) {
      setError('请安装 MetaMask 钱包扩展');
      return;
    }
    const initProvider = new ethers.providers.Web3Provider(window.ethereum, 'any');
    setProvider(initProvider);

    initProvider.listAccounts().then((accounts) => {
      if (accounts.length > 0) {
        setAccount(accounts[0]);
        setSigner(initProvider.getSigner());
      }
    });

    initProvider.getNetwork().then((network) => {
      setChainId(network.chainId);
    });

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        disconnectWallet();
      } else if (accounts[0] !== account) {
        setAccount(accounts[0]);
        if (initProvider) {
          setSigner(initProvider.getSigner());
        }
      }
    };

    const handleChainChanged = (_chainId: string) => {
      setChainId(parseInt(_chainId, 16));
    };

    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);

    return () => {
      window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum?.removeListener('chainChanged', handleChainChanged);
    };
  }, [disconnectWallet]); // 注意依赖,这里只依赖了稳定的 disconnectWallet

  return {
    provider,
    signer,
    account,
    chainId,
    isConnecting,
    error,
    connectWallet,
    disconnectWallet,
  };
};

// utils/address.ts
export const shortenAddress = (address: string, chars = 4): string => {
  if (!address) return '';
  return `${address.substring(0, chars + 2)}...${address.substring(42 - chars)}`;
};

踩坑记录

  1. Provider 未初始化错误:在 connectWallet 函数中直接使用 provider,但 provider 的初始化在 useEffect 中,是异步的。在用户快速点击连接按钮时,provider 可能还是 null解决:在函数开始处增加 if (!provider) return; 的判断。
  2. 重复监听事件导致内存泄漏:最初我把事件监听写在了一个没有依赖数组的 useEffect 里,导致组件每次渲染都绑定新监听器,旧监听器未移除。解决:确保 useEffect 有正确的依赖数组,并在清理函数中 removeListener
  3. 网络切换后 signer 失效的错觉:用户切换网络后,我最初错误地认为需要重新创建 providersigner。实际上,ethersWeb3Provider 实例在传入 'any' 参数后,可以跨网络工作,signer 仍然有效。需要更新的只是 chainId 状态。解决:在 handleChainChanged 中只更新 chainId,除非有特定业务需求,否则不重置 provider/signer
  4. chainId 类型不一致ethersgetNetwork() 返回的 chainIdnumber,而 window.ethereumchainChanged 事件返回的是十六进制 string。直接比较会导致判断失败。解决:统一转换为数字类型再比较,使用 parseInt(_chainId, 16)

小结

通过这次实践,我深刻体会到 Web3 前端连接钱包不仅仅是弹出一个授权窗口,更是一个需要持续监听和同步外部状态(账户、网络)的复杂功能。封装一个自定义 Hook 来集中管理这些状态和副作用,是让代码保持清晰、可维护的关键。下一步,可以在此基础上集成钱包余额查询、交易发送监听、以及多钱包提供商(如 WalletConnect)的支持。

昨天以前首页

Solana前端开发:从连接钱包到发送交易,我如何用@solana/web3.js搞定第一个DApp

作者 竹林818
2026年4月18日 10:02

背景

上个月,团队接了一个Solana生态的NFT项目,需要开发一个允许用户连接钱包、查看余额并铸造NFT的前端界面。作为一个在以太坊和EVM兼容链上摸爬滚打了五年的前端,我的工具箱里装满了ethers.jsviemwagmi。当任务切换到Solana时,我意识到得从头学起。核心的挑战很明确:我需要快速掌握@solana/web3.js这个官方SDK,用它来实现钱包连接、读取链上数据和发送交易这些基础但至关重要的功能。一开始我以为这和以太坊开发大同小异,结果一脚踩进了好几个坑里。

问题分析

我的第一反应是去翻@solana/web3.js的官方文档和示例。文档结构清晰,但当我试图把文档里的代码片段拼凑成一个完整的React应用时,问题来了。首先,钱包连接逻辑和以太坊的window.ethereum完全不同,Solana主流钱包如Phantom将接口注入到window.solana。其次,账户模型差异巨大:Solana使用公钥(PublicKey)作为地址,交易需要“最近区块哈希”和“手续费支付者”等概念,这让我一开始构建交易时屡屡失败。最初的几次尝试,不是钱包弹不出连接框,就是交易签名后发送失败,控制台报错信息又比较晦涩。我意识到,不能只是机械地复制代码,必须理解Solana交易构建的基本流程。

核心实现

1. 环境搭建与钱包连接

首先,我创建了一个新的React + TypeScript项目,并安装核心依赖:

npm install @solana/web3.js @solana/wallet-adapter-base @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets

这里有个关键点:单纯用@solana/web3.js也能连接钱包,但社区更推荐使用@solana/wallet-adapter-*这一套工具库,它封装了连接逻辑和UI组件,能省不少事。

接下来,我设置钱包上下文。这是整个应用能调用钱包功能的基础:

// App.tsx
import React, { useMemo } from 'react';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter } from '@solana/wallet-adapter-wallets';
import { clusterApiUrl } from '@solana/web3.js';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { MyComponent } from './MyComponent';

// 导入默认样式
import '@solana/wallet-adapter-react-ui/styles.css';

function App() {
  // 配置网络。开发时通常用devnet或testnet,这里用devnet
  const network = WalletAdapterNetwork.Devnet;
  const endpoint = useMemo(() => clusterApiUrl(network), [network]);

  // 配置支持的钱包列表
  const wallets = useMemo(
    () => [
      new PhantomWalletAdapter(),
      // 可以继续添加其他钱包适配器,如Solflare
    ],
    []
  );

  return (
    <ConnectionProvider endpoint={endpoint}>
      <WalletProvider wallets={wallets} autoConnect>
        <WalletModalProvider>
          <MyComponent />
        </WalletModalProvider>
      </WalletProvider>
    </ConnectionProvider>
  );
}

export default App;

注意这个细节ConnectionProviderendpoint参数是必须的,它指定了你的应用要连接哪个Solana集群(主网、测试网等)。autoConnect属性会在页面加载时尝试重新连接上次的钱包,提升用户体验。

2. 获取钱包地址与余额

在子组件MyComponent中,我使用适配器提供的钩子来获取钱包状态和连接信息。

// MyComponent.tsx
import React, { useState, useEffect } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { LAMPORTS_PER_SOL } from '@solana/web3.js';

export const MyComponent: React.FC = () => {
  const { connection } = useConnection();
  const { publicKey, connected } = useWallet();
  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);

  // 当钱包连接状态或公钥变化时,获取余额
  useEffect(() => {
    const fetchBalance = async () => {
      if (connected && publicKey) {
        setLoading(true);
        try {
          // 注意:getBalance返回的是lamports,1 SOL = 10^9 lamports
          const lamportsBalance = await connection.getBalance(publicKey);
          setBalance(lamportsBalance / LAMPORTS_PER_SOL); // 转换为SOL单位
        } catch (error) {
          console.error('获取余额失败:', error);
          setBalance(null);
        } finally {
          setLoading(false);
        }
      } else {
        setBalance(null);
      }
    };

    fetchBalance();
  }, [connection, publicKey, connected]);

  return (
    <div>
      <p>钱包状态: {connected ? '已连接' : '未连接'}</p>
      {publicKey && <p>钱包地址: {publicKey.toBase58()}</p>}
      {loading && <p>查询余额中...</p>}
      {balance !== null && !loading && <p>余额: {balance} SOL</p>}
    </div>
  );
};

这里有个坑connection.getBalance()返回的单位是lamports,而不是SOL。直接显示这个数字会非常大,必须除以LAMPORTS_PER_SOL(10^9)来转换。我一开始没注意,显示了一个9位数的“余额”,闹了笑话。

3. 构建并发送一笔SOL转账交易

这是最核心也最容易出错的部分。在Solana上,一笔交易可以包含多个指令,我们需要构建一个“系统程序”的转账指令。

// 在MyComponent.tsx中添加发送交易函数
import { SystemProgram, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';

const sendTransaction = async () => {
  // 1. 基础校验
  if (!publicKey || !connected) {
    alert('请先连接钱包');
    return;
  }
  if (!connection) {
    alert('连接异常');
    return;
  }

  // 2. 构建交易指令
  // 假设我们向这个地址转账0.01 SOL
  const toPublicKey = new PublicKey('接收方的Solana地址(Base58格式)');
  const transferAmount = 0.01; // SOL
  const lamportsToSend = transferAmount * LAMPORTS_PER_SOL;

  const transferInstruction = SystemProgram.transfer({
    fromPubkey: publicKey,
    toPubkey: toPublicKey,
    lamports: lamportsToSend,
  });

  // 3. 创建交易并添加指令
  const transaction = new Transaction().add(transferInstruction);

  // 4. 获取“最近区块哈希”(Recent Blockhash)——这是Solana交易必需的
  let blockhash;
  try {
    const { blockhash: recentBlockhash } = await connection.getLatestBlockhash();
    blockhash = recentBlockhash;
    transaction.recentBlockhash = blockhash;
    // 5. 设置交易的费用支付者(Fee Payer)
    transaction.feePayer = publicKey;
  } catch (error) {
    console.error('获取区块哈希失败:', error);
    alert('获取网络信息失败,请重试');
    return;
  }

  // 6. 请求钱包签名并发送
  try {
    // 这里使用了wallet-adapter的signTransaction方法
    // 注意:在真实场景中,我们通常使用wallet-adapter提供的sendTransaction方法,它内部处理了签名和发送。
    // 但为了演示底层过程,这里先展示需要手动签名的流程,后面会给出更优方案。
    const signedTransaction = await signTransaction(transaction); // 假设signTransaction来自useWallet
    const signature = await connection.sendRawTransaction(signedTransaction.serialize());
    console.log('交易已发送,签名:', signature);

    // 7. 确认交易
    const confirmation = await connection.confirmTransaction(signature);
    if (confirmation.value.err) {
      throw new Error('交易确认失败');
    }
    alert(`转账成功!交易签名: ${signature}`);
  } catch (error: any) {
    console.error('发送交易失败:', error);
    alert(`交易失败: ${error.message}`);
  }
};

注意这个细节recentBlockhashfeePayer是Solana交易对象必须设置的两个属性,缺一不可。忘记设置feePayer是我遇到的第一个报错。recentBlockhash用于防止交易重放,并让验证者知道交易的有效期。

4. 使用Wallet Adapter优化交易发送

上面的手动签名流程比较繁琐,而且useWallet钩子并不直接暴露signTransaction方法。实际上,@solana/wallet-adapter-react提供了更优雅的sendTransaction方法。

// 这是更推荐的实践,修改MyComponent.tsx
import { useConnection, useWallet } from '@solana/wallet-adapter-react';

const { connection } = useConnection();
const { publicKey, sendTransaction } = useWallet(); // 使用钩子提供的sendTransaction

const sendTransactionEasy = async () => {
  if (!publicKey) return;

  const toPublicKey = new PublicKey('接收方地址');
  const lamportsToSend = 0.01 * LAMPORTS_PER_SOL;

  const transaction = new Transaction().add(
    SystemProgram.transfer({
      fromPubkey: publicKey,
      toPubkey: toPublicKey,
      lamports: lamportsToSend,
    })
  );

  // 关键步骤:获取区块哈希并设置
  const { blockhash } = await connection.getLatestBlockhash();
  transaction.recentBlockhash = blockhash;
  transaction.feePayer = publicKey;

  try {
    // 一行代码搞定:钱包适配器会处理弹窗签名、发送、获取签名结果
    const signature = await sendTransaction(transaction, connection);
    console.log('交易签名:', signature);

    // 可选:等待交易确认
    const result = await connection.confirmTransaction(signature, 'confirmed');
    console.log('确认结果:', result);
    alert('转账成功!');
  } catch (error: any) {
    console.error('交易出错:', error);
    alert(`用户拒绝或交易失败: ${error.message}`);
  }
};

这里有个巨大的进步:使用钱包适配器提供的sendTransaction方法,我们不需要手动处理签名、序列化、发送原始交易这些底层细节。它会自动触发钱包的签名请求,并返回交易签名。代码简洁且健壮。

完整代码

以下是一个整合了所有功能、可以直接运行的MyComponent.tsx示例:

import React, { useState, useEffect } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { SystemProgram, Transaction, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';

export const MyComponent: React.FC = () => {
  const { connection } = useConnection();
  const { publicKey, connected, sendTransaction } = useWallet();
  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  const [sending, setSending] = useState(false);
  const [recipient, setRecipient] = useState('');

  // 获取余额
  useEffect(() => {
    const fetchBalance = async () => {
      if (connected && publicKey) {
        setLoading(true);
        try {
          const lamportsBalance = await connection.getBalance(publicKey);
          setBalance(lamportsBalance / LAMPORTS_PER_SOL);
        } catch (error) {
          console.error('获取余额失败:', error);
          setBalance(null);
        } finally {
          setLoading(false);
        }
      } else {
        setBalance(null);
      }
    };
    fetchBalance();
  }, [connection, publicKey, connected]);

  // 发送SOL交易
  const handleSendSol = async () => {
    if (!publicKey || !recipient) {
      alert('请先连接钱包并填写接收地址');
      return;
    }
    let toPubkey;
    try {
      toPubkey = new PublicKey(recipient);
    } catch {
      alert('接收地址格式无效');
      return;
    }

    const transferAmount = 0.01; // 固定转账0.01 SOL,实际项目可以做成输入框
    const lamportsToSend = transferAmount * LAMPORTS_PER_SOL;

    const transaction = new Transaction().add(
      SystemProgram.transfer({
        fromPubkey: publicKey,
        toPubkey: toPubkey,
        lamports: lamportsToSend,
      })
    );

    try {
      const { blockhash } = await connection.getLatestBlockhash();
      transaction.recentBlockhash = blockhash;
      transaction.feePayer = publicKey;

      setSending(true);
      const signature = await sendTransaction(transaction, connection);
      console.log('交易完成,签名:', signature);

      // 等待最终确认,提供更好反馈
      await connection.confirmTransaction(signature, 'confirmed');
      alert(`成功转账${transferAmount} SOL!交易签名: ${signature}`);
      setRecipient(''); // 清空输入框
      // 重新获取余额
      const newBalance = await connection.getBalance(publicKey);
      setBalance(newBalance / LAMPORTS_PER_SOL);
    } catch (error: any) {
      console.error('交易失败:', error);
      if (error.message.includes('User rejected')) {
        alert('您拒绝了交易签名。');
      } else {
        alert(`交易失败: ${error.message}`);
      }
    } finally {
      setSending(false);
    }
  };

  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <h1>Solana Web3.js 入门实战</h1>
      <div style={{ marginBottom: '20px' }}>
        <WalletMultiButton />
      </div>

      {connected && publicKey && (
        <div>
          <p>
            <strong>钱包地址:</strong> {publicKey.toBase58()}
          </p>
          <p>
            <strong>余额:</strong>{' '}
            {loading ? '加载中...' : balance !== null ? `${balance.toFixed(4)} SOL` : '--'}
          </p>

          <hr style={{ margin: '20px 0' }} />

          <h3>发送 SOL 测试</h3>
          <div>
            <input
              type="text"
              placeholder="输入接收方Solana地址"
              value={recipient}
              onChange={(e) => setRecipient(e.target.value)}
              style={{ width: '400px', padding: '8px', marginRight: '10px' }}
            />
            <button onClick={handleSendSol} disabled={sending || !recipient}>
              {sending ? '发送中...' : '发送 0.01 SOL'}
            </button>
            <p style={{ fontSize: '0.9em', color: '#666', marginTop: '5px' }}>
              请确保在Devnet网络,并使用Devnet的SOL进行测试。
            </p>
          </div>
        </div>
      )}
      {!connected && <p>请点击上方按钮连接钱包(推荐Phantom)。</p>}
    </div>
  );
};

踩坑记录

  1. “Cannot read properties of undefined (reading ‘solana’)”:这是我遇到的第一个错误。原因是我在没有安装Phantom钱包(或任何Solana钱包)的浏览器中运行代码。window.solana对象不存在。解决方法:在代码中增加判断,或者引导用户安装钱包。钱包适配器的UI按钮会自动处理这个状态。

  2. “Transaction recentBlockhash required”:构建交易后发送失败。我忘记给交易对象transaction设置recentBlockhash属性。解决方法:在发送交易前,必须调用connection.getLatestBlockhash()并赋值给transaction.recentBlockhash

  3. “FeePayer must be a PublicKey”:设置了recentBlockhash后依然报错。因为我连feePayer也没设置。解决方法:将当前用户的公钥publicKey赋值给transaction.feePayer。记住,这两个属性是Solana Transaction对象的必选项。

  4. 交易签名成功但链上确认失败:在测试网发送交易,钱包签名弹窗成功了,但最后交易失败。原因是我用的RPC节点不稳定或响应慢。解决方法:更换更稳定、快速的RPC端点。对于开发,可以使用Solana基金会提供的公共端点clusterApiUrl(‘devnet’),但对于生产环境,需要考虑使用付费的私有RPC服务以获得更好的可靠性。

小结

通过这个从零到一的实践,我深刻体会到Solana前端开发在交易构建细节上与EVM的差异。核心收获是:理解Solana交易必须包含recentBlockhashfeePayer,并善用@solana/wallet-adapter系列工具库能极大提升开发效率。下一步,我可以基于此继续探索如何与SPL代币(类似ERC20)交互、如何解析NFT元数据,以及如何与自定义的智能合约(Solana上称为程序)进行交互。

从“连接失败”到丝滑登录:我用 ethers.js 连接 MetaMask 的完整踩坑实录

作者 竹林818
2026年4月16日 18:03

背景

上个月,我接手了一个新的 NFT 铸造平台前端开发。项目要求很简单:用户点击“连接钱包”按钮,弹出 MetaMask 授权,连接成功后显示用户地址和余额。作为有几年经验的 Web3 开发者,我心想这还不是手到擒来?直接上 ethers.js 这个老伙计,几行代码搞定。于是,我新建了一个 React 组件,信心满满地开始敲代码。没想到,就是这个看似基础的功能,让我在接下来的一天里,跟各种奇怪的报错和边界情况斗智斗勇。

问题分析

我最开始的思路非常直接:在组件挂载时,检查 window.ethereum 是否存在(即用户是否安装了 MetaMask),然后调用 ethereum.request({ method: 'eth_requestAccounts' }) 请求账户授权,最后用 new ethers.providers.Web3Provider(window.ethereum) 创建 provider 来读取链上数据。

第一版代码跑起来,点击按钮,MetaMask 确实弹出来了,授权也很顺利。控制台打印出了地址,我正准备庆祝,问题就来了。

  1. 页面刷新后,登录状态丢失:用户需要重新点击连接。这体验太差了,我们的产品经理第一个不答应。
  2. 切换 MetaMask 账户时,前端页面没反应:用户在钱包里换了账号,但我们的网站显示的依然是旧地址。
  3. 切换网络时页面卡住:用户从以太坊主网切换到 Polygon,页面有时会卡死,需要手动刷新。

我意识到,我把问题想简单了。一个生产级的钱包连接,不仅仅是“弹出授权框拿到地址”,它必须是一个有状态、能响应变化、并且持久化的连接。我需要监听钱包的各种事件(账户变化、网络变化),并妥善管理这些状态,使其与 React 组件的状态同步。

核心实现

第一步:检测 Provider 与初始化状态

首先,我们不能假设用户一定装了 MetaMask。所以,检测 window.ethereum 是第一步,并且最好在组件生命周期早期进行。

这里有个坑:window.ethereum 的类型在 TypeScript 中是 anyunknown。为了更好的类型安全,我将其断言为 ethers.providers.ExternalProvider,但更严谨的做法是使用 ethers 提供的类型工具,或者直接检查必要的方法是否存在。

我决定在自定义 Hook (useWallet) 的初始化阶段完成检测和基础设置。

import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

// 声明全局的 ethereum 类型
declare global {
  interface Window {
    ethereum?: ethers.providers.ExternalProvider & {
      isMetaMask?: boolean;
      request: (args: { method: string; params?: any[] }) => Promise<any>;
      on: (event: string, callback: (...args: any[]) => void) => void;
      removeListener: (event: string, callback: (...args: any[]) => void) => void;
    };
  }
}

export const useWallet = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  // 初始化:检查是否已连接
  useEffect(() => {
    const checkIfWalletIsConnected = async () => {
      if (!window.ethereum) {
        setError('请安装 MetaMask 钱包扩展!');
        return;
      }

      try {
        // 尝试获取已授权的账户
        const accounts = await window.ethereum.request({
          method: 'eth_accounts',
        });
        if (accounts.length > 0) {
          // 如果已有授权账户,直接初始化 provider 和 signer
          await initProviderAndSigner(accounts[0]);
        }
        // 获取当前网络ID
        const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
        setChainId(parseInt(chainIdHex, 16));
      } catch (err) {
        console.error('初始化检查钱包连接失败:', err);
      }
    };

    checkIfWalletIsConnected();
  }, []);
}

eth_accounts 这个方法是关键,它不会弹出授权框,而是静默返回已被当前 DApp 授权的账户列表。如果列表不为空,说明用户之前已经连接过,我们可以直接恢复状态。这是解决“刷新后状态丢失”问题的核心。

第二步:实现连接与断开功能

连接功能就是主动弹出授权请求。这里要注意错误处理,特别是用户拒绝授权的情况。

const connectWallet = useCallback(async () => {
  if (!window.ethereum) {
    setError('请安装 MetaMask 钱包扩展!');
    return;
  }

  setIsConnecting(true);
  setError('');
  try {
    // 1. 请求账户授权,这会弹出 MetaMask 窗口
    const accounts = await window.ethereum.request({
      method: 'eth_requestAccounts',
    });
    // 2. 用获取到的第一个账户初始化
    await initProviderAndSigner(accounts[0]);
    // 3. 获取当前网络
    const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
    setChainId(parseInt(chainIdHex, 16));
  } catch (err: any) {
    // 用户拒绝授权是最常见的错误
    if (err.code === 4001) {
      setError('您拒绝了钱包连接请求。');
    } else {
      setError(`连接失败: ${err.message}`);
    }
    console.error('连接钱包失败:', err);
  } finally {
    setIsConnecting(false);
  }
}, []);

const disconnectWallet = useCallback(() => {
  // 注意:ethers.js 和 MetaMask 没有真正的“断开连接”API。
  // 所谓的断开,只是清除我们本地应用的状态。
  setProvider(null);
  setSigner(null);
  setAccount('');
  setChainId(0);
  setError('');
  // 在实际项目中,你可能还需要清除 localStorage/SessionStorage 中的相关状态
}, []);

这里有个大坑:很多新手(包括当时的我)会寻找 disconnectlogout 方法。但实际上,MetaMask 的权限模型是“一次授权,持续有效”,直到用户在其钱包界面手动移除站点权限。所以前端的“断开”只是前端自己清空状态,下次用 eth_accounts 检查时,如果用户没移除权限,还是会拿到地址。这是一个重要的认知点。

第三步:监听钱包事件(关键!)

这是让应用“活”起来,响应外部变化的核心。我们需要监听 accountsChangedchainChanged 事件。

// 初始化 provider 和 signer 的辅助函数
const initProviderAndSigner = useCallback(async (accountAddress: string) => {
  if (!window.ethereum) return;
  // 创建 Provider
  const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
  setProvider(web3Provider);
  // 创建 Signer
  const web3Signer = web3Provider.getSigner();
  setSigner(web3Signer);
  setAccount(accountAddress);
}, []);

// 设置事件监听
useEffect(() => {
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    console.log('accountsChanged 事件触发:', accounts);
    if (accounts.length === 0) {
      // 用户在所有界面断开了连接,或者切换到了一个没有权限的账户
      disconnectWallet();
      setError('请连接您的钱包账户。');
    } else if (accounts[0] !== account) {
      // 用户切换了账户
      initProviderAndSigner(accounts[0]);
    }
  };

  const handleChainChanged = (_chainId: string) => {
    // 注意:chainId 是十六进制字符串
    console.log('chainChanged 事件触发:', _chainId);
    // 当网络切换时,MetaMask 建议刷新页面,因为许多链上数据可能失效。
    // 但为了更好体验,我们可以只重置部分状态并重新获取链ID。
    window.location.reload();
    // 更优雅的做法:不刷新,只更新 chainId 并重新初始化 provider(可能需要新的 RPC 配置)
    // setChainId(parseInt(_chainId, 16));
    // initProviderAndSigner(account); // 重新初始化,因为网络变了
  };

  // 绑定监听器
  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  // 组件卸载时清理监听器,防止内存泄漏
  return () => {
    if (window.ethereum) {
      window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum.removeListener('chainChanged', handleChainChanged);
    }
  };
}, [account, disconnectWallet, initProviderAndSigner]);

注意这个细节chainChanged 事件的处理。早期文档和很多教程都建议直接 window.location.reload(),因为网络切换后,旧的 provider 实例可能指向错误的 RPC。虽然刷新简单粗暴,但体验不好。更优的方案是:更新 chainId,然后基于新的 chainId 创建一个新的 provider 实例(如果你配置了多链 RPC 的话)。我这里为了代码清晰,先用了刷新方案。

第四步:获取余额与完善 UI

有了 provideraccount,获取余额就很简单了。但要注意异步操作和错误处理。

const [balance, setBalance] = useState<string>('0');

// 获取余额的函数
const fetchBalance = useCallback(async () => {
  if (!provider || !account) {
    setBalance('0');
    return;
  }
  try {
    const balanceWei = await provider.getBalance(account);
    // 格式化为 Ether 单位,保留4位小数
    const balanceEth = ethers.utils.formatEther(balanceWei);
    setBalance(parseFloat(balanceEth).toFixed(4));
  } catch (err) {
    console.error('获取余额失败:', err);
    setBalance('0');
  }
}, [provider, account]);

// 当 account 或 provider 变化时,重新获取余额
useEffect(() => {
  fetchBalance();
}, [fetchBalance]);

最后,将这些状态和方法暴露给组件,一个基础但健壮的钱包连接 Hook 就完成了。

完整代码

以下是一个整合了上述所有功能的 React 组件示例:

// WalletConnector.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

declare global {
  interface Window {
    ethereum?: ethers.providers.ExternalProvider & {
      isMetaMask?: boolean;
      request: (args: { method: string; params?: any[] }) => Promise<any>;
      on: (event: string, callback: (...args: any[]) => void) => void;
      removeListener: (event: string, callback: (...args: any[]) => void) => void;
    };
  }
}

const WalletConnector: React.FC = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [balance, setBalance] = useState<string>('0');
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  const initProviderAndSigner = useCallback(async (accountAddress: string) => {
    if (!window.ethereum) return;
    const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
    const web3Signer = web3Provider.getSigner();
    setProvider(web3Provider);
    setSigner(web3Signer);
    setAccount(accountAddress);
  }, []);

  const fetchBalance = useCallback(async () => {
    if (!provider || !account) {
      setBalance('0');
      return;
    }
    try {
      const balanceWei = await provider.getBalance(account);
      const balanceEth = ethers.utils.formatEther(balanceWei);
      setBalance(parseFloat(balanceEth).toFixed(4));
    } catch (err) {
      console.error('获取余额失败:', err);
      setBalance('0');
    }
  }, [provider, account]);

  const connectWallet = useCallback(async () => {
    if (!window.ethereum) {
      setError('请安装 MetaMask 钱包扩展!');
      return;
    }
    setIsConnecting(true);
    setError('');
    try {
      const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
      await initProviderAndSigner(accounts[0]);
      const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
      setChainId(parseInt(chainIdHex, 16));
    } catch (err: any) {
      if (err.code === 4001) {
        setError('连接请求被拒绝。');
      } else {
        setError(`连接失败: ${err.message}`);
      }
    } finally {
      setIsConnecting(false);
    }
  }, [initProviderAndSigner]);

  const disconnectWallet = useCallback(() => {
    setProvider(null);
    setSigner(null);
    setAccount('');
    setChainId(0);
    setBalance('0');
    setError('');
  }, []);

  // 初始化检查与事件监听
  useEffect(() => {
    if (!window.ethereum) {
      setError('未检测到 Web3 钱包。请安装 MetaMask。');
      return;
    }

    const checkInitialConnection = async () => {
      try {
        const accounts = await window.ethereum!.request({ method: 'eth_accounts' });
        if (accounts.length > 0) {
          await initProviderAndSigner(accounts[0]);
        }
        const chainIdHex = await window.ethereum!.request({ method: 'eth_chainId' });
        setChainId(parseInt(chainIdHex, 16));
      } catch (err) {
        console.error('初始连接检查出错:', err);
      }
    };

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        disconnectWallet();
        setError('账户已断开。');
      } else if (accounts[0] !== account) {
        initProviderAndSigner(accounts[0]);
      }
    };

    const handleChainChanged = (_chainId: string) => {
      // 简单处理:刷新页面
      window.location.reload();
    };

    checkInitialConnection();

    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);

    return () => {
      window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum?.removeListener('chainChanged', handleChainChanged);
    };
  }, [account, disconnectWallet, initProviderAndSigner]);

  // 余额监听
  useEffect(() => {
    fetchBalance();
  }, [fetchBalance]);

  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>钱包连接状态</h2>
      {error && <p style={{ color: 'red' }}>错误: {error}</p>}
      
      {!account ? (
        <button onClick={connectWallet} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接 MetaMask'}
        </button>
      ) : (
        <div>
          <p><strong>连接地址:</strong> {`${account.substring(0, 6)}...${account.substring(account.length - 4)}`}</p>
          <p><strong>网络 ID:</strong> {chainId}</p>
          <p><strong>余额:</strong> {balance} ETH</p>
          <button onClick={disconnectWallet} style={{ marginTop: '10px' }}>
            断开连接(前端)
          </button>
          <p style={{ fontSize: '0.8em', color: '#666', marginTop: '5px' }}>
            (注:需在 MetaMask 中移除站点权限才能完全断开)
          </p>
        </div>
      )}
      
      <div style={{ marginTop: '20px', fontSize: '0.9em', color: '#333' }}>
        <p>试试以下操作,观察页面变化:</p>
        <ul>
          <li>在 MetaMask 中切换账户</li>
          <li>在 MetaMask 中切换网络(如 Goerli 测试网)</li>
          <li>刷新页面</li>
        </ul>
      </div>
    </div>
  );
};

export default WalletConnector;

踩坑记录

  1. window.ethereum 类型错误:在 TypeScript 中直接使用 window.ethereum 会报类型错误。我一开始用 (window as any).ethereum 粗暴解决,后来发现这不利于代码维护。最终通过扩展 global 接口提供了更精确的类型定义,并检查必要方法是否存在。
  2. accountsChanged 事件在断开时触发空数组:我最初只监听新账户,没处理 accounts.length === 0 的情况。导致用户在 MetaMask 里断开连接后,我的应用界面还显示着旧地址。加上这个判断后,体验才正常。
  3. 网络切换后 Provider 失效:这是我遇到最棘手的问题。用户切换网络后,旧的 provider 实例发出的请求可能仍发往旧的 RPC 节点,导致各种 UNSUPPORTED_OPERATION 或网络错误。我尝试过在 chainChanged 事件里创建新的 provider,但有时会碰到异步时序问题。最后,对于这个简单 demo,我采用了 MetaMask 官方早期文档推荐的页面刷新方案。在真实复杂项目中,需要结合项目状态管理库(如 Redux、Zustand)和自定义的多链 RPC 配置来更优雅地处理。
  4. 余额显示单位问题provider.getBalance() 返回的是 BigNumber 类型的 wei 单位。直接 toString() 会显示一长串数字。必须用 ethers.utils.formatEther() 进行单位转换。同时要注意转换后的精度显示,避免出现过多小数位。

小结

这次折腾让我彻底明白,一个稳定的钱包连接不仅仅是调用一个 API,而是一个需要持续维护状态、监听外部事件、并妥善处理各种边界情况的完整功能模块。虽然现在有 wagmiRainbowKit 这样优秀的封装库,但理解其底层原理,亲手用 ethers.js 实现一遍,对于排查复杂问题和构建定制化需求依然至关重要。下一步,我可以在此基础上集成多链支持、钱包连接缓存(localStorage)以及更优雅的网络切换处理逻辑。

用wagmi v2构建DeFi前端:从连接钱包到读取合约数据的完整实战与避坑指南

作者 竹林818
2026年4月15日 10:01

背景

上个月,我接手了一个DeFi收益聚合器项目的前端重构任务。老代码用的是 ethers.js + 自己封装的钱包连接逻辑,维护起来非常头疼,尤其是多链支持和交易状态跟踪的部分,bug频出。团队决定迁移到更现代的 wagmi v2 搭配 viem,希望利用其声明式的Hooks来简化状态管理。我的任务很明确:用 React + wagmi v2 搭建一个新的前端基础框架,核心要搞定钱包连接、实时读取用户在不同链上的资产余额、以及一个关键合约(质押池)的数据。

一开始我以为照着官方文档拼凑一下就行,结果在实际开发中,从钱包连接状态同步到合约数据读取,我踩了一路的坑。这篇文章就是我解决这些问题的完整记录。

问题分析

我最开始的思路很简单:按照wagmi官方示例,配置好WagmiProvider,用useConnect连接钱包,用useAccount获取账户,然后用useReadContract读取数据。但一上手就发现了问题。

首先,当用户在MetaMask里切换网络时,前端应用的状态并没有立即同步更新。用户从以太坊主网切换到Arbitrum,但UI上显示的链ID还是1,这会导致后续所有针对错误链的合约调用失败。其次,在读取用户在不同链上的ERC20代币余额时,我需要根据当前激活的链动态切换合约地址,但最初的实现里,链切换后合约查询并没有自动重新执行。最后,在用户进行质押操作后,我需要准确监听交易状态(提交、打包、成功/失败),并实时更新UI上的余额数据,避免用户看到陈旧信息。

排查过程让我意识到,wagmi虽然抽象得很好,但如果不理解其内部的状态更新机制和Hooks的依赖关系,很容易写出看起来能跑但实际上有隐性bug的代码。问题的核心在于如何让React组件状态与钱包的外部状态(链、账户)保持强同步,以及如何正确构造依赖数组以触发查询的重新执行。

核心实现

1. 配置Provider与多链支持

第一步是正确配置WagmiProvider。这里我选择了项目需要支持的四个链:Ethereum, Arbitrum, Optimism和Polygon。我使用viem提供的预定义链配置,并创建了一个自定义的wagmi配置对象。这里有个关键点config对象必须被稳定地引用,最好在React组件外部创建,或者用useMemo包裹,防止它在每次渲染时重新创建,导致不必要的上下文重置。

// src/providers/WagmiProvider.tsx
import { createConfig, http, WagmiProvider as WagmiProviderCore } from 'wagmi';
import { mainnet, arbitrum, optimism, polygon } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { injected } from 'wagmi/connectors';

// 创建稳定的查询客户端和配置
const queryClient = new QueryClient();

const config = createConfig({
  chains: [mainnet, arbitrum, optimism, polygon],
  connectors: [injected()], // 主要支持注入式钱包(如MetaMask)
  transports: {
    [mainnet.id]: http(),
    [arbitrum.id]: http(),
    [optimism.id]: http(),
    [polygon.id]: http(),
  },
});

export function WagmiProvider({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProviderCore config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProviderCore>
  );
}

2. 实现可靠的钱包连接与链状态同步

接下来是连接组件。我不仅需要连接按钮,还需要一个实时显示当前网络和账户的组件。useAccount Hook提供了address, chainId, connector等信息,并且会响应钱包扩展程序的状态变化。但为了处理网络切换,我必须结合使用useSwitchChain

踩过的一个坑:最初我试图用useAccountchainId直接作为读取合约数据的链依据,但当用户拒绝网络切换请求时,chainId可能处于“期望切换”但“实际未变”的中间状态。更好的做法是,对于关键操作(如发送交易),始终使用useAccount返回的chain对象,并结合错误处理。

// src/components/ConnectButton.tsx
import { useConnect, useAccount, useDisconnect, useSwitchChain } from 'wagmi';

export function ConnectButton() {
  const { connect, connectors, isPending } = useConnect();
  const { address, chain, isConnected } = useAccount();
  const { disconnect } = useDisconnect();
  const { switchChain } = useSwitchChain();

  const handleConnect = () => {
    // 默认连接第一个注入式连接器(如MetaMask)
    connect({ connector: connectors[0] });
  };

  const handleSwitchChain = (targetChainId: number) => {
    switchChain({ chainId: targetChainId });
  };

  if (!isConnected) {
    return (
      <button onClick={handleConnect} disabled={isPending}>
        {isPending ? 'Connecting...' : 'Connect Wallet'}
      </button>
    );
  }

  return (
    <div>
      <p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
      <p>Network: {chain?.name} (ID: {chain?.id})</p>
      <div>
        <button onClick={() => handleSwitchChain(arbitrum.id)}>Switch to Arbitrum</button>
        <button onClick={() => disconnect()}>Disconnect</button>
      </div>
    </div>
  );
}

3. 动态读取多链合约数据

这是DeFi前端的核心。我需要读取用户在某条链上的特定代币余额。合约地址因链而异。useReadContract Hook接收一个配置对象,当其中的addresschainIdaccount发生变化时,它会自动重新获取数据。

注意这个细节useReadContractquery选项中的enabled属性非常有用。我可以设置enabled: !!address && !!chainId,这样只有当用户钱包已连接且链ID明确时,才会发起查询,避免了不必要的错误请求和日志噪音。

// src/hooks/useTokenBalance.ts
import { useReadContract, useAccount } from 'wagmi';
import { erc20Abi } from 'viem';

// 不同链上的USDC合约地址映射
const USDC_ADDRESS: Record<number, `0x${string}`> = {
  1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum Mainnet
  42161: '0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8', // Arbitrum
  10: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism
  137: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', // Polygon
};

export function useTokenBalance() {
  const { address, chainId } = useAccount();

  const { data: balance, isLoading, error, refetch } = useReadContract({
    abi: erc20Abi,
    address: chainId ? USDC_ADDRESS[chainId] : undefined,
    functionName: 'balanceOf',
    args: address ? [address] : undefined,
    query: {
      enabled: !!address && !!chainId, // 关键:确保条件满足才查询
      refetchInterval: 10000, // 每10秒自动刷新一次
    },
  });

  return {
    balance,
    isLoading,
    error,
    refetch, // 暴露手动刷新函数,用于交易后更新
  };
}

4. 执行合约写入与交易状态监听

用户操作,比如质押代币,需要发送交易。我使用useWriteContract来发起交易,但更重要的是监听交易状态。wagmi v2 通过useWaitForTransactionReceipt Hook提供了优雅的解决方案。

这里有个大坑useWriteContract返回的writeContractAsync函数在调用时,必须明确指定chainId。即使你的config里配置了多链,且用户当前已切换到目标链,如果你不传chainId,它有时会默认使用配置中的第一个链(比如主网),导致交易发错链。务必显式传递accountchainId

// src/components/StakeForm.tsx
import { useState } from 'react';
import { useWriteContract, useWaitForTransactionReceipt, useAccount } from 'wagmi';
import { parseUnits } from 'viem';

const stakingPoolAbi = [ /* 你的质押合约ABI */ ] as const;
const STAKING_POOL_ADDRESS = '0x...'; // 你的质押合约地址

export function StakeForm() {
  const [amount, setAmount] = useState('');
  const { address, chainId } = useAccount();

  const {
    writeContractAsync,
    isPending: isWritePending,
    data: hash,
    error: writeError,
    reset: resetWrite,
  } = useWriteContract();

  const {
    isLoading: isConfirming,
    isSuccess: isConfirmed,
    error: receiptError,
  } = useWaitForTransactionReceipt({
    hash,
    query: {
      enabled: !!hash, // 只有有交易哈希时才启动监听
    },
  });

  const handleStake = async () => {
    if (!address || !chainId) return;
    try {
      resetWrite(); // 重置上一次的写入状态
      await writeContractAsync({
        abi: stakingPoolAbi,
        address: STAKING_POOL_ADDRESS,
        functionName: 'stake',
        args: [parseUnits(amount, 18)], // 假设代币精度18
        account: address,
        chainId: chainId, // !!!务必显式指定链ID
      });
      // 交易哈希已提交,状态由 useWaitForTransactionReceipt 监听
    } catch (err) {
      console.error('Stake failed:', err);
    }
  };

  return (
    <div>
      <input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="Amount to stake" />
      <button onClick={handleStake} disabled={isWritePending || !amount}>
        {isWritePending ? 'Confirming in wallet...' : 'Stake'}
      </button>
      {isConfirming && <p>Transaction is being confirmed...</p>}
      {isConfirmed && <p>Stake successful! <button onClick={() => refetchBalance()}>Refresh Balance</button></p>}
      {writeError && <p style={{ color: 'red' }}>Error: {writeError.message}</p>}
    </div>
  );
}

完整代码示例

下面是一个整合了以上关键部分的简化版主应用组件,你可以直接复制到一个新的React项目中运行(需先安装依赖)。

// App.tsx
import { WagmiProvider } from './providers/WagmiProvider';
import { ConnectButton } from './components/ConnectButton';
import { useTokenBalance } from './hooks/useTokenBalance';
import { StakeForm } from './components/StakeForm';

function AppContent() {
  const { balance, isLoading, error, refetch } = useTokenBalance();

  return (
    <div style={{ padding: '20px' }}>
      <h1>DeFi Staking Dashboard</h1>
      <ConnectButton />
      <hr />
      <h2>Your USDC Balance</h2>
      {isLoading && <p>Loading balance...</p>}
      {error && <p>Error loading balance: {error.message}</p>}
      {balance !== undefined && (
        <p>Balance: {balance.toString()} units (raw)</p>
        // 实际应用中,这里需要根据代币精度格式化显示
      )}
      <button onClick={() => refetch()}>Refresh Balance</button>
      <hr />
      <h2>Stake Tokens</h2>
      <StakeForm onSuccess={refetch} /> {/* 传入刷新余额的回调 */}
    </div>
  );
}

export default function App() {
  return (
    <WagmiProvider>
      <AppContent />
    </WagmiProvider>
  );
}

踩坑记录

  1. useReadContract 不自动更新:当用户切换钱包账户后,余额查询没有更新。原因:我忘记将address作为args的一部分。args: [address]必须依赖address变量,当address变化时,查询才会重新执行。解决:确保args正确绑定到响应式变量(如来自useAccountaddress)。

  2. 交易发错链:用户在Arbitrum上点击质押,交易却发到了以太坊主网,导致失败和Gas费损失。原因:调用writeContractAsync时没有显式传递chainId参数。解决:始终从useAccount中获取当前的chainId,并在写入合约时明确指定chainId: currentChainId

  3. “RPC Error: Rate Limited”:在开发时频繁刷新页面,快速连接/断开钱包,导致Infura或Alchemy的RPC端点报速率限制错误。原因wagmihttp()传输层默认没有配置请求节流或重试。解决:为生产环境配置更健壮的RPC提供商,或者使用viemfallback传输层,设置多个RPC端点作为备用。例如:transport: fallback([http('https://mainnet.infura.io/v3/your-key'), http()])

  4. **TypeScript类型错误:0xstring:在定义合约地址常量时,直接写字符串字面量,TypeScript报错,要求是0ˋx{string}`”`**:在定义合约地址常量时,直接写字符串字面量,TypeScript报错,要求是`\`0x{string}`类型。**原因**:viemwagmi为了类型安全,要求地址是严格的以0x开头的十六进制字符串类型。**解决**:使用类型断言as `0x${string}``,或者确保你的地址常量符合该模板字面量类型。

小结

通过这一轮实战,我深刻体会到在Web3前端开发中,状态同步的可靠性远比功能实现更重要。wagmi v2配合viem提供了强大的基础,但开发者必须清晰地理解:账户、链ID、合约地址如何作为Hooks的依赖项驱动数据流。下一步,我计划深入研究wagmi的存储持久化和自定义缓存策略,以进一步提升复杂DeFi应用的用户体验。

❌
❌