跳到主要内容

关于dapp和钱包连接的真相

· 阅读需 14 分钟

去中心化应用(Decentralized Application),简称 Dapp,其为用户提供了一个直接与区块链系统交互的可视化界面。目前主要以 Web 网页的形式存在。 用户要想与区块链产生交互(例如读取链上数据、发送交易等),首先需要做的就是在 Dapp 中连接钱包。连接钱包通常有以下几种形式:

  • 浏览器钱包插件连接
  • 手机钱包扫码连接

本篇主要讲解浏览器钱包插件是如何与 Dapp 连接的。除了讲解钱包连接的原理外还将手动实现一个简易版的钱包插件。

钱包插件

首先需要明确一点的是,在整个连接过程中存在两个角色

  • 钱包插件: 以 metamask 为例, 你可以从 chrome 插件商店中下载安装。安装完成并创建钱包后,钱包插件保存着钱包私钥以及公链信息, 公链信息包括 链的名称、 链 ID、 RPC 链接 等。
  • 前端页面: 与区块链交互的前端网页, 由开发者进行开发

在浏览器安装钱包插件并创建钱包后,打开任意网页时, 钱包插件将会向网页注入 JS。打开控制台可以看到

content-scripts

在浏览器插件中,Content Scripts 是一种特殊的脚本,可以被注入到网页中,能够访问或修改网页内容。

metamask 会向 window 对象添加名为 ethereum 的属性。在 EIP-1193 中称这个属性值为 Provider

provider

Provider

本质为一个 JS 对象,在网页中可以通过 window.ethereum 获取。EIP-1193 规定了 Provider 对象的能力:

  • 发送 RPC 请求
  • 事件监听: 响应链、客户端和钱包的状态变化

发送 RPC 请求

Provider 提供 request 方法用于向钱包插件发送请求, 请求方法定义在以下标准中

  • EIP-1474: 标准 RPC 方法列表, 用户向区块链节点发送请求
  • EIP-1102: 新增 RPC 方法 eth_requestAccounts, 允许用户批准或拒绝给 Dapp 的哪些帐户的访问权限, 返回可供 Dapp 访问你的账号地址列表
  • EIP-3085: 新增 RPC 方法 wallet_addEthereumChain, 用于添加网络
  • EIP-3326: 新增 RPC 方法 wallet_switchEthereumChain, 用于切换网络
  • EIP-747: 新增 RPC 方法 wallet_watchAsset, 用于向钱包添加 token, 支持 ERC-20ERC-721ERC-1155
  • EIP-2255: 钱包权限系统, 新增 RPC 方法wallet_requestPermissions 请求钱包权限授予给 Dapp, 例如查看账号信息的权限。Dapp 获取权限后,下次则无需询问用户。wallet_getPermissions 可查看已授予的权限。

在如下示例中, 你可以复制代码到浏览器的开发者工具中:

请求连接

// 获取 provider
const provider = window.ethereum

// 请求连接钱包, 钱包插件通常会弹窗让用户选择要连接的账户地址, 并返回账户地址列表
// 一旦连接完成, 将会持久化连接数据, 用于下一次的自动连接
provider.request({ method: 'eth_requestAccounts' }).then((accounts) => {
console.log(accounts)
})

发送 rpc 请求

// 获取 chainId
provider.request({ method: 'eth_chainId' }).then((chainId) => {
console.log(`chainId: ${chainId}`)
})

添加网络

// 添加 Gnosis 网络
provider.request({
method: 'wallet_addEthereumChain',
params: [
{
chainId: '0x64',
chainName: 'Gnosis',
rpcUrls: ['https://rpc.ankr.com/gnosis'],
iconUrls: [
'https://xdaichain.com/fake/example/url/xdai.svg',
'https://xdaichain.com/fake/example/url/xdai.png'
],
nativeCurrency: {
name: 'xDAI',
symbol: 'xDAI',
decimals: 18
},
blockExplorerUrls: ['https://blockscout.com/poa/xdai/']
}
]
})

切换网络

// 切换到 polygon 网络
provider
.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: '0x89' }]
})
.then((chainId) => {
console.log(`chainId: ${chainId}`)
})

添加 token

provider.request({
method: 'wallet_watchAsset',
params: {
type: 'ERC20',
options: {
address: '0xb60e8dd61c5d32be8058bb8eb970870f07233155',
symbol: 'FOO',
decimals: 18,
image: 'https://foo.io/token-image.svg'
}
}
})

如果请求出错, 则抛出下列结构的错误

interface ProviderRpcError extends Error {
code: number
data?: unknown
}

code 有以下取值

  • 4001: User Rejected Request(用户拒绝)
  • 4100: Unauthorized(请求方法未授权)
  • 4200: Unsupported Method(不支持的方法)
  • 4900: Disconnected(Provider 已断开与所有链的连接)
  • 4901: Chain Disconnected(Provider 未连接到目标链)

事件监听

