钱包连接与签名
钱包的本质
钱包不存储资产,资产始终在区块链上。钱包本质是一个密钥管理器,负责保管私钥、派生地址、签署交易。
钱包的核心职责:
| 职责 | 说明 |
|---|---|
| 密钥管理 | 生成、存储、保护私钥(或助记词) |
| 地址派生 | 从私钥通过椭圆曲线(secp256k1)→ 公钥 → Keccak256 哈希 → 取后 20 字节得到地址 |
| 交易签名 | 用私钥对交易数据进行 ECDSA 签名 |
| DApp 交互 | 通过 Provider API 暴露能力给网页/应用 |
钱包怎么连接 DApp? 所有主流钱包都遵守 EIP-1193 接口:
- 浏览器扩展钱包会注入
window.ethereum(现代会走 EIP-6963 多钱包发现)。 - 调
ethereum.request({ method: 'eth_requestAccounts' })拉起弹窗让用户授权,返回账户列表。 - 要监听
accountsChanged、chainChanged事件,用户在钱包里切账号/切链时 DApp 要同步刷新状态。 - 移动端没有
window.ethereum,需 WalletConnect(扫二维码走 WebSocket relay 传递请求)。
签名有几种? 要区分清楚,避免让用户「盲签」:
- personal_sign:签任意字符串,限于登录、身份验证,应遵循 SIWE(EIP-4361) 格式给出 domain/nonce/expiration。
- EIP-712 结构化签名:签名内容在钱包以表单形式展示出来。Permit、OpenSea 下单都用这种。
- eth_sign:极危险,能签任何 hash,等于全权委托,其他签名能代替的都不要用。
钱包不存资产,存什么? 钱包其实只是个密钥管理器:
- 管助记词与私钥、选择派生出地址、在用户同意后用私钥签交易,资产本身始终在链上。
- 硬件钱包(Ledger/Trezor)与 MPC 钱包让私钥不出设备 / 不完整存在一个地方,安全性高。
钱包分类
| 类型 | 代表 | 特点 | 私钥存储 |
|---|---|---|---|
| 浏览器扩展钱包 | MetaMask、Rabby | DApp 体验最好,注入 window.ethereum | 加密存储在浏览器 |
| 移动端钱包 | Rainbow、Trust Wallet | 通过 WalletConnect 与 DApp 连接 | 设备安全区域 |
| 硬件钱包 | Ledger、Trezor | 私钥永不离开硬件设备,最安全 | 安全芯片 |
| 托管钱包 | 交易所钱包(Coinbase) | 用户不持有私钥,由平台代管 | 平台服务器 |
| 智能合约钱包 | Safe(原 Gnosis Safe) | 多签、社交恢复、gas 代付 | 合约逻辑控制 |
| MPC 钱包 | Fireblocks、Privy | 私钥分片,多方计算签名 | 分布式分片 |
EIP-1193 Provider 标准
EIP-1193 定义了钱包向 DApp 暴露的标准 JavaScript API,所有主流钱包(MetaMask、Rabby 等)都遵循此标准。
Provider 接口
interface EIP1193Provider {
// 核心方法:所有交互都通过 request 完成
request(args: { method: string; params?: unknown[] }): Promise<unknown>;
// 事件监听
on(event: string, listener: (...args: any[]) => void): void;
removeListener(event: string, listener: (...args: any[]) => void): void;
}
检测钱包是否安装
function detectWallet(): EIP1193Provider | null {
// MetaMask 等扩展钱包会注入 window.ethereum
if (typeof window !== 'undefined' && window.ethereum) {
return window.ethereum;
}
return null;
}
// 注意:多个钱包同时安装时,window.ethereum 可能被覆盖
// EIP-5749 引入了 window.ethereum.providers 数组
function detectMultipleWallets() {
const providers = window.ethereum?.providers;
if (providers) {
const metamask = providers.find((p: any) => p.isMetaMask);
const rabby = providers.find((p: any) => p.isRabby);
return { metamask, rabby };
}
return { default: window.ethereum };
}
当用户同时安装了 MetaMask 和 Rabby,两者都会尝试注入 window.ethereum。后加载的会覆盖先加载的。使用 window.ethereum.providers 或 EIP-6963(钱包主动广播自身)来解决此问题。
连接流程详解
基本连接
async function connectWallet() {
const provider = detectWallet();
if (!provider) {
// 引导用户安装钱包,或提供 WalletConnect 作为备选
window.open('https://metamask.io/download/', '_blank');
return;
}
try {
// eth_requestAccounts 会触发钱包弹窗请求用户授权
const accounts = await provider.request({
method: 'eth_requestAccounts',
}) as string[];
const address = accounts[0]; // 用户选择的账户地址
console.log('已连接:', address);
// 获取当前链 ID
const chainId = await provider.request({
method: 'eth_chainId',
}) as string;
console.log('当前链:', parseInt(chainId, 16));
return { address, chainId };
} catch (error: any) {
// EIP-1193 标准错误码
if (error.code === 4001) {
console.log('用户拒绝了连接请求');
} else {
console.error('连接失败:', error);
}
}
}
监听账户和链变化
function setupListeners(provider: EIP1193Provider) {
// 用户在钱包中切换了账户
provider.on('accountsChanged', (accounts: string[]) => {
if (accounts.length === 0) {
// 用户主动断开连接(在钱包中取消了授权)
handleDisconnect();
} else {
handleAccountChange(accounts[0]);
}
});
// 用户在钱包中切换了网络
provider.on('chainChanged', (chainId: string) => {
// MetaMask 官方建议:链切换后直接刷新页面,避免状态不一致
window.location.reload();
});
// 连接断开(通常是 WalletConnect 场景)
provider.on('disconnect', (error: { code: number; message: string }) => {
console.log('钱包断开连接:', error.message);
handleDisconnect();
});
}
切换网络
async function switchToChain(provider: EIP1193Provider, chainId: number) {
const hexChainId = `0x${chainId.toString(16)}`;
try {
await provider.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: hexChainId }],
});
} catch (error: any) {
// 4902 表示钱包中没有该网络,需要先添加
if (error.code === 4902) {
await provider.request({
method: 'wallet_addEthereumChain',
params: [{
chainId: hexChainId,
chainName: 'Polygon',
nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 },
rpcUrls: ['https://polygon-rpc.com'],
blockExplorerUrls: ['https://polygonscan.com'],
}],
});
}
}
}
WalletConnect 协议
WalletConnect 让移动端钱包可以与桌面端 DApp 交互,用户扫描二维码即可建立连接。
v2 架构
| 概念 | 说明 |
|---|---|
| Relay Server | 消息中转服务器,DApp 和钱包不直接通信 |
| Pairing | 一次性配对过程(扫码),建立加密通道 |
| Session | 持久会话,记录授权的账户、链、方法 |
| Topic | 会话标识符,用于 Relay 路由消息 |
| 对称加密 | 使用共享密钥加密所有消息,Relay 无法读取内容 |
v2 相比 v1 的核心改进:支持多链 session(一次连接授权多条链)、基于 Relay 协议而非 Bridge Server、更好的会话管理和恢复机制。v1 已于 2023 年 6 月停止维护。
签名类型详解
签名是 Web3 的核心操作,根据签名内容的不同分为三类:
| 签名方法 | 用途 | 是否上链 | Gas 费用 |
|---|---|---|---|
personal_sign | 登录验证、链下消息 | 否 | 无 |
eth_signTypedData_v4 | 结构化数据签名(EIP-712) | 否 | 无 |
eth_sendTransaction | 发送交易(转账、合约调用) | 是 | 需要 |
personal_sign(消息签名)
最简单的签名方式,对人类可读的字符串进行签名,常用于登录验证。
async function personalSign(
provider: EIP1193Provider,
address: string,
message: string,
) {
// 将消息转为 hex 编码
const msgHex = `0x${Buffer.from(message, 'utf8').toString('hex')}`;
// personal_sign 会在消息前自动添加前缀:
// "\x19Ethereum Signed Message:\n" + message.length + message
// 这防止了恶意 DApp 让用户签署伪装成交易的消息
const signature = await provider.request({
method: 'personal_sign',
params: [msgHex, address],
}) as string;
return signature; // 0x 开头的 65 字节签名(r + s + v)
}
// 使用示例
const sig = await personalSign(
window.ethereum,
'0xYourAddress...',
'Welcome to MyDApp! Please sign to verify your identity.\n\nNonce: abc123',
);
personal_sign 签名的是纯文本,用户在钱包中看到的就是原始字符串。但对于复杂的数据结构(如订单信息),纯文本难以展示清晰的结构,且无法被智能合约验证结构。此时应使用 EIP-712。
eth_signTypedData_v4(EIP-712 结构化签名)
EIP-712 定义了结构化数据的签名标准,钱包会以格式化的方式展示签名内容,用户能清楚看到每个字段的含义。
async function signTypedData(
provider: EIP1193Provider,
address: string,
) {
// EIP-712 签名数据由三部分组成:domain、types、message
const typedData = {
// domain:标识签名的上下文(哪个 DApp、哪条链)
// 防止一个 DApp 的签名被另一个 DApp 重放
domain: {
name: 'MyDApp', // DApp 名称
version: '1', // DApp 版本
chainId: 1, // 链 ID(防止跨链重放)
verifyingContract: '0x...', // 验证合约地址
},
// types:定义数据结构(类似 TypeScript 的类型定义)
types: {
// EIP712Domain 是必须的,但不需要手动定义(库会自动处理)
Order: [
{ name: 'maker', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
{ name: 'price', type: 'uint256' },
{ name: 'expiry', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
],
},
// primaryType:指定主类型
primaryType: 'Order',
// message:实际数据
message: {
maker: address,
tokenId: '42',
price: '1000000000000000000', // 1 ETH (wei)
expiry: '1700000000',
nonce: '1',
},
};
const signature = await provider.request({
method: 'eth_signTypedData_v4',
params: [address, JSON.stringify(typedData)],
}) as string;
return signature;
}
- 用户体验:钱包以结构化表格展示字段,而非一堆十六进制
- 链上验证:智能合约可以通过
ecrecover验证 EIP-712 签名 - 防重放:domain 中包含
chainId和verifyingContract,签名无法跨链或跨合约使用 - 类型安全:数据结构明确定义,防止数据被篡改
eth_sendTransaction(交易签名)
交易签名会实际上链,产生 gas 费用,改变链上状态。
async function sendTransaction(
provider: EIP1193Provider,
from: string,
) {
const txHash = await provider.request({
method: 'eth_sendTransaction',
params: [{
from,
to: '0xRecipientAddress...',
value: '0xDE0B6B3A7640000', // 1 ETH in hex wei
// gas、gasPrice/maxFeePerGas 可省略,钱包会自动估算
}],
}) as string;
console.log('交易哈希:', txHash);
// 等待交易确认(需要轮询或使用 ethers.js 的 waitForTransaction)
return txHash;
}
// 调用合约方法的交易(需要编码 calldata)
async function callContract(
provider: EIP1193Provider,
from: string,
) {
// data 字段是 ABI 编码后的函数调用
// 实际项目中使用 ethers.js 或 viem 来编码
const txHash = await provider.request({
method: 'eth_sendTransaction',
params: [{
from,
to: '0xContractAddress...',
data: '0xa9059cbb000000000000000000000000...', // transfer(address,uint256) 的 ABI 编码
value: '0x0',
}],
}) as string;
return txHash;
}
签名验证
前端验证
前端验证通常用于快速检查签名是否匹配,但不能作为安全依据(前端代码可被篡改)。
import { verifyMessage, verifyTypedData } from 'ethers';
// 验证 personal_sign
function verifyPersonalSign(message: string, signature: string): string {
// 返回签名者的地址,与预期地址对比即可
const recoveredAddress = verifyMessage(message, signature);
return recoveredAddress;
}
// 验证 EIP-712
function verifyEIP712(
domain: any,
types: any,
value: any,
signature: string,
): string {
const recoveredAddress = verifyTypedData(domain, types, value, signature);
return recoveredAddress;
}
后端验证(推荐)
登录场景中,签名验证必须在后端完成。
- Nonce 必须一次性:每次签名使用不同的 nonce,防止签名重放攻击
- 后端验证:永远不要信任前端的验证结果
- 时间戳校验:SIWE 消息中可包含过期时间,防止旧签名被滥用
Sign-In with Ethereum(SIWE / EIP-4361)
EIP-4361 标准化了"使用以太坊登录"的消息格式,类似于 OAuth 但基于钱包签名。
import { SiweMessage } from 'siwe';
async function signInWithEthereum(
provider: EIP1193Provider,
address: string,
) {
// SIWE 消息有严格的格式规范
// 钱包会以人类友好的方式展示这些字段
const message = new SiweMessage({
domain: window.location.host, // 请求签名的网站域名
address, // 用户钱包地址
statement: 'Sign in to MyDApp', // 对用户的说明
uri: window.location.origin, // DApp 的 URI
version: '1', // SIWE 版本
chainId: 1, // 链 ID
nonce: 'abc123xyz', // 从后端获取的随机 nonce
issuedAt: new Date().toISOString(),
expirationTime: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 分钟过期
});
// 将 SIWE 消息转为标准字符串格式
const messageStr = message.prepareMessage();
// 生成的消息类似:
// "myapp.com wants you to sign in with your Ethereum account:
// 0x1234...
// Sign in to MyDApp
// URI: https://myapp.com
// Version: 1
// Chain ID: 1
// Nonce: abc123xyz
// Issued At: 2024-01-01T00:00:00Z"
const msgHex = `0x${Buffer.from(messageStr, 'utf8').toString('hex')}`;
const signature = await provider.request({
method: 'personal_sign',
params: [msgHex, address],
});
// 发送到后端验证
const response = await fetch('/api/auth/siwe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: messageStr, signature }),
});
return response.json(); // { token: 'jwt...' }
}
| 维度 | 传统登录 | SIWE |
|---|---|---|
| 凭证 | 用户名 + 密码 | 钱包私钥签名 |
| 注册 | 需要邮箱、手机号 | 无需注册,连接即登录 |
| 托管方 | 平台存储密码哈希 | 用户自持私钥 |
| 跨平台 | 每个平台独立账号 | 一个钱包地址登录所有 DApp |
| 标准 | OAuth 2.0 | EIP-4361(SIWE) |
常见问题处理
EIP-1193 标准错误码
| 错误码 | 名称 | 含义 |
|---|---|---|
| 4001 | User Rejected | 用户在钱包中拒绝了请求 |
| 4100 | Unauthorized | DApp 未被授权(需先调用 eth_requestAccounts) |
| 4200 | Unsupported Method | 钱包不支持该 RPC 方法 |
| 4900 | Disconnected | 钱包未连接到任何链 |
| 4901 | Chain Disconnected | 钱包未连接到请求的链 |
function handleWalletError(error: any) {
switch (error.code) {
case 4001:
return '您取消了操作,请重试';
case 4100:
return '请先连接钱包';
case 4200:
return '您的钱包不支持此功能,请更新钱包';
case 4900:
return '钱包已断开连接,请检查网络';
case 4901:
return '请切换到正确的网络';
case -32002:
// MetaMask 特有:已有等待中的请求(弹窗未处理)
return '钱包中有待处理的请求,请先完成';
default:
return `操作失败: ${error.message}`;
}
}
钱包未安装的引导方案
function getWalletStatus() {
if (typeof window === 'undefined') {
return 'ssr'; // 服务端渲染环境
}
if (window.ethereum) {
return 'installed';
}
// 检测用户是否在移动端浏览器(可能有内置钱包的 DApp 浏览器)
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
if (isMobile) {
return 'mobile-no-wallet';
}
return 'not-installed';
}
// 根据状态引导用户
function guideUser(status: string) {
switch (status) {
case 'installed':
// 正常连接流程
break;
case 'mobile-no-wallet':
// 使用 WalletConnect 或引导到钱包 App 的 DApp 浏览器
// 例如:打开 MetaMask deeplink
window.location.href = `https://metamask.app.link/dapp/${window.location.host}`;
break;
case 'not-installed':
// 显示安装引导或提供 WalletConnect 选项
break;
}
}
更多钱包方案架构设计详见Web3 钱包连接方案,Web3 整体知识体系参见 Web3 知识体系概览。
常见面试问题
Q1: 钱包的本质是什么?它实际存储了什么?
答案:
钱包本质是一个密钥管理器,它存储的是私钥(或助记词),而不是加密货币或 Token。资产始终记录在区块链上,钱包只是用私钥证明"我有权操作这个地址上的资产"。类比:钱包是银行保险箱的钥匙,不是保险箱本身。
Q2: EIP-1193 是什么?window.ethereum 从哪来?
答案:
EIP-1193 是以太坊钱包 Provider 的标准接口,定义了 request() 方法和事件系统。window.ethereum 由浏览器扩展钱包(如 MetaMask)在页面加载时注入。所有 DApp 与钱包的交互都通过这个标准接口完成,常用方法包括 eth_requestAccounts(连接)、eth_chainId(获取链 ID)、personal_sign(签名)等。
Q3: personal_sign 和 eth_signTypedData_v4 有什么区别?
答案:
| 维度 | personal_sign | eth_signTypedData_v4 |
|---|---|---|
| 数据格式 | 纯文本字符串 | 结构化数据(EIP-712) |
| 钱包展示 | 原始文本 | 格式化的表格,字段清晰 |
| 消息前缀 | "\x19Ethereum Signed Message:\n" | "\x19\x01" + domainSeparator |
| 合约验证 | 可以但不方便 | 原生支持 |
| 防重放 | 需手动加 nonce | domain 中包含 chainId、合约地址 |
| 典型场景 | 登录验证 | 链下订单签名(NFT 交易、DEX 限价单) |
Q4: 为什么 personal_sign 要加前缀?
答案:
personal_sign 在签名前自动添加 "\x19Ethereum Signed Message:\n" + len + message 前缀。这是为了防止恶意 DApp 构造一段看似普通消息、实际是合法交易的数据让用户签名。加了前缀后,签名结果与交易签名的格式不同,无法被当作交易提交到链上,从而保护用户资产安全。
Q5: WalletConnect v2 的通信流程是怎样的?
答案:
- DApp 生成 pairing 提议(包含 topic 和对称密钥),编码为二维码
- 用户用手机钱包扫码,获得 topic 和密钥
- DApp 和钱包都连接到 Relay Server,订阅同一 topic
- 钱包发送 session 提议(授权的账户和链),DApp 确认
- 后续所有请求(签名、交易)通过 Relay 中转,使用对称密钥加密
- Relay Server 只负责路由消息,无法解密内容
Q6: 什么是 SIWE(Sign-In with Ethereum)?它解决了什么问题?
答案:
SIWE(EIP-4361)标准化了"使用以太坊登录"的消息格式和验证流程。它解决的核心问题:在 Web3 中实现去中心化的身份认证,不需要用户名密码,不需要第三方 OAuth 提供商。流程是:后端生成 nonce → 前端构造 SIWE 消息 → 钱包签名 → 后端验证签名并颁发 JWT。SIWE 消息包含域名、地址、nonce、过期时间等字段,防止签名重放和跨站滥用。
Q7: 签名验证为什么必须在后端完成?
答案:
前端代码可被用户修改(DevTools、篡改脚本),前端验证的结果不可信。攻击者可以绕过前端验证,直接向后端发送伪造的请求。后端验证的流程:使用 ecrecover 从签名中恢复出签名者地址 → 与声称的地址对比 → 校验 nonce 是否匹配且未过期。只有后端验证通过后才能颁发认证凭证(JWT)。
Q8: 多个钱包同时安装时 window.ethereum 冲突如何解决?
答案:
有两种标准方案:
- EIP-5749:多钱包在
window.ethereum.providers数组中注册,DApp 遍历数组查找特定钱包(通过isMetaMask、isRabby等标识) - EIP-6963(推荐):钱包通过
window.dispatchEvent广播自身信息,DApp 监听eip6963:announceProvider事件。这种方式不依赖window.ethereum,彻底解决了覆盖问题
Q9: EIP-712 中的 domain 字段有什么作用?
答案:
domain 是 EIP-712 的防重放机制,包含以下字段:
name:DApp 名称(防止不同 DApp 之间重放)version:DApp 版本(同一 DApp 升级后旧签名失效)chainId:链 ID(防止跨链重放,如以太坊签名在 BSC 上使用)verifyingContract:验证合约地址(将签名绑定到特定合约)
这些字段会参与签名的哈希计算,任何一个不匹配都会导致验证失败。
Q10: eth_requestAccounts 和 eth_accounts 有什么区别?
答案:
eth_requestAccounts:主动请求连接,如果未授权会弹出钱包弹窗让用户授权,是连接钱包的标准方法eth_accounts:被动查询,返回已授权的账户列表,如果未授权返回空数组,不会弹出弹窗
典型模式:页面加载时用 eth_accounts 静默检查是否已授权(恢复之前的连接状态),用户点击"连接"按钮时用 eth_requestAccounts。
Q11: 如何检测用户是否安装了钱包?未安装时如何引导?
答案:
检测方式:判断 window.ethereum 是否存在。未安装时的引导策略:
- 桌面端:显示安装引导弹窗,链接到 MetaMask 官网或 Chrome Web Store
- 移动端浏览器:使用 deep link 跳转到钱包 App 的 DApp 浏览器(如
https://metamask.app.link/dapp/yoursite.com) - 提供 WalletConnect:作为无需安装扩展的备选方案,用户可以扫码连接移动端钱包
- 渐进式体验:先展示只读内容,用户点击需要签名的功能时再提示连接
Q12: 交易签名和消息签名的本质区别是什么?
答案:
| 维度 | 消息签名 | 交易签名 |
|---|---|---|
| 上链 | 不上链,纯链下操作 | 上链,改变区块链状态 |
| Gas | 免费 | 需要支付 gas 费 |
| 方法 | personal_sign / eth_signTypedData_v4 | eth_sendTransaction |
| 内容 | 任意消息或结构化数据 | 交易参数(to、value、data) |
| 用途 | 身份验证、链下授权 | 转账、合约调用、NFT 铸造 |
| 可逆性 | 无链上效果,无需逆转 | 上链后不可逆 |
Q13: nonce 在签名验证中起什么作用?
答案:
nonce(Number Used Once)是一次性随机数,用于防止签名重放攻击。流程:后端生成随机 nonce 并存储(与地址关联)→ 前端将 nonce 嵌入签名消息 → 用户签名 → 后端验证签名时检查 nonce 是否匹配 → 验证通过后立即作废该 nonce。如果没有 nonce,攻击者截获一次合法签名后可以无限次重放,冒充用户身份。