被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还在,但provider和signer都丢了——因为我没把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.ethereum的eth_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_accounts和eth_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>
);
}
踩坑记录
-
ethers.providers.Web3Provider is not a constructor
这个错误是因为我用了ethers v6,但代码里写的是v5的API。v6里改成了BrowserProvider,而且不需要new ethers.providers.这种写法,直接import { BrowserProvider } from 'ethers'就行。 -
MetaMask - RPC Error: User rejected the request
用户取消连接后,这个错误会抛出来。如果不捕获,页面会直接崩溃。必须在connect()里用try-catch包起来,并且给用户友好的提示。我最初没处理,用户取消后页面白屏,被测试骂了一顿。 -
刷新页面后地址消失
这个最坑。我一开始以为只要window.ethereum还在,连接就还在。后来才知道,ethers的BrowserProvider实例是内存里的,刷新就没了。必须用eth_accounts静默恢复,或者把地址存到localStorage里(但存localStorage有安全风险,我最后选择了eth_accounts方案)。 -
切换链后签名失败
用户在链A连接钱包,然后切换到链B,再去签名。MetaMask会提示"链不匹配",但ethers不会自动处理。我后来加了监听chainChanged事件,在切换链时重新创建provider和signer,才解决这个问题。
小结
MetaMask钱包登录的核心就三件事:正确获取provider、监听账户和链切换事件、刷新后恢复连接状态。这些看起来简单,但每个环节都有坑。如果你也在做类似功能,建议先把eth_accounts和eth_requestAccounts的区别搞清楚,这是最容易出错的地方。下一步可以研究如何支持多钱包(比如WalletConnect)以及如何在React组件中优雅地管理这些状态。