老胡茶室
老胡茶室

MetaMask 非权威开发指南

胡键

用户要想与以太坊交互就离不开钱包。同样的,为用户提供服务的以太坊 dapp 也必须实现与用户钱包交互的功能。否则,它算不上真正的 dapp,顶多成为一个以太坊数据的查看器。在众多钱包之中,不得不提及 MetaMask。不仅仅因为其是目前使用最多的钱包之一,而且还因为它是不少的 EIP 的参考实现。基本上,只要你的 dapp 可以正常跟 MetaMask 交互,那跟其他钱包交互也不会有多少大问题。因此,本文以 MetaMask 为例,谈谈 dapp 开发者必须了解的知识点。

通过本文,你可以了解到:

  • 每个【Connect Wallet】都需要实现的基本功能
  • 支持多 provider
  • 应用加载后自动连接
  • 如何让地址旁出现跟 MM 一样的图标?
  • 理解 permission
  • 如何添加新网络?
  • 如何实现自动切换网络?
  • 如何添加新 Token?
  • 实现钱包登录的一般思路
  • 实现自己的钱包连接界面
  • 移动端和桌面端的差异

同时,也请留意本文不会提及的内容:

每个【Connect Wallet】都需要实现的基本功能

当 dapp 被(已装 MM 插件的)桌面浏览器或钱包浏览器加载后,后者会往页面注入钱包实例。此时,开发者所需完成的工作就是:

  1. 连上它。
  2. 得到当前实例连接到的网络和账户地址。
  3. 注册必要的事件处理函数。

以上三件事就是每个【Connect Wallet】必须实现的基本事项,至于其他的,比如说“若当前连接到的网络不符合预期则报错”,都属于锦上添花。这三件事对应下面的三段代码:

// 获得实例
provider = window.ethereum;

// 连接并获得当前账户地址
const accounts = await ethereum.request({ method: "eth_requestAccounts" });
const account = accounts[0];

// 获得当前链接的 chain id
const chainId = await ethereum.request({ method: "eth_chainId" });

// 注册事件处理函数
provider
  .on("connect", async (connectInfo) => {
    // 处理
  })
  .on("disconnect", (error) => {
    // 处理
  })
  .on("accountsChanged", (accounts) => {
    // 处理
  })
  .on("chainChanged", (chainId) => {
    // 处理
  });

这段代码并不复杂,但有需要留意几点:

  1. 获得 window.ethereum 并不是去连接 MM,而是获得注入的钱包实例。
  2. 真正的连接发生在 eth_requestAccounts 调用时,这对于初学者会有些困惑,但只需注意这一点就好了。即,获取账户地址的过程实际上就是连接钱包的过程。
  3. eth_requestAccounts 并不是返回你在 MM 中创建的所有账户,而仅仅是已连接并未断开的那些账户。
  4. 当前所连账户就是 eth_requestAccounts 返回数组的第一个值。
  5. 若一个账户已连接,除非手动断开,下载加载 dapp 时,它仍处于已连接状态,并且此时再调用 eth_requestAccounts 并不会导致弹出框的出现。
  6. 上面代码中的那些事件是针对 MM 而言,并非针对当个账户而言,即:
    • connect,MM 连上网络,并非指账户连上应用
    • disconnect,MM 断开网络,同上
    • accountsChanged,MM 当前账户改变
    • chainChanged,MM 当前网络改变

显然,上面的代码片段太基础了,用于传授知识点合适,但对于实际开发就有点太简单了。通常,在这个时候我会建议大家直接去使用 Web3Modal

直接使用 Web3Modal 会有这些好处:

  • 直接利用工程化的代码,各位可以对比一下它的 injected.ts 代码,它同样实现了连接 MM,但明眼人一看就知道这个代码更完善。
  • 给 dapp 带来支持多个 provider 的能力,让它一下子就可以同时支持多种连接方式,典型比如:MM、WalletConnect、Torus 等。

当然,Web3Modal 也并非尽善尽美:

  • 它是 react 组件,对 react 有依赖。对于 react 应用还好,对于非 react 应用而言,则有一个多余的依赖。最坏的情况下是无法通过 npm i 安装。
  • 需要预装 provider 包,导致 dapp 体积变大。

但是这些问题并不难化解,采用最朴素的 <script> 引入即可,请参考 Ethers.js 非权威开发指南(上)

支持多 provider

使用 Web3Modal 支持多 provider 的步骤如下:

  1. 安装 web3modal
  2. 安装要支持的 provider
  3. 代码实现

