被MetaMask的"连接"坑了三天,我终于搞懂了ethers.js钱包登录的正确姿势

0 阅读1分钟

被MetaMask的"连接"坑了三天,我终于搞懂了ethers.js钱包登录的正确姿势

背景

上个月我在做一个DeFi看板项目,功能很简单:用户连接MetaMask钱包后,可以查看自己在几个主流DeFi协议中的持仓和收益。项目用React + TypeScript搭建,后端用Node.js做API。

一开始我觉得钱包登录不就是调几个API嘛,网上教程一搜一大把。结果真正动手才发现,从"用户点连接按钮"到"前端拿到用户地址并验证身份",中间全是坑。

最让我崩溃的是:明明MetaMask已经弹窗让用户授权了,console.log也能打印出地址,但刷新页面后状态就丢了,用户得重新连一次。还有一次用户明明连接了钱包,但发起交易签名时却报错"invalid provider"。我当时就想:这破事到底怎么搞?

问题分析

我最初的思路很简单:用ethers.js的BrowserProvider(当时还叫Web3Provider)去连MetaMask,然后调用getSigner()拿到签名器,再调用getAddress()获取用户地址。

代码大概长这样:

// 我最初写的坑爹代码
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const address = await signer.getAddress();

当时跑起来确实能弹出MetaMask授权窗口,也能拿到地址。但我一刷新页面,window.ethereum还在,但providersigner都丢了——因为我没把provider实例存到React的状态管理里。

更离谱的是,有用户反馈说"连接后钱包地址显示不对"。我排查了半天,发现是因为用户切换了MetaMask账户,但前端没有监听accountsChanged事件,导致页面显示的地址还是旧的。

另一个大坑是:某些用户安装了多个钱包插件(比如MetaMask + Coinbase Wallet),window.ethereum指向的可能是别的钱包,导致连接时弹的不是MetaMask。

核心实现

第一步:检测MetaMask是否安装,处理多钱包冲突

我决定从最底层开始写。首先,用户进入页面时,我得先判断他有没有装MetaMask。如果没装,得提示他去安装;如果装了多个钱包,我得确保用的是MetaMask的provider。

这里有个关键细节:window.ethereum在MetaMask安装后会存在,但如果用户装了多个钱包,window.ethereum会被覆盖。MetaMask提供了一种方式:通过window.ethereum.isMetaMask来判断当前provider是不是MetaMask的。但更稳妥的做法是用window.ethereum.providers数组,找到isMetaMask为true的那个。

// 检测并获取MetaMask provider
function getMetaMaskProvider(): any {
  if (typeof window.ethereum === 'undefined') {
    throw new Error('请安装MetaMask浏览器扩展');
  }

  // 如果存在providers数组(多钱包情况)
  if (window.ethereum.providers?.length) {
    const mmProvider = window.ethereum.providers.find(
      (p: any) => p.isMetaMask
    );
    if (!mmProvider) {
      throw new Error('未检测到MetaMask,请确保已安装并启用');
    }
    return mmProvider;
  }

  // 单钱包情况
  if (!window.ethereum.isMetaMask) {
    throw new Error('当前钱包不是MetaMask,请切换到MetaMask');
  }

  return window.ethereum;
}

这里有个坑window.ethereum.providers是MetaMask v10+才有的API。旧版本没有这个属性,所以必须做兼容处理。我当时就因为这个,在测试环境(MetaMask v9)上报了"undefined is not iterable"的错误。

第二步:用ethers.js的BrowserProvider建立连接

拿到正确的provider后,我需要用它创建ethers.js的BrowserProvider实例,然后请求用户授权连接。

注意:BrowserProvider是ethers v6的写法,v5里叫Web3Provider。我项目用的是ethers v6,所以代码基于v6。

import { BrowserProvider } from 'ethers';

interface WalletConnection {
  provider: BrowserProvider;
  signer: any;
  address: string;
  chainId: number;
}

async function connectWallet(): Promise<WalletConnection> {
  const mmProvider = getMetaMaskProvider();

  // 创建BrowserProvider实例
  const browserProvider = new BrowserProvider(mmProvider);

  // 请求用户授权连接钱包
  // 注意:这里会触发MetaMask弹窗
  const accounts = await browserProvider.send('eth_requestAccounts', []);
  if (!accounts || accounts.length === 0) {
    throw new Error('用户取消了钱包连接');
  }

  // 获取签名器
  const signer = await browserProvider.getSigner();
  const address = await signer.getAddress();

  // 获取当前链ID
  const network = await browserProvider.getNetwork();
  const chainId = Number(network.chainId);

  return {
    provider: browserProvider,
    signer,
    address,
    chainId,
  };
}

