关于dapp和钱包连接的真相

去中心化应用(DecentralizedApplication),简称Dapp,其为用户提供了一个直接与区块链系统交互的可视化界面。

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

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

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

钱包插件

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

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

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

content-scripts.png

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

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

provider.png

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

点赞 2
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
午时已到
午时已到
0xB483...3cD2
江湖只有他的大名,没有他的介绍。