跳到主要内容

钱包连接与签名

钱包的本质

核心认知

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

钱包的核心职责:

职责说明
密钥管理生成、存储、保护私钥(或助记词)
地址派生从私钥通过椭圆曲线(secp256k1)→ 公钥 → Keccak256 哈希 → 取后 20 字节得到地址
交易签名用私钥对交易数据进行 ECDSA 签名
DApp 交互通过 Provider API 暴露能力给网页/应用
面试速答版

钱包怎么连接 DApp? 所有主流钱包都遵守 EIP-1193 接口:

  • 浏览器扩展钱包会注入 window.ethereum(现代会走 EIP-6963 多钱包发现)。
  • ethereum.request({ method: 'eth_requestAccounts' }) 拉起弹窗让用户授权,返回账户列表。
  • 要监听 accountsChangedchainChanged 事件,用户在钱包里切账号/切链时 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.providersEIP-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 中包含 chainIdverifyingContract,签名无法跨链或跨合约使用
  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_signeth_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 遍历数组查找特定钱包(通过 isMetaMaskisRabby 等标识)
  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_requestAccountseth_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,攻击者截获一次合法签名后可以无限次重放,冒充用户身份。

相关链接