Provider 提供了 on 方法,用于监听钱包插件发送的事件。可监听的事件有

  • connect
  • disconnect
  • chainChanged
  • accountsChanged
  • message

connect

如果 Provider 变为已连接状态,则发出 connect 事件, 首次触发时机在调用了 provider.request({ method: 'eth_requestAccounts' }) 方法之后

interface ProviderConnectInfo {
readonly chainId: string
}

Provider.on('connect', listener: (connectInfo: ProviderConnectInfo) => void): Provider;

disconnect

如果 Provider 与所有链断开连接,Provider 按照 RPC 错误部分中定义的接口发出名为 disconnect 的事件,并附带值 error: ProviderRpcError

Provider.on('disconnect', listener: (error: ProviderRpcError) => void): Provider;

chainChanged

如果连接到的链发生变化,Provider 触发 chainChanged的事件

Provider.on('chainChanged', listener: (chainId: string) => void): Provider;

accountsChanged

如果 Provider 可用的账户发生变化,触发 accountsChanged 的事件,并附带值 accounts: string[],为 eth_accounts RPC 方法返回的账户地址。

Provider.on('accountsChanged', listener: (accounts: string[]) => void): Provider;

message

message 事件用于未涵盖其他事件的任意事件

interface ProviderMessage {
readonly type: string
readonly data: unknown
}
Provider.on('message', listener: (message: ProviderMessage) => void): Provider;

示例:

// 获取 provider
const provider = window.ethereum

// 请求连接
provider.request({ method: 'eth_requestAccounts' }).then((accounts) => {
console.log(`init accounts: ${accounts}`)
})

// 连接完成后触发; 钱包内手动切换/断开也会触发
provider.on('accountsChanged', (accounts) => {
console.log(`changed accounts: ${accounts}`)
})

// 切换公链时触发
provider.on('chainChanged', (chainId) =>
console.log(`current chainId: ${chainId}`)
)

钱包冲突

EIP-1193 规定了 Provider 是绑定在 window.ethereum 上的,如果多家钱包插件开发商都绑定在该属性上,那么在用户安装多个钱包后,注入网页的脚本文件执行时必然会出现 window.ethereum 上的值被覆盖的情况(根据钱包脚本的加载顺序,只会保留最后一个执行的钱包),也会导致无法让用户选择想要使用的钱包。为了解决这个问题,提出了EIP-6963

这项标准提出钱包开发商需要使用名为 EIP6963ProviderInfo 的接口开公开自己。

interface EIP6963ProviderInfo {
uuid: string // 唯一ID
name: string // 名称
icon: string // 图标
rdns: string // 反向域名标识符(域名反写,如 com.google)
}

interface EIP6963ProviderDetail {
info: EIP6963ProviderInfo
provider: EIP1193Provider // provider信息
}

钱包和 Dapp 之间会发送一个事件来识别彼此的存在。

// 钱包发送的事件
interface EIP6963AnnounceProviderEvent extends CustomEvent {
type: 'eip6963:announceProvider'
detail: EIP6963ProviderDetail
}

// Dapp 发送的事件
interface EIP6963RequestProviderEvent extends Event {
type: 'eip6963:requestProvider'
}

EIP-6963 标准下,通信流程变为了

  • 钱包插件
    • 监听 eip6963:requestProvider 事件, 当收到该事件时, 触发 EIP6963AnnounceProviderEvent 事件,将钱包的 EIP6963ProviderDetail 信息发送给 Dapp
    • 钱包加载完成时,主动触发 EIP6963AnnounceProviderEvent 事件。避免因钱包脚本未加载时导致未能监听到eip6963:requestProvider 事件时导致的错误。
  • Dapp: 监听 eip6963:announceProvider 事件获取 Provider, 或者主动触发eip6963:requestProvider 事件获取 Provider

当每个钱包插件都发送 EIP6963AnnounceProviderEvent 时, Dapp 就能知道本地已安装的哪些插件钱包。就能给用户一个选择使用哪个钱包插件的权利

实现简易版钱包插件

基础介绍

实现钱包插件前, 我们需要先了解一下插件开发的基础知识。

Chrome 插件涉及以下几个部分:

  • manifest.json 插件配置文件
  • content-scripts 向打开的页面注入的脚本, 可访问页面 DOM。但是不可访问页面 JS,页面也无法主动调用其中的方法。仅可调用部分插件 API
  • injected-script 向页面插入的脚本, 相当于网页中脚本, 无法直接访问插件数据。 由于 content-scripts 可以访问页面 DOM, 因此可以在content-scripts 中创建 script 标签并插入到页面中。给 window 对象添加属性也由该脚本实现。
  • background 插件的后台脚本,常驻在浏览器的生命周期中。
  • popup 插件打开的页面

需要注意的是:popupbackground 都是运行在插件上下文中,而 content-scriptinjected-script 则是运行在网页的上下文中。因此在这两个上下文中,获取到的 window 对象是不同的。

