跳到主要内容

钱包连接与签名

钱包的本质​

核心认知

钱包不存储资产,资产始终在区块链上。钱包本质是一个密钥管理器,负责保管私钥、派生地址、签署交易。

钱包的核心职责:

职责说明
密钥管理生成、存储、保护私钥(或助记词)
地址派生从私钥通过椭圆曲线(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、RabbyDApp 体验最好,注入 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 接口​

EIP-1193 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 无法读取内容
WalletConnect v2 vs v1

v2 相比 v1 的核心改进:支持多链 session(一次连接授权多条链)、基于 Relay 协议而非 Bridge Server、更好的会话管理和恢复机制。v1 已于 2023 年 6 月停止维护。

签名类型详解​

签名是 Web3 的核心操作,根据签名内容的不同分为三类:

签名方法用途是否上链Gas 费用
personal_sign登录验证、链下消息否无
eth_signTypedData_v4结构化数据签名(EIP-712)否无
eth_sendTransaction发送交易(转账、合约调用)是需要

personal_sign(消息签名)​

最简单的签名方式,对人类可读的字符串进行签名,常用于登录验证。

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 的局限

personal_sign 签名的是纯文本,用户在钱包中看到的就是原始字符串。但对于复杂的数据结构(如订单信息),纯文本难以展示清晰的结构,且无法被智能合约验证结构。此时应使用 EIP-712。

eth_signTypedData_v4(EIP-712 结构化签名)​

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;
}
EIP-712 的优势
  1. 用户体验:钱包以结构化表格展示字段,而非一堆十六进制
  2. 链上验证:智能合约可以通过 ecrecover 验证 EIP-712 签名
  3. 防重放:domain 中包含 chainId 和 verifyingContract,签名无法跨链或跨合约使用
  4. 类型安全:数据结构明确定义,防止数据被篡改

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;
}

签名验证​

前端验证​

前端验证通常用于快速检查签名是否匹配,但不能作为安全依据(前端代码可被篡改)。

使用 ethers.js 前端验证签名
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 但基于钱包签名。

SIWE 消息格式与登录流程
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 vs 传统登录
维度传统登录SIWE
凭证用户名 + 密码钱包私钥签名
注册需要邮箱、手机号无需注册,连接即登录
托管方平台存储密码哈希用户自持私钥
跨平台每个平台独立账号一个钱包地址登录所有 DApp
标准OAuth 2.0EIP-4361(SIWE)

常见问题处理​

EIP-1193 标准错误码​

错误码名称含义
4001User Rejected用户在钱包中拒绝了请求
4100UnauthorizedDApp 未被授权(需先调用 eth_requestAccounts)
4200Unsupported Method钱包不支持该 RPC 方法
4900Disconnected钱包未连接到任何链
4901Chain 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_signeth_signTypedData_v4
数据格式纯文本字符串结构化数据(EIP-712)
钱包展示原始文本格式化的表格,字段清晰
消息前缀"\x19Ethereum Signed Message:\n""\x19\x01" + domainSeparator
合约验证可以但不方便原生支持
防重放需手动加 noncedomain 中包含 chainId、合约地址
典型场景登录验证链下订单签名(NFT 交易、DEX 限价单)

Q4: 为什么 personal_sign 要加前缀?​

答案:

personal_sign 在签名前自动添加 "\x19Ethereum Signed Message:\n" + len + message 前缀。这是为了防止恶意 DApp 构造一段看似普通消息、实际是合法交易的数据让用户签名。加了前缀后,签名结果与交易签名的格式不同,无法被当作交易提交到链上,从而保护用户资产安全。

Q5: WalletConnect v2 的通信流程是怎样的?​

答案:

  1. DApp 生成 pairing 提议(包含 topic 和对称密钥),编码为二维码
  2. 用户用手机钱包扫码,获得 topic 和密钥
  3. DApp 和钱包都连接到 Relay Server,订阅同一 topic
  4. 钱包发送 session 提议(授权的账户和链),DApp 确认
  5. 后续所有请求(签名、交易)通过 Relay 中转,使用对称密钥加密
  6. 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 冲突如何解决?​

答案:

有两种标准方案:

  1. EIP-5749:多钱包在 window.ethereum.providers 数组中注册,DApp 遍历数组查找特定钱包(通过 isMetaMask、isRabby 等标识)
  2. 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 是否存在。未安装时的引导策略:

  1. 桌面端:显示安装引导弹窗,链接到 MetaMask 官网或 Chrome Web Store
  2. 移动端浏览器:使用 deep link 跳转到钱包 App 的 DApp 浏览器(如 https://metamask.app.link/dapp/yoursite.com)
  3. 提供 WalletConnect:作为无需安装扩展的备选方案,用户可以扫码连接移动端钱包
  4. 渐进式体验:先展示只读内容,用户点击需要签名的功能时再提示连接

Q12: 交易签名和消息签名的本质区别是什么?​

答案:

维度消息签名交易签名
上链不上链,纯链下操作上链,改变区块链状态
Gas免费需要支付 gas 费
方法personal_sign / eth_signTypedData_v4eth_sendTransaction
内容任意消息或结构化数据交易参数(to、value、data)
用途身份验证、链下授权转账、合约调用、NFT 铸造
可逆性无链上效果,无需逆转上链后不可逆

Q13: nonce 在签名验证中起什么作用?​

答案:

nonce(Number Used Once)是一次性随机数,用于防止签名重放攻击。流程:后端生成随机 nonce 并存储(与地址关联)→ 前端将 nonce 嵌入签名消息 → 用户签名 → 后端验证签名时检查 nonce 是否匹配 → 验证通过后立即作废该 nonce。如果没有 nonce,攻击者截获一次合法签名后可以无限次重放,冒充用户身份。

相关链接​