EIP-1193 & EIP-6963

Provider 接口标准与多钱包发现机制
EIP-1193 · EIP-6963 · Ethereum Provider · Wallet Discovery
dApp browser context EIP-1193 provider.request() MetaMask io.metamask Phantom app.phantom.app EIP-6963 eip6963:announceProvider eip6963:requestProvider
EIP-1193 定义 dApp 与钱包的通信协议;EIP-6963 解决多钱包同时存在时的发现问题

USB 标准化之前,每台设备都有自己的接口:打印机有打印机口,鼠标有 PS/2 口,调制解调器有串口。 USB 做的事很简单:定义一个通用语言,让所有外设能和主机说同一种话。 但 USB 只解决了"怎么说话",没解决"怎么找到设备"——那是 USB 设备枚举协议做的事。

Web3 浏览器钱包面临的,是同一个两阶段问题: 先要有人定义 dApp 和钱包"说同一种语言"的规范(EIP-1193), 再要有人定义多个钱包同时存在时 dApp "如何找到并选择它们"(EIP-6963)。 这两个 EIP 不是竞争关系,是同一个问题的两个层次。

核心洞察

EIP-1193 是"通信协议",EIP-6963 是"发现协议"。标准化让它们能交流;发现机制让它们知道对方的存在。

目录

背景:混乱的 window.ethereum 时代

↑ top

Web3 的浏览器插件钱包最早没有任何标准。MetaMask 在 2016 年率先将一个名为 web3 的对象注入到页面的全局作用域, dApp 通过 window.web3.eth.sendTransaction() 发送交易。其他钱包——Brave Wallet、Coinbase Wallet、imToken—— 纷纷效仿,但每家的 API 形状都略有不同。

很多开发者以为这个问题后来被统一了。其实只是暂时被压制了。 EIP-1193 在 2019 年标准化了 window.ethereum 这个 Provider 接口,但它没有规定"谁有权利把自己放进 window.ethereum"。 结果是:当用户同时安装了 MetaMask 和 Phantom,最后加载的那个插件会悄悄覆盖前一个写入的值。 dApp 读到的是哪个钱包,取决于插件的加载顺序——这是一个竞争条件,不是设计。

时代方式问题
2016–2018 window.web3,各家自定义 API 没有标准,dApp 要对每个钱包单独适配
2019–2022 EIP-1193 标准化 window.ethereum 接口统一了,但多钱包冲突未解决:最后注入者覆盖前者
2023–今 EIP-6963 引入事件式多钱包发现 多钱包共存,dApp 主动枚举,用户自由选择
DESIGN PRINCIPLE

window.ethereum 是单例变量,天然不支持多值。EIP-6963 的核心思路是:放弃争夺同一个变量,改用事件总线让所有钱包各自报到。


EIP-1193:Provider 接口标准

↑ top

EIP-1193(由 Ryan Ghods 等人提出,2019 年 Final)定义了一个最小化的 JavaScript 接口, 让任何符合标准的钱包都能被任何符合标准的 dApp 使用,无需了解彼此的内部实现。

接口极简:一个方法(request)+ 三个事件监听方法(on / removeListener / once)。 这不是偶然的——越小的接口越难被打破,越容易被正确实现。

request() 方法 — 唯一的入口

整个 EIP-1193 最核心的约定只有一个方法签名:

interface RequestArguments { readonly method: string; readonly params?: readonly unknown[] | object; } interface EIP1193Provider { request(args: RequestArguments): Promise<unknown>; on(eventName: string, listener: Function): this; removeListener(eventName: string, listener: Function): this; }

request() 是一个 Promise 化的 JSON-RPC 调用。method 是任何合法的 JSON-RPC 方法名, params 是对应的参数。如果请求成功,Promise resolve 为结果;如果失败,reject 为一个 ProviderRpcError

常用的 JSON-RPC 方法:

方法说明典型返回值
eth_requestAccounts 请求连接钱包,弹出授权弹窗 string[] — 账户地址数组
eth_accounts 获取当前已授权账户(不弹窗) string[]
eth_chainId 获取当前链 ID "0xc4" — 十六进制字符串
eth_sendTransaction 发送交易(需用户确认) string — 交易哈希
eth_sign / personal_sign 签名消息 string — 签名结果
wallet_switchEthereumChain 请求切换到指定链 null
wallet_addEthereumChain 请求添加自定义链 null