脚本之间的通信满足如下规则:

  • injected-scriptcontent-scripts之间发送消息使用 window.postMessage, 接收消息使用 window.addEventListener('message', listener)

  • injected-script 和插件内脚本的通信需要 content-scripts 作为中间介质。

  • content-scripts 向插件内脚本发送消息使用 chrome.runtime.sendMessage, 插件内脚本接收消息使用 chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {})

  • 插件内脚本主动向 content-scripts 发送消息使用chrome.tabs.sendMessage, content-scripts接收消息使用 chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {})

    background 向c ontentscript 发送消息
    // background.js
    chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
    chrome.tabs.sendMessage(tabs[0].id, message, function (response) {
    // 接收到来自 contentscript 响应的消息
    console.log('receive response')
    })
    })
    // contentscript.js
    chrome.runtime.onMessage.addListener(function (
    request,
    sender,
    sendResponse
    ) {
    // 想应消息
    sendResponse('reveive message')
    })
  • 插件内脚本中 backgroundpopup 之间的通信

    // popup 调用 background
    const bg = chrome.extension.getBackgroundPage()
    bg.xxx()

    // background 调用 popup
    const views = chrome.extension.getViews({ type: 'popup' })
    if (views.length > 0) {
    console.log(views[0].location.href)
    }

总结如下图所示

message.png

插件实现

使用 chrome 插件开发模板 chrome-extension-typescript-starter 创建名为 easy-wallet 项目

修改 public/manifest.json

{
"name": "Easy Wallet",
"description": "a simple wallet chrome extension",
"web_accessible_resources": [
{
"resources": ["js/inpage.js"],
"matches": ["<all_urls>"]
}
]
}

创建 inpage.ts 作为向网页中插入的 js 文件

export type RequestArguments = {
method: string
params?: unknown[] | Record<string, unknown>
}

export type PostMessageStream = {
target: string
data: RequestArguments
}

// 为了避免和 window.ethereum 冲突 此处暂时用 easy 变量
window.easy = {
request: (args: RequestArguments) => {
// 发送消息给 content script
window.postMessage(
{
target: 'easywallet_contentscript',
data: args
},
window.location.origin
)
return new Promise((resolve, reject) => {
const listener = (event: MessageEvent<PostMessageStream>) => {
if (event.data.target === 'easywallet_inpage') {
window.removeEventListener('message', listener)
resolve(event.data.data)
}
}
// 监听 content script 的消息
window.addEventListener('message', listener)
})
}
}

content_script.tsx 中创建 script 标签, 内容为 inpage.js, 并添加监听消息的代码

// 插入 script 标签
function injectScript() {
try {
const script = document.createElement('script')
// script.textContent = ``
script.src = chrome.runtime.getURL('js/inpage.js')
script.setAttribute('async', 'false')
const head = document.head || document.documentElement

head.insertBefore(script, head.children[0])
head.removeChild(script)
} catch (error) {
console.error('Provider injection failed.', error)
}
}

injectScript()

// 监听来自 inpage 中消息
window.addEventListener('message', (event: MessageEvent<PostMessageStream>) => {
const { data } = event.data
if (event.data.target === 'easywallet_contentscript') {
// 发送消息到插件脚本中获取数据
chrome.runtime.sendMessage(
{
target: 'easywallet_background',
data: data
},
(response) => {
// 接收到来自插件脚本中的消息 并通知给 inpage
window.postMessage(
{
target: 'easywallet_inpage',
data: response
},
window.location.origin
)
}
)
}
})

background.ts 中接收来自 content_script.tsx 中消息

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.target === 'easywallet_background') {
const { data } = message

if (data.method === 'eth_requestAccounts') {
// 读取存储中钱包账号数据
chrome.storage.local.get(['accounts'], async (result) => {
sendResponse(result.accounts.map((account: any) => account.address))
})
} else if (data.method === 'eth_accounts') {
// 读取存储中记录的已连接网站的账号数据
} else {
// 获取链信息发送 rpc 请求
chrome.storage.local.get(['goerli'], async (result) => {
const chainInfo = result.goerli
const response = await fetch(chainInfo.rpc, {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
method: data.method,
params: data.params || [],
id: rpcIndex
})
}).then((res) => res.json())

// 结果发送给 content script
sendResponse(response)
})
}
return true
}
})

完整代码见 easy-wallet

接着运行下面的命令

npm i
npm run watch

打开 chrome, 地址栏输入 chrome://extensions/, 打开页面右上角开发者模式。此时页面左侧会出现加载以解压的拓展程序,点击后选择项目根目录下 dist 目录。接着打开任意网页下的开发者工具,输入

window.easy
.request({
method: 'eth_chainId'
})
.then((res) => console.log(res))

可以看到如下输出

{ "jsonrpc": "2.0", "id": 1, "result": "0x5" }

至此我们便彻底了解了 Dapp 与钱包插件连接的完整过程。