最后一步的代码实现框架如下,以 MM、WalletConnect、Torus 为例。

// 列出要支持的 provider,
// MM 默认支持,不需要单独列出。
const providerOptions = {
  walletconnect: {
    package: WalletConnectProvider,
    options: {
      infuraId: "...",
    },
  },
  torus: {
    package: Torus,
  },
};

// 初始化 web3modal 对象
const web3Modal = new Web3Modal({
  network: "mainnet",
  cacheProvider: true,
  providerOptions,
});

export function isEnabled() {
  return web3Modal.cachedProvider;
}

export async function connect() {
  try {
    const web3ModalProvider = await web3Modal.connect();
    provider = new ethers.providers.Web3Provider(web3ModalProvider);
    registerEthListener(web3ModalProvider);
    updateCurrentStatus(await provider.listAccounts());
  } catch (err) {
    console.log(err);
  }
}

function updateCurrentStatus(accounts) {
  if (accounts.length === 0) {
    console.log("no account");
  } else {
    saveCurrentAccount(accounts[0]);
  }
}

function registerEthListener(web3ModalProvider) {
  web3ModalProvider
    .on("disconnect", (error) => {
      reset();
      location.reload();
    })
    .on("accountsChanged", (accounts) => {
      if (accounts.length === 0) {
        reset();
      } else {
        saveCurrentAccount(accounts[0]);
      }
    })
    .on("chainChanged", (chainId) => {
      console.log("chainChanged", chainId);
    });
}

function reset() {
  web3Modal.clearCachedProvider();
  if (web3Modal.cachedProvider === "walletconnect") {
    localStorage.removeItem("walletconnect");
  }
}

代码中的注意点:

  1. 一旦连过,Web3Modal 会在 localStorage 中保存上次连过的 provider,可用于下次自动重连。这也是为何 isEnabled 以此为判断依据的原因。
  2. 注意 provider 在不同上下文中的含义:
    • ethers provider,eth rpc 连接
    • web3 provider,钱包连接
  3. 钱包事件的监听要针对 web3 provider 而言,也就是代码中的 web3ModalProvider
  4. WalletConnect 本身也会在 localStorage 中保存状态,故 reset 会一并清除。

自动重连

所谓自动重连就是指:dapp 在连过一次钱包之后,下次打开时会自动连上上次连接的钱包。这个功能的实现并不复杂。从前文我们已知:

  • MM 一旦连上,除非手动断开,就会一直保持链接状态。并且,在此情况下在此调用 eth_requestAccounts 不会出现第一次连接时出现的弹出框。
  • Web3Modal 缺省会在 localStorage 中记录状态。

这样一来,利用上面的框架代码,自动重连的功能其实就是在程序加载时调用下面的代码:

if (isEnabled()) {
  await connect();
}

地址图标

细心的朋友会发现某些 dapp 还有更神奇的功能:应用中在地址旁的图标居然跟同一地址在 MM 中的图标一样!显然,一定有相关的 npm 可以做到这一点。猜的没错,它就是 @metamask/jazzicon

安装好之后,调用下面的代码:

import jazzicon from "@metamask/jazzicon";

function jsNumberForAddress(address) {
  const addr = address.slice(2, 10);
  const seed = parseInt(addr, 16);
  return seed;
}

// 在需要的地方调用它
jazzicon(iconWidth, jsNumberForAddress(address));

理解 Permission

Permission 对应于 EIP-2255,其目的在于:为敏感操作提供一个统一和标准化的接口。在首次连接钱包时,弹出的确认对话框其实就是触发了 permission 机制。

与 Permission 对应的主要有两个方法:wallet_getPermissionswallet_requestPermissions。一般情况下,大多数开发者其实用不到它们。但某些情况下,出于 UX 方面的考虑,你可能得通过 permission 来实现某些 trick。

比如这个场景:在使用 Web3Modal 时,为了统一 UX,期望每次在切换 provider 进行连接时,dapp 都有某种提示。通俗来讲,它的需求就是(假设 dapp 同时提供了 MM 和 WalletConnect):

  1. 当选择 MM 时,出现确认对话框。
  2. 当选择 WalletConnect 时,出现二维码对话框。
  3. 当断开 WalletConnect 再次切换到 MM 时,还会出现对话框。
  4. 自动重连时不必如此。