注意这个细节browserProvider.send('eth_requestAccounts', [])这一步是必须的。虽然getSigner()也能触发MetaMask弹窗,但它的行为不太可控——如果用户之前拒绝过连接,getSigner()不会再次弹窗,而是直接抛错。所以我总是先用eth_requestAccounts明确请求授权。

第三步:监听账户和链切换事件

这是我最开始踩的坑。用户连接钱包后,如果他在MetaMask里切换账户或切换链,前端必须能感知到并更新状态,否则所有数据都是错的。

MetaMask通过window.ethereum(注意,是原始provider,不是BrowserProvider)抛出事件。我需要用mmProvider.on()来监听。

// 在React组件中管理钱包状态
import { useState, useEffect, useCallback } from 'react';

function useWallet() {
  const [walletState, setWalletState] = useState<{
    address: string | null;
    chainId: number | null;
    provider: BrowserProvider | null;
    signer: any | null;
  }>({
    address: null,
    chainId: null,
    provider: null,
    signer: null,
  });

  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // 连接钱包
  const connect = useCallback(async () => {
    setIsConnecting(true);
    setError(null);
    try {
      const connection = await connectWallet();
      setWalletState({
        address: connection.address,
        chainId: connection.chainId,
        provider: connection.provider,
        signer: connection.signer,
      });
      
      // 连接成功后,开始监听事件
      setupEventListeners(connection);
    } catch (err: any) {
      setError(err.message || '钱包连接失败');
    } finally {
      setIsConnecting(false);
    }
  }, []);

  // 设置事件监听
  const setupEventListeners = useCallback((connection: WalletConnection) => {
    const mmProvider = getMetaMaskProvider();

    // 监听账户切换
    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        // 用户断开了所有账户
        disconnect();
      } else {
        // 更新地址
        setWalletState(prev => ({
          ...prev,
          address: accounts[0],
        }));
      }
    };

    // 监听链切换
    const handleChainChanged = (chainIdHex: string) => {
      // chainId是十六进制字符串,需要转成数字
      const newChainId = parseInt(chainIdHex, 16);
      setWalletState(prev => ({
        ...prev,
        chainId: newChainId,
      }));
    };

    // 监听断开连接
    const handleDisconnect = () => {
      disconnect();
    };

    mmProvider.on('accountsChanged', handleAccountsChanged);
    mmProvider.on('chainChanged', handleChainChanged);
    mmProvider.on('disconnect', handleDisconnect);

    // 返回清理函数
    return () => {
      mmProvider.removeListener('accountsChanged', handleAccountsChanged);
      mmProvider.removeListener('chainChanged', handleChainChanged);
      mmProvider.removeListener('disconnect', handleDisconnect);
    };
  }, []);

  // 断开连接
  const disconnect = useCallback(() => {
    setWalletState({
      address: null,
      chainId: null,
      provider: null,
      signer: null,
    });
  }, []);

  return {
    ...walletState,
    isConnecting,
    error,
    connect,
    disconnect,
  };
}

这里有个大坑chainChanged事件传过来的chainId是十六进制字符串(比如"0x1"),不是数字。我第一次没做转换,直接用===比较,结果死活匹配不上。后来看文档才发现要parseInt(chainIdHex, 16)

另一个坑是:disconnect事件在MetaMask里触发条件很特殊——只有在用户通过MetaMask设置里"断开连接"时才会触发,不是用户关闭弹窗或切换账户。所以不能完全依赖它做清理。

第四步:刷新页面后恢复连接状态

用户连接钱包后刷新页面,钱包状态会丢失。这很烦人,但有一个解决办法:利用window.ethereumeth_accounts方法,它不会弹窗,而是返回当前已授权的账户列表。如果有账户,说明用户之前授权过,可以直接恢复连接。

