去中心化应用(Decentralized Application
),简称 Dapp
,其为用户提供了一个直接与区块链系统交互的可视化界面。目前主要以 Web 网页的形式存在。 用户要想与区块链产生交互(例如读取链上数据、发送交易等),首先需要做的就是在 Dapp
中连接钱包。连接钱包通常有以下几种形式:
- 浏览器钱包插件连接
- 手机钱包扫码连接
本篇主要讲解浏览器钱包插件是如何与 Dapp
连接的。除了讲解钱包连接的原理外还将手动实现一个简易版的钱包插件。
钱包插件
首先需要明确一点的是,在整个连接过程中存在两个角色
- 钱包插件: 以
metamask
为例, 你可以从 chrome 插件商店中下载安装。安装完成并创建钱包后,钱包插件保存着钱包私钥以及公链信息, 公链信息包括 链的名称、 链 ID、 RPC 链接 等。 - 前端页面: 与区块链交互的前端网页, 由开发者进行开发
在浏览器安装钱包插件并创建钱包后,打开任意网页时, 钱包插件将会向网页注入 JS。打开控制台可以看到
在浏览器插件中,Content Scripts
是一种特殊的脚本,可以被注入到网页中,能够访问或修改网页内容。
metamask
会向 window
对象添加名为 ethereum
的属性。在 EIP-1193 中称这个属性值为 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-20
、ERC-721
、ERC-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,页面也无法主动调用其中的方法。仅可调用部分插件 APIinjected-script
向页面插入的脚本, 相当于网页中脚本, 无法直接访问插件数据。 由于content-scripts
可以访问页面 DOM, 因此可以在content-scripts
中创建script
标签并插入到页面中。给window
对象添加属性也由该脚本实现。background
插件的后台脚本,常驻在浏览器的生命周期中。popup
插件打开的页面
需要注意的是:popup
和 background
都是运行在插件上下文中,而 content-script
和 injected-script
则是运行在网页的上下文中。因此在这两个上下文中,获取到的 window
对象是不同的。
脚本之间的通信满足如下规则:
-
injected-script
和content-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')
}) -
插件内脚本中
background
和popup
之间的通信// 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)
}
总结如下图所示
插件实现
使用 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
与钱包插件连接的完整过程。