简单吧?但这里隐藏着一个问题:前面说过,除非手动断开,MM 一旦连上它就一直保持连接状态。而在此状态下再次连接 MM 不会出现对话框。也就是说,在断开 WalletConnect 再去连 MM 时,对话框并不会出现。这是由于在第一步已经连过,所以它其实还是保持连接状态,此时再连对话框就不会出现了。

对于上面的需求,只能借助 Permission 实现。在再次重连时,附加下面的代码:

await window.ethereum.request({
  method: "wallet_requestPermissions",
  params: [
    {
      eth_accounts: {},
    },
  ],
});

此时,即使在已连接状态下,对话框依旧会出现。在某种意义上来讲,这算得上是一种“诡计”,因为此时并没有一个实际断开再重连的过程。

添加网络和切换网络

在缺省情况下,MM 已经预置了若干网络,基本上就是 mainnet 加上若干测试网。随着以太坊生态的状态,出现了不少兼容 evm 的其他公链,典型比如 polygon,并且 MM 也对它们提供了支持。那么,假如 dapp 可以实现下面的功能岂不是很酷:

  1. 若发现当前 MM 所连的网络不对,提示用户并提供一个切换网络的链接或按钮。
  2. 在切换时,若发现当前 MM 没有配置该网络,自动给用户添加,然后再完成切换。

这两个步骤分别对应两个 rpc 方法:wallet_switchEthereumChainwallet_addEthereumChain。整个代码框架如下(以 ploygon 和 mumbai 为例):

const networkInfo = {
  mumbai: {
    chainId: "0x13881",
    chainName: "Mumbai",
    nativeCurrency: {
      name: "MATIC",
      symbol: "MATIC",
      decimals: 18,
    },
    rpcUrls: ["https://rpc-mumbai.maticvigil.com"],
    blockExplorerUrls: ["https://mumbai.polygonscan.com/"],
  },
  matic: {
    chainId: "0x89",
    chainName: "Polygon",
    nativeCurrency: {
      name: "MATIC",
      symbol: "MATIC",
      decimals: 18,
    },
    rpcUrls: ["https://rpc-mainnet.maticvigil.com/"],
    blockExplorerUrls: ["https://polygonscan.com/"],
  },
};

export async function switchNetwork(network: string, walletProvider) {
  try {
    await walletProvider.request({
      method: "wallet_switchEthereumChain",
      params: [{ chainId: networkInfo[network].chainId }],
    });
  } catch (error) {
    if (error.code === 4902) {
      return await addNetwork(network, walletProvider);
    }
    throw error;
  }
}

export async function addNetwork(network: string, walletProvider) {
  try {
    return await walletProvider.request({
      method: "wallet_addEthereumChain",
      params: [networkInfo[network]],
    });
  } catch (error) {
    console.error(error);
  }
}

请注意,这里传入的必须是 walletProvider,也就是 Web3Modal 的返回值,而不是 ethers 的 provider。

添加新的 Token

与添加网络一样,添加新 Token 同样是利用 rpc 完成:wallet_watchAsset,这里以文档里的例子来说明:

ethereum.request({
  method: 'wallet_watchAsset',
  params: {
    type: 'ERC20',
    options: {
      address: '0xb60e8dd61c5d32be8058bb8eb970870f07233155',
      symbol: 'FOO',
      decimals: 18,
      image: 'https://foo.io/token-image.svg',
    },
  },
});
  .then((success) => {
    if (success) {
      console.log('FOO successfully added to wallet!')
    } else {
      throw new Error('Something went wrong.')
    }
  })
  .catch(console.error)

MM 还支持其他 rpc 方法,但大多数要么不常用,要么就可以直接通过 ethers provider 来完成,不必直接使用,有兴趣的可以参见文档

使用钱包登录

现在的 dapp 有种趋势就是使用钱包连接来代替传统的登录,其基本流程大致如下:

  • 用户点击连接钱包。
  • 连接完成之后,提示用户完成签名。
  • 签名完毕之后,显示完成登录。

看起来很时髦不是吗。既然所有数据都在链上,跟合约交互也只需钱包授权就行了,用钱包签名替代传统登录似乎是个合理的想法。实话实说,关于这种方式的合理性判断,传统的 “it depends” 要来的更合适。但是本文并不打算就此展开讨论,单就技术实现简单说说。

当下的 web 应用,jwt 应该已经成为了主流。如何要让钱包作为登录手段,替代传统的用户密码登录方式,那么最省事的方式就是能打通签名和 jwt 生成就行了。整个实现思路:

  1. dapp 发起钱包签名。
  2. 签名成功之后,发往后端验证。
  3. 后端验证签名成功之后,生成 jwt 传回。
  4. 未来所有请求都携带此 jwt 以表示已登录。