// 尝试恢复已存在的连接
async function tryRestoreConnection(): Promise<WalletConnection | null> {
  try {
    const mmProvider = getMetaMaskProvider();
    const browserProvider = new BrowserProvider(mmProvider);

    // 不弹窗,静默检查是否有已授权的账户
    const accounts = await browserProvider.send('eth_accounts', []);
    if (!accounts || accounts.length === 0) {
      return null; // 没有已授权的账户
    }

    const signer = await browserProvider.getSigner();
    const address = await signer.getAddress();
    const network = await browserProvider.getNetwork();
    const chainId = Number(network.chainId);

    return {
      provider: browserProvider,
      signer,
      address,
      chainId,
    };
  } catch {
    return null;
  }
}

在React组件初始化时调用这个函数:

useEffect(() => {
  // 页面加载时尝试恢复连接
  tryRestoreConnection().then(connection => {
    if (connection) {
      setWalletState({
        address: connection.address,
        chainId: connection.chainId,
        provider: connection.provider,
        signer: connection.signer,
      });
      setupEventListeners(connection);
    }
  });
}, []);

注意这个细节eth_accountseth_requestAccounts的区别。前者静默返回已授权账户,后者会弹窗请求授权。如果用户之前授权过,用前者就能恢复连接,不用再次弹窗。

第五步:签名验证用户身份

钱包连接只是拿到了地址,但后端需要验证这个地址确实是用户本人控制的。所以需要让用户用私钥签名一条消息,后端验证签名。

// 生成签名消息
function createSignMessage(address: string, nonce: string): string {
  return `欢迎登录DeFi看板\n\n地址: ${address}\n随机数: ${nonce}\n\n此签名用于验证您的身份,不会产生任何链上交易。`;
}

// 让用户签名
async function signMessage(signer: any, message: string): Promise<string> {
  return await signer.signMessage(message);
}

// 完整登录流程
async function loginWithSignature(
  address: string,
  signer: any
): Promise<string> {
  // 1. 向后端请求一个随机数(nonce)
  const response = await fetch(`/api/auth/nonce?address=${address}`);
  const { nonce } = await response.json();

  // 2. 构建签名消息
  const message = createSignMessage(address, nonce);

  // 3. 让用户签名
  const signature = await signMessage(signer, message);

  // 4. 将地址和签名发给后端验证
  const verifyResponse = await fetch('/api/auth/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ address, signature, message }),
  });

  const { token } = await verifyResponse.json();
  return token; // 返回JWT token,后续请求携带
}

这里有个坑:签名消息的格式。如果消息里有换行符,不同钱包的处理方式可能不同。MetaMask在显示签名弹窗时,会尝试解析消息中的换行符。我遇到过一个情况:消息里有个\n被转义成了\\n,导致用户看到的签名内容和实际签名的内容不一致,验证失败。所以一定要用模板字符串或明确写死换行符。

完整代码

下面是一个完整的React组件,实现了上述所有功能:

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

// 类型定义
interface WalletState {
  address: string | null;
  chainId: number | null;
  provider: BrowserProvider | null;
  signer: any | null;
}

// 检测MetaMask provider
function getMetaMaskProvider(): any {
  if (typeof window.ethereum === 'undefined') {
    throw new Error('请安装MetaMask浏览器扩展');
  }
  if (window.ethereum.providers?.length) {
    const mmProvider = window.ethereum.providers.find((p: any) => p.isMetaMask);
    if (!mmProvider) throw new Error('未检测到MetaMask');
    return mmProvider;
  }
  if (!window.ethereum.isMetaMask) {
    throw new Error('当前钱包不是MetaMask');
  }
  return window.ethereum;
}

// 连接钱包
async function connectWallet(): Promise<{
  provider: BrowserProvider;
  signer: any;
  address: string;
  chainId: number;
}> {
  const mmProvider = getMetaMaskProvider();
  const browserProvider = new BrowserProvider(mmProvider);
  const accounts = await browserProvider.send('eth_requestAccounts', []);
  if (!accounts?.length) throw new Error('用户取消了钱包连接');
  const signer = await browserProvider.getSigner();
  const address = await signer.getAddress();
  const network = await browserProvider.getNetwork();
  const chainId = Number(network.chainId);
  return { provider: browserProvider, signer, address, chainId };
}

