{"content":{"title":"关于dapp和钱包连接的真相","body":"去中心化应用（`Decentralized Application`），简称 `Dapp`，其为用户提供了一个直接与区块链系统交互的可视化界面。目前主要以 Web 网页的形式存在。 用户要想与区块链产生交互(例如读取链上数据、发送交易等)，首先需要做的就是在 `Dapp` 中连接钱包。连接钱包通常有以下几种形式:\r\n\r\n- 浏览器钱包插件连接\r\n- 手机钱包扫码连接\r\n\r\n本篇主要讲解浏览器钱包插件是如何与 `Dapp` 连接的。除了讲解钱包连接的原理外还将手动实现一个简易版的钱包插件。\r\n\r\n## 钱包插件\r\n\r\n首先需要明确一点的是，在整个连接过程中存在两个角色\r\n\r\n- 钱包插件: 以 `metamask` 为例, 你可以从 chrome 插件商店中下载安装。安装完成并创建钱包后，钱包插件保存着钱包私钥以及公链信息, 公链信息包括 链的名称、 链 ID、 RPC 链接 等。\r\n- 前端页面: 与区块链交互的前端网页, 由开发者进行开发\r\n\r\n在浏览器安装钱包插件并创建钱包后，打开任意网页时, 钱包插件将会向网页注入 JS。打开控制台可以看到\r\n\r\n![content-scripts.png](https://img.learnblockchain.cn/attachments/2024/01/K3nGnhXL65ae986be5106.png)\r\n\r\n在浏览器插件中，`Content Scripts` 是一种特殊的脚本，可以被注入到网页中，能够访问或修改网页内容。\r\n\r\n`metamask` 会向 `window` 对象添加名为 `ethereum` 的属性。在 [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) 中称这个属性值为 `Provider`\r\n\r\n\r\n![provider.png](https://img.learnblockchain.cn/attachments/2024/01/gcaRfvWO65ae9876954b3.png)\r\n\r\n## Provider\r\n\r\n本质为一个 `JS` 对象，在网页中可以通过 `window.ethereum` 获取。[EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) 规定了 `Provider` 对象的能力:\r\n\r\n- 发送 RPC 请求\r\n- 事件监听: 响应链、客户端和钱包的状态变化\r\n\r\n### 发送 RPC 请求\r\n\r\n`Provider` 提供 `request` 方法用于向钱包插件发送请求, 请求方法定义在以下标准中\r\n\r\n- [EIP-1474](https://eips.ethereum.org/EIPS/eip-1474): 标准 RPC 方法列表, 用户向区块链节点发送请求\r\n- [EIP-1102](https://eips.ethereum.org/EIPS/eip-1102): 新增 RPC 方法 `eth_requestAccounts`, 允许用户批准或拒绝给 `Dapp` 的哪些帐户的访问权限, 返回可供 `Dapp` 访问你的账号地址列表\r\n- [EIP-3085](https://eips.ethereum.org/EIPS/eip-3085): 新增 RPC 方法 `wallet_addEthereumChain`， 用于添加网络\r\n- [EIP-3326](https://eips.ethereum.org/EIPS/eip-3326): 新增 RPC 方法 `wallet_switchEthereumChain`, 用于切换网络\r\n- [EIP-747](https://eips.ethereum.org/EIPS/eip-747): 新增 RPC 方法 `wallet_watchAsset`， 用于向钱包添加 `token`, 支持 `ERC-20` 、`ERC-721`、`ERC-1155`\r\n- [EIP-2255](https://eips.ethereum.org/EIPS/eip-2255): 钱包权限系统, 新增 `RPC` 方法`wallet_requestPermissions` 请求钱包权限授予给 `Dapp`, 例如**查看账号信息**的权限。`Dapp` 获取权限后，下次则无需询问用户。`wallet_getPermissions` 可查看已授予的权限。\r\n\r\n在如下示例中, 你可以复制代码到浏览器的开发者工具中:\r\n\r\n**请求连接**\r\n\r\n```ts showLineNumbers\r\n// 获取 provider\r\nconst provider = window.ethereum\r\n\r\n// 请求连接钱包, 钱包插件通常会弹窗让用户选择要连接的账户地址, 并返回账户地址列表\r\n// 一旦连接完成, 将会持久化连接数据， 用于下一次的自动连接\r\nprovider.request({ method: 'eth_requestAccounts' }).then((accounts) => {\r\n  console.log(accounts)\r\n})\r\n```\r\n\r\n**发送 rpc 请求**\r\n\r\n```ts showLineNumbers\r\n// 获取 chainId\r\nprovider.request({ method: 'eth_chainId' }).then((chainId) => {\r\n  console.log(`chainId: ${chainId}`)\r\n})\r\n```\r\n\r\n**添加网络**\r\n\r\n```ts showLineNumbers\r\n// 添加 Gnosis 网络\r\nprovider.request({\r\n  method: 'wallet_addEthereumChain',\r\n  params: [\r\n    {\r\n      chainId: '0x64',\r\n      chainName: 'Gnosis',\r\n      rpcUrls: ['https://rpc.ankr.com/gnosis'],\r\n      iconUrls: [\r\n        'https://xdaichain.com/fake/example/url/xdai.svg',\r\n        'https://xdaichain.com/fake/example/url/xdai.png'\r\n      ],\r\n      nativeCurrency: {\r\n        name: 'xDAI',\r\n        symbol: 'xDAI',\r\n        decimals: 18\r\n      },\r\n      blockExplorerUrls: ['https://blockscout.com/poa/xdai/']\r\n    }\r\n  ]\r\n})\r\n```\r\n\r\n**切换网络**\r\n\r\n```ts showLineNumbers\r\n// 切换到 polygon 网络\r\nprovider\r\n  .request({\r\n    method: 'wallet_switchEthereumChain',\r\n    params: [{ chainId: '0x89' }]\r\n  })\r\n  .then((chainId) => {\r\n    console.log(`chainId: ${chainId}`)\r\n  })\r\n```\r\n\r\n**添加 token**\r\n\r\n```ts showLineNumbers\r\nprovider.request({\r\n  method: 'wallet_watchAsset',\r\n  params: {\r\n    type: 'ERC20',\r\n    options: {\r\n      address: '0xb60e8dd61c5d32be8058bb8eb970870f07233155',\r\n      symbol: 'FOO',\r\n      decimals: 18,\r\n      image: 'https://foo.io/token-image.svg'\r\n    }\r\n  }\r\n})\r\n```\r\n\r\n如果请求出错, 则抛出下列结构的错误\r\n\r\n```ts showLineNumbers\r\ninterface ProviderRpcError extends Error {\r\n  code: number\r\n  data?: unknown\r\n}\r\n```\r\n\r\n`code` 有以下取值\r\n\r\n- `4001`: User Rejected Request(用户拒绝)\r\n- `4100`: Unauthorized(请求方法未授权)\r\n- `4200`: Unsupported Method(不支持的方法)\r\n- `4900`: Disconnected(Provider 已断开与所有链的连接)\r\n- `4901`: Chain Disconnected(Provider 未连接到目标链)\r\n\r\n### 事件监听\r\n\r\n`Provider` 提供了 `on` 方法，用于监听钱包插件发送的事件。可监听的事件有\r\n\r\n- `connect`\r\n- `disconnect`\r\n- `chainChanged`\r\n- `accountsChanged`\r\n- `message`\r\n\r\n#### connect\r\n\r\n如果 `Provider` 变为已连接状态，则发出 `connect` 事件， 首次触发时机在调用了 `provider.request({ method: 'eth_requestAccounts' })` 方法之后\r\n\r\n```ts\r\ninterface ProviderConnectInfo {\r\n  readonly chainId: string\r\n}\r\n\r\nProvider.on('connect', listener: (connectInfo: ProviderConnectInfo) => void): Provider;\r\n```\r\n\r\n#### disconnect\r\n\r\n如果 `Provider` 与所有链断开连接，`Provider` 按照 `RPC` 错误部分中定义的接口发出名为 `disconnect` 的事件，并附带值 `error: ProviderRpcError`\r\n\r\n```ts\r\nProvider.on('disconnect', listener: (error: ProviderRpcError) => void): Provider;\r\n```\r\n\r\n#### chainChanged\r\n\r\n如果连接到的链发生变化，`Provider` 触发 `chainChanged`的事件\r\n\r\n```ts\r\nProvider.on('chainChanged', listener: (chainId: string) => void): Provider;\r\n```\r\n\r\n#### accountsChanged\r\n\r\n如果 `Provider` 可用的账户发生变化，触发 `accountsChanged` 的事件，并附带值 `accounts: string[]`，为 `eth_accounts` RPC 方法返回的账户地址。\r\n\r\n```ts\r\nProvider.on('accountsChanged', listener: (accounts: string[]) => void): Provider;\r\n```\r\n\r\n#### message\r\n\r\n`message` 事件用于未涵盖其他事件的任意事件\r\n\r\n```\r\ninterface ProviderMessage {\r\n  readonly type: string\r\n  readonly data: unknown\r\n}\r\nProvider.on('message', listener: (message: ProviderMessage) => void): Provider;\r\n```\r\n\r\n示例:\r\n\r\n```ts\r\n// 获取 provider\r\nconst provider = window.ethereum\r\n\r\n// 请求连接\r\nprovider.request({ method: 'eth_requestAccounts' }).then((accounts) => {\r\n  console.log(`init accounts: ${accounts}`)\r\n})\r\n\r\n// 连接完成后触发; 钱包内手动切换/断开也会触发\r\nprovider.on('accountsChanged', (accounts) => {\r\n  console.log(`changed accounts: ${accounts}`)\r\n})\r\n\r\n// 切换公链时触发\r\nprovider.on('chainChanged', (chainId) =>\r\n  console.log(`current chainId: ${chainId}`)\r\n)\r\n```\r\n\r\n## 钱包冲突\r\n\r\n`EIP-1193` 规定了 `Provider` 是绑定在 `window.ethereum` 上的，如果多家钱包插件开发商都绑定在该属性上，那么在用户安装多个钱包后，注入网页的脚本文件执行时必然会出现 `window.ethereum` 上的值被覆盖的情况（根据钱包脚本的加载顺序，只会保留最后一个执行的钱包），也会导致无法让用户选择想要使用的钱包。为了解决这个问题，提出了[EIP-6963](https://eips.ethereum.org/EIPS/eip-6963)\r\n\r\n这项标准提出钱包开发商需要使用名为 `EIP6963ProviderInfo` 的接口开公开自己。\r\n\r\n```ts\r\ninterface EIP6963ProviderInfo {\r\n  uuid: string // 唯一ID\r\n  name: string // 名称\r\n  icon: string // 图标\r\n  rdns: string // 反向域名标识符(域名反写,如 com.google)\r\n}\r\n\r\ninterface EIP6963ProviderDetail {\r\n  info: EIP6963ProviderInfo\r\n  provider: EIP1193Provider // provider信息\r\n}\r\n```\r\n\r\n钱包和 `Dapp` 之间会发送一个事件来识别彼此的存在。\r\n\r\n```ts\r\n// 钱包发送的事件\r\ninterface EIP6963AnnounceProviderEvent extends CustomEvent {\r\n  type: 'eip6963:announceProvider'\r\n  detail: EIP6963ProviderDetail\r\n}\r\n\r\n// Dapp 发送的事件\r\ninterface EIP6963RequestProviderEvent extends Event {\r\n  type: 'eip6963:requestProvider'\r\n}\r\n```\r\n\r\n在 `EIP-6963` 标准下，通信流程变为了\r\n\r\n- 钱包插件\r\n  - 监听 `eip6963:requestProvider` 事件, 当收到该事件时, 触发 `EIP6963AnnounceProviderEvent` 事件，将钱包的 `EIP6963ProviderDetail` 信息发送给 `Dapp`\r\n  - 钱包加载完成时，主动触发 `EIP6963AnnounceProviderEvent` 事件。避免因钱包脚本未加载时导致未能监听到`eip6963:requestProvider` 事件时导致的错误。\r\n- `Dapp`: 监听 `eip6963:announceProvider` 事件获取 `Provider`, 或者主动触发`eip6963:requestProvider` 事件获取 `Provider`\r\n\r\n当每个钱包插件都发送 `EIP6963AnnounceProviderEvent` 时, `Dapp` 就能知道本地已安装的哪些插件钱包。就能给用户一个选择使用哪个钱包插件的权利\r\n\r\n## 实现简易版钱包插件\r\n\r\n### 基础介绍\r\n\r\n实现钱包插件前, 我们需要先了解一下插件开发的基础知识。\r\n\r\nChrome 插件涉及以下几个部分:\r\n\r\n- `manifest.json` 插件配置文件\r\n- `content-scripts` 向打开的页面注入的脚本, 可访问页面 DOM。但是不可访问页面 JS，页面也无法主动调用其中的方法。仅可调用部分插件 API\r\n- `injected-script` 向页面插入的脚本, 相当于网页中脚本, 无法直接访问插件数据。 由于 `content-scripts` 可以访问页面 DOM, 因此可以在`content-scripts` 中创建 `script` 标签并插入到页面中。给 `window` 对象添加属性也由该脚本实现。\r\n- `background` 插件的后台脚本，常驻在浏览器的生命周期中。\r\n- `popup` 插件打开的页面\r\n\r\n需要注意的是：`popup` 和 `background` 都是运行在插件上下文中，而 `content-script` 和 `injected-script` 则是运行在网页的上下文中。因此在这两个上下文中，获取到的 `window` 对象是不同的。\r\n\r\n脚本之间的通信满足如下规则:\r\n\r\n- `injected-script` 和 `content-scripts`之间发送消息使用 `window.postMessage`, 接收消息使用 `window.addEventListener('message', listener)`\r\n- `injected-script` 和插件内脚本的通信需要 `content-scripts` 作为中间介质。\r\n- `content-scripts` 向插件内脚本发送消息使用 `chrome.runtime.sendMessage`, 插件内脚本接收消息使用 `chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {})`\r\n- 插件内脚本主动向 `content-scripts` 发送消息使用`chrome.tabs.sendMessage`, `content-scripts`接收消息使用 `chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {})`\r\n  ```ts title=\"background 向c ontentscript 发送消息\"\r\n  // background.js\r\n  chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {\r\n    chrome.tabs.sendMessage(tabs[0].id, message, function (response) {\r\n      // 接收到来自 contentscript 响应的消息\r\n      console.log('receive response')\r\n    })\r\n  })\r\n  // contentscript.js\r\n  chrome.runtime.onMessage.addListener(function (\r\n    request,\r\n    sender,\r\n    sendResponse\r\n  ) {\r\n    // 想应消息\r\n    sendResponse('reveive message')\r\n  })\r\n  ```\r\n- 插件内脚本中 `background` 和 `popup` 之间的通信\r\n\r\n  ```ts\r\n  // popup 调用 background\r\n  const bg = chrome.extension.getBackgroundPage()\r\n  bg.xxx()\r\n\r\n  // background 调用 popup\r\n  const views = chrome.extension.getViews({ type: 'popup' })\r\n  if (views.length > 0) {\r\n    console.log(views[0].location.href)\r\n  }\r\n  ```\r\n\r\n总结如下图所示\r\n\r\n\r\n![message.png](https://img.learnblockchain.cn/attachments/2024/01/Gqx2Z6Yj65ae988652d2b.png)\r\n\r\n### 插件实现\r\n\r\n使用 chrome 插件开发模板 [chrome-extension-typescript-starter](https://github.com/chibat/chrome-extension-typescript-starter/) 创建名为 `easy-wallet` 项目\r\n\r\n修改 `public/manifest.json`\r\n\r\n```json\r\n{\r\n  \"name\": \"Easy Wallet\",\r\n  \"description\": \"a simple wallet chrome extension\",\r\n  \"web_accessible_resources\": [\r\n    {\r\n      \"resources\": [\"js/inpage.js\"],\r\n      \"matches\": [\"<all_urls>\"]\r\n    }\r\n  ]\r\n}\r\n```\r\n\r\n创建 `inpage.ts` 作为向网页中插入的 js 文件\r\n\r\n```ts\r\nexport type RequestArguments = {\r\n  method: string\r\n  params?: unknown[] | Record<string, unknown>\r\n}\r\n\r\nexport type PostMessageStream = {\r\n  target: string\r\n  data: RequestArguments\r\n}\r\n\r\n// 为了避免和 window.ethereum 冲突 此处暂时用 easy 变量\r\nwindow.easy = {\r\n  request: (args: RequestArguments) => {\r\n    // 发送消息给 content script\r\n    window.postMessage(\r\n      {\r\n        target: 'easywallet_contentscript',\r\n        data: args\r\n      },\r\n      window.location.origin\r\n    )\r\n    return new Promise((resolve, reject) => {\r\n      const listener = (event: MessageEvent<PostMessageStream>) => {\r\n        if (event.data.target === 'easywallet_inpage') {\r\n          window.removeEventListener('message', listener)\r\n          resolve(event.data.data)\r\n        }\r\n      }\r\n      // 监听 content script 的消息\r\n      window.addEventListener('message', listener)\r\n    })\r\n  }\r\n}\r\n```\r\n\r\n在 `content_script.tsx` 中创建 `script` 标签, 内容为 `inpage.js`, 并添加监听消息的代码\r\n\r\n```ts\r\n// 插入 script 标签\r\nfunction injectScript() {\r\n  try {\r\n    const script = document.createElement('script')\r\n    // script.textContent = ``\r\n    script.src = chrome.runtime.getURL('js/inpage.js')\r\n    script.setAttribute('async', 'false')\r\n    const head = document.head || document.documentElement\r\n\r\n    head.insertBefore(script, head.children[0])\r\n    head.removeChild(script)\r\n  } catch (error) {\r\n    console.error('Provider injection failed.', error)\r\n  }\r\n}\r\n\r\ninjectScript()\r\n\r\n// 监听来自 inpage 中消息\r\nwindow.addEventListener('message', (event: MessageEvent<PostMessageStream>) => {\r\n  const { data } = event.data\r\n  if (event.data.target === 'easywallet_contentscript') {\r\n    // 发送消息到插件脚本中获取数据\r\n    chrome.runtime.sendMessage(\r\n      {\r\n        target: 'easywallet_background',\r\n        data: data\r\n      },\r\n      (response) => {\r\n        // 接收到来自插件脚本中的消息 并通知给 inpage\r\n        window.postMessage(\r\n          {\r\n            target: 'easywallet_inpage',\r\n            data: response\r\n          },\r\n          window.location.origin\r\n        )\r\n      }\r\n    )\r\n  }\r\n})\r\n```\r\n\r\n在 `background.ts` 中接收来自 `content_script.tsx` 中消息\r\n\r\n```ts\r\nchrome.runtime.onMessage.addListener((message, sender, sendResponse) => {\r\n  if (message.target === 'easywallet_background') {\r\n    const { data } = message\r\n\r\n    if (data.method === 'eth_requestAccounts') {\r\n      // 读取存储中钱包账号数据\r\n      chrome.storage.local.get(['accounts'], async (result) => {\r\n        sendResponse(result.accounts.map((account: any) => account.address))\r\n      })\r\n    } else if (data.method === 'eth_accounts') {\r\n      // 读取存储中记录的已连接网站的账号数据\r\n    } else {\r\n      // 获取链信息发送 rpc 请求\r\n      chrome.storage.local.get(['goerli'], async (result) => {\r\n        const chainInfo = result.goerli\r\n        const response = await fetch(chainInfo.rpc, {\r\n          method: 'POST',\r\n          body: JSON.stringify({\r\n            jsonrpc: '2.0',\r\n            method: data.method,\r\n            params: data.params || [],\r\n            id: rpcIndex\r\n          })\r\n        }).then((res) => res.json())\r\n\r\n        // 结果发送给 content script\r\n        sendResponse(response)\r\n      })\r\n    }\r\n    return true\r\n  }\r\n})\r\n```\r\n\r\n完整代码见 [easy-wallet](https://github.com/lybenson/easy-wallet)\r\n\r\n接着运行下面的命令\r\n\r\n```shell\r\nnpm i\r\nnpm run watch\r\n```\r\n\r\n打开 `chrome`, 地址栏输入 `chrome://extensions/`, 打开页面右上角开发者模式。此时页面左侧会出现加载以解压的拓展程序，点击后选择项目根目录下 `dist` 目录。接着打开任意网页下的开发者工具，输入\r\n\r\n```ts\r\nwindow.easy\r\n  .request({\r\n    method: 'eth_chainId'\r\n  })\r\n  .then((res) => console.log(res))\r\n```\r\n\r\n可以看到如下输出\r\n\r\n```json\r\n{ \"jsonrpc\": \"2.0\", \"id\": 1, \"result\": \"0x5\" }\r\n```\r\n\r\n至此我们便彻底了解了 `Dapp` 与钱包插件连接的完整过程。"},"author":{"user":"https://learnblockchain.cn/people/9647","address":null},"history":null,"timestamp":1705941185,"version":1}