以上就是实现签名的最简步骤,更复杂的方式基本都是在此之上扩展的。思路讲完,让我们看看实际的代码。

客户端发起签名的代码(基于 ethers),代码中的 provider 是 ethers provider:

export async function sign(message: string, account: string) {
  const signature = await provider.send("personal_sign", [
    ethers.utils.hexlify(ethers.utils.toUtf8Bytes(message)),
    account,
  ]);
  return signature;
}

后端验证并生成 jwt 的代码(基于 ethers + fastify jwt):

// 对应的 url 为:/login/:address,处理 post 请求
async function generateJwtToken(request: FastifyRequest, reply: FastifyReply) {
  const { address } = request.params as any;
  const { message, signature } = request.body as any;

  const addressVerified = ethers.utils.verifyMessage(message, signature);
  if (addressVerified !== address) {
    reply.code(403).send("Invalid signature.");
  } else {
    reply.code(200).send({ jwt: await reply.jwtSign({ address }, { expiresIn: "10s" }) });
  }
}

这里略去了:

  • 客户端发送请求和响应处理
  • fastify controller 和 jwt 插件的配置

以上常规内容并不复杂,请自行解决。对于 ethers 不熟悉的,请参见前面的链接。

实现自己的钱包连接界面

为了满足界面的个性化要求 Web3Modal,提供了自定义主题的功能,参见这里。当然,你也可以做得跟 geek 一些:从 devtool 分析它的 css,然后覆盖它。

但假如你有更高要求呢?典型的如 1inch 的那种:

  1. 用户可以选择不同的网络。
  2. 在选择完网络之后,用户可以继续选择需要使用的 provider。

很遗憾,Web3Modal 无法完成以上需求,除了自行编写代码没有其他办法。但其实也没有想象中那么难,并且我们依旧可以 Web3Modal 的力量来完成这一点。只不过,是从源代码级别上。大体步骤如下:

  1. git clone Web3Modal 的源代码仓库。
  2. 将其 src 下除 component 之外的其他文件都复制出来。
  3. 按需实现自己的界面。

以上做法的思路可以总结为:保留 provider 层,替换 ui 层,即 component 目录的内容。此外,这种方式还有另一个好处:不再依赖 react。Web3Moadal 项目除了 component 的代码依赖 react 之外,其余代码是框架中立的,移出该目录之后,各位大可使用自己喜爱的框架去实现 ui 层。

当然,这种方式对于开发者的代码能力有一定的要求,但好处也显而易见:从此将拥有自己的连接组件,够酷吧,😄。

这个自定义的连接组件的使用方式跟 Web3Modal 也一样:除了 MM 外,需要先安装想要使用的 provider。而且在实现过程中,我们可以参考 Web3Modal 的接口,尽量保持使用方式接近。

移动端和桌面端的差异

最后,简单说说移动端和桌面端的差异。

首先,操作方式上有差异:桌面端上,如 MM,在连上之后,还允许用户自行选择网络和切换账户。但是,典型的移动端钱包的使用方式基本都是:先选择网络和账户,再打开 dapp。

其次,桌面端上 walletProvider 存在的方法在移动端上不一定有,比如事件监听的 on 和 Permission 的 request

第三,某些 provider 在用移动端钱包打开时不可用,比如 WalletConnect。所幸,Web3Modal 会自动帮助我们处理这些。

以上的这些差异可能会要求 dapp 能智能判断,并依据不同的平台显示不同的界面。最典型的情况就是某些菜单项在移动端的时候不显示。

Web3Modal 提供了方便判断当前是桌面端还是移动端的方法:isMobile(),可以利用它来作为判断条件。

此外,还可以在调用函数前先检查所需方法是否存在,若没有则返回即可。比如下面的代码片段:

function registerEthListener(walletProvider) {
  if (!walletProvider || !walletProvider.removeAllListeners || !walletProvider.on) {
    return;
  }

  //其余处理
}

上面的代码更加通用,且没有其他额外的依赖。

总结

对于 dapp 开发者而言,除了熟悉 ethers 之外,对于钱包交互也必不可少。本文则完整覆盖了这方面的主要的场景和知识点,并给出相应的代码示例和实现思路说明,为实现更高级和个性化的需求提供了一个坚实的基础。

付费内容

本文包含付费内容,需要会员权限才能查看完整内容。