Provider 事件

Provider 还需要支持标准事件,让 dApp 响应钱包状态变化,而不是主动轮询。

事件触发时机回调参数
connect Provider 连接到链时(首次或重连) { chainId: string }
disconnect Provider 与链断开连接 ProviderRpcError
chainChanged 用户在钱包中切换了网络 string — 新 chainId(十六进制)
accountsChanged 用户切换账户或撤销授权 string[] — 新账户列表
message 收到订阅推送(如 eth_subscribe) { type: string, data: unknown }
// 监听链切换,自动刷新页面 window.ethereum.on('chainChanged', (chainId) => { window.location.reload(); }); // 监听账户切换,更新 UI window.ethereum.on('accountsChanged', (accounts) => { if (accounts.length === 0) { // 用户断开连接或锁定钱包 handleDisconnect(); } else { updateConnectedAccount(accounts[0]); } });
注意

EIP-1193 规范建议在收到 chainChanged 事件后刷新页面,以避免因链切换导致的状态不一致。大多数生产级 dApp 都遵循这个建议。

错误码

request() 失败时,会抛出一个实现了以下接口的 ProviderRpcError

interface ProviderRpcError extends Error { code: number; data?: unknown; }
Code含义常见触发场景
4001 User Rejected Request — 用户主动拒绝 用户点击取消连接弹窗、拒绝交易确认
4100 Unauthorized — 未获授权 dApp 未调用 eth_requestAccounts 就直接调用需要授权的方法
4200 Unsupported Method — 不支持该方法 钱包未实现该 JSON-RPC 方法
4900 Disconnected — Provider 未连接任何链 钱包离线、网络错误
4901 Chain Disconnected — 未连接到请求的链 当前连接链与请求操作的目标链不符

交互演示:构造 EIP-1193 请求

EIP-1193 Request Builder 生成标准 provider.request() 调用示例

EIP-6963:多钱包发现机制

↑ top

EIP-6963(由 Pedro Gomes 等人提出,2023 年 Final)解决了一个在 EIP-1193 标准化之后才变得更严重的问题: 当多个 EIP-1193 兼容钱包同时存在时,dApp 如何感知它们的存在,用户如何做出选择。

问题:最后写入者胜出

理解 EIP-6963 的前提,是理解它想解决的问题有多荒谬。

假设用户同时安装了 MetaMask 和 Phantom。两个插件都会在页面加载时执行 window.ethereum = myProvider。 执行顺序取决于浏览器插件的加载顺序——而这个顺序取决于用户的安装顺序、插件的启动速度,甚至当天浏览器的心情。 dApp 看到的 window.ethereum 永远只有一个,另一个钱包对它来说根本不存在。

window.ethereum 只有一个槽位 MetaMask 先加载 Phantom 后加载,覆盖 dApp 只看到 Phantom 被覆盖,dApp 无法发现
window.ethereum 只有一个槽位,后加载的钱包会覆盖先加载的
根本矛盾

EIP-1193 假设"有且仅有一个 Provider"。但现实是用户可能同时安装多个钱包。window.ethereum 是单例,承载不了多值。

解决方案:事件总线式广播

EIP-6963 的解决思路借鉴了广播协议(如 mDNS/Bonjour): 不要争夺一个全局变量,改用事件系统让所有参与者主动声明自己的存在。

协议定义了两个自定义事件:

事件名发出方时机作用
eip6963:announceProvider 钱包 页面加载时主动广播;收到 request 事件时重新广播 声明自己存在,携带 Provider 详情
eip6963:requestProvider dApp 需要枚举可用钱包时 触发所有钱包重新广播(用于晚启动的 dApp)

EIP6963ProviderInfo 结构

每次 announceProvider 事件携带一个 EIP6963ProviderDetail 对象:

interface EIP6963ProviderInfo { uuid: string; // UUID v4,每个钱包实例唯一,去重用 name: string; // 人类可读名称,如 "MetaMask" icon: string; // 钱包图标,data URI(推荐 96×96 PNG) rdns: string; // 反向域名,如 "io.metamask" } interface EIP6963ProviderDetail { info: EIP6963ProviderInfo; provider: EIP1193Provider; // 实现了 EIP-1193 的 Provider 实例 } interface EIP6963AnnounceProviderEvent extends CustomEvent { type: 'eip6963:announceProvider'; detail: Readonly<EIP6963ProviderDetail>; }

rdns 是关键字段:它使用反向域名格式(类似 Android App ID),在所有支持 EIP-6963 的钱包中全局唯一, 可用于精确标识某个特定钱包(例如,dApp 可以通过 rdns === "io.metamask" 筛选 MetaMask)。

完整发现流程

① 页面加载 ② 钱包广播 ③ dApp 请求 ④ 再次广播 MetaMask Phantom window (事件总线) dApp announceProvider detail requestProvider 触发所有钱包重新广播 dApp 收集所有 Provider,展示选择器
EIP-6963 双向握手:钱包主动广播 + dApp 主动请求,确保无论谁先启动都不会遗漏
为什么需要 requestProvider?

如果 dApp 比钱包插件晚加载(例如懒加载场景),它可能错过初始的 announceProvider 广播。 发送 requestProvider 事件会触发所有钱包重新广播,确保 dApp 不会遗漏任何已安装的钱包。


协议如何配合

↑ top

EIP-1193 和 EIP-6963 分工明确,层次清晰: EIP-6963 负责发现("这里有哪些钱包"),EIP-1193 负责通信("怎么和选中的钱包交互")。 发现完成后,拿到的 EIP6963ProviderDetail.provider 就是一个标准的 EIP-1193 Provider,直接使用。

钱包侧:如何正确实现 EIP-6963

// 钱包插件注入脚本(content script) const provider = new MyWalletProvider(); // 实现 EIP-1193 const info: EIP6963ProviderInfo = { uuid: '550e8400-e29b-41d4-a716-446655440000', // UUID v4,固定值 name: 'MyWallet', icon: 'data:image/png;base64,iVBORw0KGgo...', rdns: 'com.mywallet', }; function announce() { window.dispatchEvent(new CustomEvent('eip6963:announceProvider', { detail: Object.freeze({ info, provider }), })); } // 主动广播 announce(); // 响应 dApp 的 requestProvider 事件 window.addEventListener('eip6963:requestProvider', announce);
Object.freeze 的必要性

EIP-6963 要求 detail 是 frozen 的 — 防止 dApp 或恶意脚本篡改 Provider 对象。这是协议安全模型的一部分。

dApp 侧:枚举并展示钱包选择器

// dApp 的钱包连接模块 const providers: Map<string, EIP6963ProviderDetail> = new Map(); // 监听钱包广播 window.addEventListener('eip6963:announceProvider', (event: EIP6963AnnounceProviderEvent) => { const { info, provider } = event.detail; providers.set(info.uuid, event.detail); // uuid 去重 renderWalletList(providers); }); // 触发所有钱包广播(覆盖晚加载场景) window.dispatchEvent(new Event('eip6963:requestProvider')); // 用户选择钱包后,使用标准 EIP-1193 API 连接 async function connectWallet(detail: EIP6963ProviderDetail) { try { const accounts = await detail.provider.request({ method: 'eth_requestAccounts', }); console.log(`Connected to ${detail.info.name}: ${accounts[0]}`); } catch (err: any) { if (err.code === 4001) { console.log('User rejected connection'); } } }

从旧模式迁移:window.ethereum 还能用吗

很多人问:实现了 EIP-6963 之后,是否还需要 window.ethereum

答案是:暂时两者并行。window.ethereum 仍然是大量 dApp 和钱包的既有接口,短期内不会消失。 推荐做法是:优先使用 EIP-6963,回退到 window.ethereum

async function getProvider(): Promise<EIP1193Provider | null> { // 优先:尝试 EIP-6963 发现 const detected = await discoverEIP6963Providers(); if (detected.length > 0) { // 展示钱包选择器,让用户选择 return promptUserToSelect(detected); } // 回退:使用 window.ethereum(单钱包场景) if (typeof window !== 'undefined' && window.ethereum) { return window.ethereum; } return null; }

安全考量

↑ top

EIP-1193 的安全边界

风险说明缓解措施
钓鱼 Provider 恶意页面注入伪造的 window.ethereum,劫持 request() 调用 用户应通过浏览器扩展安装钱包,不信任页面注入的 Provider;钱包应在安全上下文(content script)中注入
过度权限请求 dApp 在未告知用户的情况下调用高权限方法(如 eth_sign 钱包应对每个高危方法显示清晰的用户确认弹窗
事件监听泄漏 忘记移除事件监听器,导致内存泄漏和重复触发 使用 removeListener 及时清理;只在需要时注册监听器

EIP-6963 的安全考量

风险说明缓解措施
恶意 announceProvider 恶意脚本派发伪造的 announceProvider 事件,冒充合法钱包 协议本身无法完全防止;用户应辨别钱包名称和图标,dApp 可通过 rdns 做已知钱包白名单校验
Provider 对象篡改 事件 detail 被中间代码修改,Provider 行为被劫持 规范要求 Object.freeze(detail),使 detail 不可变
UUID 碰撞 两个钱包使用相同的 UUID,导致 dApp 错误去重 UUID v4 碰撞概率极低;dApp 可同时使用 rdns 辅助去重
根本限制

EIP-6963 的安全模型依赖于"合法钱包在受信任的浏览器扩展上下文中运行"这一前提。 它无法防止运行在同一页面上下文中的恶意脚本伪造钱包事件。 最终的安全保障来自浏览器的扩展隔离机制,而不是协议本身。


Quick Reference

↑ top
EIP-1193 — Provider 接口
项目说明
核心方法 provider.request({ method, params }) 所有 JSON-RPC 调用的统一入口,返回 Promise
事件订阅 provider.on(event, listener) 监听 connect / disconnect / chainChanged / accountsChanged
错误基类 ProviderRpcError 扩展自 Error,附带 code 字段(4001=拒绝,4100=未授权…)
连接账户 eth_requestAccounts 弹出授权弹窗,返回账户地址数组
获取链 eth_chainId 返回十六进制字符串,如 "0x1"(主网)、"0xc4"(X Layer)
EIP-6963 — 多钱包发现
项目说明
钱包广播事件 eip6963:announceProvider 钱包主动派发,携带 frozen EIP6963ProviderDetail
dApp 请求事件 eip6963:requestProvider dApp 派发,触发所有钱包重新广播
身份字段 rdns 反向域名,全局唯一,用于精确识别钱包(如 "io.metamask"
去重字段 uuid UUID v4,每个钱包实例唯一,用于 Map 去重
不可变要求 Object.freeze(detail) 防止 Provider 对象被篡改,协议安全基础

理解测验

↑ top

Q1. EIP-1193 定义的 request() 方法的正确返回类型是?

Q2. 当用户在 MetaMask 中切换网络时,EIP-1193 规范要求 Provider 派发哪个事件?

Q3. EIP-1193 错误码 4001 代表什么?

Q4. EIP-6963 中 rdns 字段的主要用途是?

Q5. 为什么 EIP-6963 要求钱包广播时使用 Object.freeze(detail)

Q6. dApp 派发 eip6963:requestProvider 事件的主要目的是?

0/6


Summary

↑ top
项目EIP-1193EIP-6963
解决的问题 dApp 与钱包如何通信(接口标准) 多钱包共存时 dApp 如何发现它们(发现协议)
核心机制 provider.request() + 事件系统 announceProvider / requestProvider 双向事件
关键接口 EIP1193Provider:request + on + removeListener EIP6963ProviderDetail:info(uuid/name/icon/rdns)+ provider
错误处理 ProviderRpcError,code 4001/4100/4200/4900/4901 —(使用底层 EIP-1193 错误)
向后兼容 沿用 window.ethereum 单例 推荐优先使用,回退到 window.ethereum
状态 Final(2019) Final(2023)
两个 EIP 的关系

EIP-1193 是通信层,EIP-6963 是发现层。它们不是替代关系,而是叠加关系: 先用 EIP-6963 发现所有可用钱包,用户选择后,用 EIP-1193 和所选钱包进行通信。 EIP-6963 的 detail.provider 直接就是一个 EIP-1193 Provider,无需任何适配。