关于dapp和钱包连接的真相

关于dapp和钱包连接的真相

去中心化应用(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,页面也无法主动调用其中的方法。仅可调用部分插件 API

injected-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.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 与钱包插件连接的完整过程。

相关推荐

渠道整合+秒回快办+实时跟进 “企呼我应”机制让涉企问题“件件有着落”
魔兽世界克罗卡斯位置
365足球平台是合法的吗

魔兽世界克罗卡斯位置

📅 07-15 👁️ 5593
枳壳种子价格及种植方法 枳壳有哪些品种
365bet苹果版

枳壳种子价格及种植方法 枳壳有哪些品种

📅 11-21 👁️ 5154