// 恢复连接
async function tryRestoreConnection() {
  try {
    const mmProvider = getMetaMaskProvider();
    const browserProvider = new BrowserProvider(mmProvider);
    const accounts = await browserProvider.send('eth_accounts', []);
    if (!accounts?.length) return null;
    const signer = await browserProvider.getSigner();
    const address = await signer.getAddress();
    const network = await browserProvider.getNetwork();
    const chainId = Number(network.chainId);
    return { provider: browserProvider, signer, address, chainId };
  } catch {
    return null;
  }
}

// React组件
export default function WalletLogin() {
  const [wallet, setWallet] = useState<WalletState>({
    address: null,
    chainId: null,
    provider: null,
    signer: null,
  });
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [token, setToken] = useState<string | null>(null);

  const disconnect = useCallback(() => {
    setWallet({ address: null, chainId: null, provider: null, signer: null });
    setToken(null);
  }, []);

  const connect = useCallback(async () => {
    setIsConnecting(true);
    setError(null);
    try {
      const connection = await connectWallet();
      setWallet({
        address: connection.address,
        chainId: connection.chainId,
        provider: connection.provider,
        signer: connection.signer,
      });
    } catch (err: any) {
      setError(err.message || '连接失败');
    } finally {
      setIsConnecting(false);
    }
  }, []);

  const signAndLogin = useCallback(async () => {
    if (!wallet.address || !wallet.signer) return;
    try {
      const nonceRes = await fetch(`/api/auth/nonce?address=${wallet.address}`);
      const { nonce } = await nonceRes.json();
      const message = `欢迎登录\n地址: ${wallet.address}\n随机数: ${nonce}`;
      const signature = await wallet.signer.signMessage(message);
      const verifyRes = await fetch('/api/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ address: wallet.address, signature, message }),
      });
      const { token: jwt } = await verifyRes.json();
      setToken(jwt);
    } catch (err: any) {
      setError('签名登录失败: ' + err.message);
    }
  }, [wallet]);

  useEffect(() => {
    tryRestoreConnection().then(connection => {
      if (connection) {
        setWallet({
          address: connection.address,
          chainId: connection.chainId,
          provider: connection.provider,
          signer: connection.signer,
        });
      }
    });
  }, []);

  return (
    <div style={{ padding: '20px' }}>
      <h1>DeFi 看板 - 钱包登录</h1>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      {!wallet.address ? (
        <button onClick={connect} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接MetaMask钱包'}
        </button>
      ) : (
        <div>
          <p>地址: {wallet.address}</p>
          <p>链ID: {wallet.chainId}</p>
          {token ? (
            <p>已登录,Token: {token.substring(0, 20)}...</p>
          ) : (
            <button onClick={signAndLogin}>签名登录</button>
          )}
          <button onClick={disconnect} style={{ marginLeft: '10px' }}>
            断开连接
          </button>
        </div>
      )}
    </div>
  );
}

踩坑记录

  1. ethers.providers.Web3Provider is not a constructor
    这个错误是因为我用了ethers v6,但代码里写的是v5的API。v6里改成了BrowserProvider,而且不需要new ethers.providers.这种写法,直接import { BrowserProvider } from 'ethers'就行。

  2. MetaMask - RPC Error: User rejected the request
    用户取消连接后,这个错误会抛出来。如果不捕获,页面会直接崩溃。必须在connect()里用try-catch包起来,并且给用户友好的提示。我最初没处理,用户取消后页面白屏,被测试骂了一顿。

  3. 刷新页面后地址消失
    这个最坑。我一开始以为只要window.ethereum还在,连接就还在。后来才知道,ethers的BrowserProvider实例是内存里的,刷新就没了。必须用eth_accounts静默恢复,或者把地址存到localStorage里(但存localStorage有安全风险,我最后选择了eth_accounts方案)。

  4. 切换链后签名失败
    用户在链A连接钱包,然后切换到链B,再去签名。MetaMask会提示"链不匹配",但ethers不会自动处理。我后来加了监听chainChanged事件,在切换链时重新创建provider和signer,才解决这个问题。

小结

MetaMask钱包登录的核心就三件事:正确获取provider、监听账户和链切换事件、刷新后恢复连接状态。这些看起来简单,但每个环节都有坑。如果你也在做类似功能,建议先把eth_accountseth_requestAccounts的区别搞清楚,这是最容易出错的地方。下一步可以研究如何支持多钱包(比如WalletConnect)以及如何在React组件中优雅地管理这些状态。