老胡茶室
老胡茶室

Ethers.js 非权威开发指南(上)

胡键

我曾经不止一次在以前的文章中鼓励大家在开发 dapp 时使用 Ethers.js,但时至今日却还没有一篇专门的文章来介绍它,这多少有些说不过去。于是,我决定用两篇文章的篇幅来谈谈它的开发使用。

这两篇文章以当前最新的 v5 版本为基础,采用问题驱动形式,涵盖了 dapp 开发中面临的主要开发主题。

在本篇中,你可以了解到:

  • 安装过程可能面临的问题
  • 如何与 Web3Modal 搭配使用
  • human readable abi 和只读合约方法调用
  • 读写合约调用以及 gas 相关问题
  • 如何确定交易的状态
  • 如何调用使用了 struct 的合约方法
  • 常见的 UNPREDICTABLE_GAS_LIMIT 错误如何解决

下篇则重点关注合约事件、历史、签名等相关内容。

作为开发者的你,假如在寻找让你能快速上手 Ethers.js 的使用,同时又能看到不那么小儿科的例子,那么本系列应该成为你的选择。

那么,让我们进入正题吧!

安装

通常,这里没什么好说的,按照文档上面的指示来就好了:

npm install --save ethers

但是,在极个别的情况下,尤其是采用某些前端框架时,按照以上方式安装可能会导致无法正确初始化 provider。此时,可以求助于另一种很容易被我们这些经过现代前端框架“洗脑”后的开发者所忽视的方法:最原始的 <script> 引入方式。

<script
  src="https://cdn.jsdelivr.net/npm/ethers@5.1.4/dist/ethers.umd.min.js"
  type="application/javascript"
></script>

然后,通过下面的代码引用 ethers 对象而非常见的 import

const ethers = window.ethers;

这种方式有一个副作用:在一些现代编辑器中,如 vscode ,无法利用其代码联想功能。因此,第一种方式应该成为你的首选,除非没得办法。

最后请注意:<script> 引入方式仅限于前端。对于后端代码(假如仍然使用 js / ts 的话),采用 npm i 方式即可。

连接钱包

严格来讲,本节的标题采用另一种说法更合适:“连接以太坊”。但由于“连接钱包”通俗易懂,故采用之。

所谓连接,可以从两个维度来说明:

  • 发生地点:前端 or 后端
  • 连接目的:只读调用 or 发起交易

在 Ethers.js 中,与之相关的类有三个:

  • provider,面向只读操作
  • signer,发起交易时必需
  • wallet,signer 的子类

在前端连接

一般来讲,从前端连接,有以下的特点:

  • 通常会对接用户已安装的钱包或钱包插件,如 MetaMask
  • 一般都会涉及到读写操作

这里的步骤是:

  • 先创建 Web3Provider 实例,它可以应付一般的只读操作。
  • 如果需要发起交易或签名,则可以通过 provider.getSigner() 来得到 signer,通过它来完成剩下的操作。

由于本节的关注点是连接钱包,因此让我们先来看看第一步:获取 Web3Provider

对于前端应用,建议配合 Web3Modal 来使用,因为它对于常见的 provider 提供了统一的接口,并可和 Ethers.js 搭配使用。但是,使用时需注意:

  • 它依赖 react ,若你采用的是非 react 框架,需要注意。
  • 使用不同的 provider 前需安装,这意味着支持的 provider 越多,那么应用构建之后的包会越大。

然而,这两个问题可以用同样方式来解决:最原始的 <script> 引入方式。采用这种方式,不仅可以规避必须安装 react 的限制,同时也避免了需静态打包各个 provider ,将其延迟到应用加载页面时。因为我个人目前偏好 svelte ,这种做法已经在实际中得到了应用,效果良好。

下面示例采用 <script> 引入方式的做法,但很容易改造成通过 npm i 方式引入的代码:忽略第一步,采用 import 方式而非 window 引用对象。

1,安装 Web3Modal 及需支持的 provider,如 WalletConnect。

<script
  type="text/javascript"
  src="https://cdn.jsdelivr.net/npm/web3modal@1.9.3/dist/index.min.js"
></script>
<script
  type="text/javascript"
  src="https://cdn.jsdelivr.net/npm/@walletconnect/web3-provider@1.4.1/dist/umd/index.min.js"
></script>

Web3Modal 默认支持 injected ,因此,此例中已经同时支持 MetaMask 和 WalletConnect 两种连接方式。

2,初始化

const Web3Modal = window.Web3Modal.default;
const WalletConnectProvider = window.WalletConnectProvider.default;
const ethers = window.ethers;

const providerOptions = {
  walletconnect: {
    package: WalletConnectProvider,
    options: {
      infuraId: INFURA_ID,
      network: NETWORK,
    },
  },
};

const web3Modal = new Web3Modal({
  cacheProvider: true,
  providerOptions,
});

注意:cacheProvider 可缓存当前连接的 provider,方便重连。

3,获取 Web3Provider

const web3ModalProvider = await web3Modal.connect();
const provider = new ethers.providers.Web3Provider(web3ModalProvider);

注意:若设置了 cacheProvidertrue ,那么下次调用 web3Modal.connect() 时不会出现对话框。此时,可以通过 web3Modal.clearCachedProvider() 来对此复位。

并且,对于 provider 是 WalletConnect 方式来讲,在 clear 时还需多一个步骤:清除 WalletConnect 本身的缓存。否则,即使出现了对话框,点击 WalletConnect 方式时仍然会连到上一次 WalletConnect 连接的钱包上。参见下面代码示例:

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

即:同时清除两处。关于 Web3Modal 的其他用法,请参见其文档。

在后端连接

后端应用链接以太坊有其自身的特点:

  • 大多数情况下是只读调用,比如监听以太坊事件。
  • 如果涉及到读写操作,就直接通过私钥来初始化一个 Wallet 对象。

场景 1:只读调用。

初始化一个 InfuraProvider 即可:

const provider = new ethers.providers.InfuraProvider(NETWORK);

注意:使用自己的 infura key ,默认的会有些连接次数限制。

const provider = new ethers.providers.InfuraProvider(NETWORK, INFURA_PROJECT_ID);

场景 2:读写操作

const provider = new ethers.providers.InfuraProvider(NETWORK);
const wallet = new ethers.Wallet(privateKey, provider);

Wallet 除了 Signer 的功能还能用于创建钱包,但它不是本系列的重点,故就此略过。后续代码不会再区分 SignerWallet,因为对本系列关注的重点来讲,二者没有区别。

合约调用

调用合约主要有两个步骤:

  1. 引入合约 abi
  2. 调用合约方法

合约 abi 一般会在编译合约时自动生成。但这样也带来一点麻烦:每当合约方法发生变化,abi 就必须重新生成和引入。好在 Ethers.js 支持所谓的 Human Readable ABI ,该特性完全避免了生成 abi 的过程,将引入合约 abi 的过程简化成复制合约方法签名的过程。

这里以 ERC20 的 balanceOf 方法为例说明整个过程。

1,引入感兴趣的合约方法签名:

const erc20Abi = ["function balanceOf(address) view returns (uint256)"];

2,初始化合约实例:

const erc20Contract = new ethers.Contract(erc20Address, erc20Abi, provider);

3,调用合约方法:

const balanc = await erc20.balanceOf(account);

关于这个例子有几点说明:

  • 只需要引入感兴趣的合约方法签名。
  • 合约方法签名从合约代码直接复制而来。
  • 初始化合约实例需要:地址、abi 和 provider。
  • 合约实例化之后,调用合约方法就如同调用普通对象的方法一样。

以上例子展示了只读方法的调用,这是最简单的一种情形。接下来,让我们看看读写方法的调用。

读写合约方法调用

总得来讲,读写合约方法调用跟上面只读合约方法调用差别不大,但需注意两点:

  • 使用 signer
  • 需设置合理的 gaslimit

第一条不难理解,因为任何交易都需要钱包来签名,显然不得不用到 signer

第二条就不那么显眼了,不少网上的例子实际上都没有明确体现出来,可一旦用到实际项目中,却可能因为没有显式设置它而导致调用失败:因为超出了默认 gaslimit 的限制。但要设置这个值并不是那么简单。

你可以强行对所有读写方法都设定一个固定大小的值,但这种方式的缺点也很明显:它必须是所有可能(因为每个方法的消耗并不一样)的最大值,一般来讲,这很难估算。

更合理的方法是:针对每个方法进行单独设置,它可以通过合约对象的 estimateGas 来实现。

这里有一个工具方法,它实现了估算 gas 和合约调用的二合一,各位可以直接使用:

const provider = ...

async function executeContractMethodWithEstimatedGas(contract, functionName, args) {
  const estimatedGas = new BigNumber(
    ethersOf(
      await contract.estimateGas[functionName](...args)
        .then((value) => {
          const minimumGas = ethers.BigNumber.from("300000");
          if (value.lt(minimumGas)) {
            return minimumGas;
          }
          return value;
        })
        .catch((err) => {
          return ethers.BigNumber.from("700000");
        })
    )
  );
  const argsForOverridden = args.pop();
  argsForOverridden.gasLimit = parseEthers(estimatedGas.times(1.2).toString());
  args.push(argsForOverridden);
  return contract.connect(getSigner())[functionName](...args);
}

该方法会先尝试采用 estimateGas 进行估算,若估算不成功则设置一个默认值。最后会以整个估算的 1.2 倍作为实际的使用数值。这是为了方便冗余。它用到的辅助方法如下:

function getSigner() {
  if (!provider) {
    console.error("please connect a wallet first.");
    return;
  }

  return provider.getSigner();
}

export function parseUnits(amount: string, unit: number) {
  const bnAmount = new BigNumber(amount);
  try {
    return ethers.utils.parseUnits(bnAmount.toFixed(unit), unit);
  } catch (e) {
    return ethers.BigNumber.from(bnAmount.times(Math.pow(10, unit)).toFixed(0));
  }
}

export function parseEthers(amount: string) {
  return parseUnits(amount, ETHER_DECIMALS);
}

export function ethersOf(amount) {
  return ethers.utils.formatEther(amount);
}

注意上面代码中的 new BigNumber,它用的是 bignumber.js,引用:

import BigNumber from "bignumber.js";

关于这个工具方法的使用,以 ERC721 的 safeTransferFrom 为例进行说明:

const ERC721_ABI = ["function safeTransferFrom(address, address, uint256) public"];

const contractObj = new ethers.Contract(contractAddress, ERC721_ABI, provider);

await executeContractMethodWithEstimatedGas(contractObj, "safeTransferFrom", [
  addressFrom,
  addressTo,
  tokenId,
  {},
]);

注意:

  • {} 前的参数对应 abi 中合约方法所需的参数
  • 若调用的方法涉及到发送 eth ,则放在 {} 中,形式为:{value: ...},见下例:
await executeContractMethodWithEstimatedGas(contract, "method", [
  arg1,
  arg2,
  {
    value: ...,
  },
]);

交易状态

在进行合约的读写方法调用时,交易状态非常关键,因为它涉及到实现这样的 UI 效果:

  • 交易提交之后,显示 pending 标志。
  • 交易确认之后,pending 标志结束,提示用户交易完成。

假如你使用过 Uniswap ,那么对于上面的描述应该不会陌生。它的实现并不复杂:

  • executeContractMethodWithEstimatedGas 返回,则表示交易已经提交。
  • provider.waitForTransaction 返回,则表示交易已经确认

还是以上面的 safeTransferFrom 为例进行说明:

const tx = await executeContractMethodWithEstimatedGas(contractObj, "safeTransferFrom", [
  addressFrom,
  addressTo,
  tokenId,
  {},
]);

// 你的动作:在此处显示 pending 标志

const txReceipt = txawait provider.waitForTransaction(tx.hash, CONFIRMATIONS);

// 你的动作:在此处结束 pending 标志,进行其他处理

注意上面的 CONFIRMATIONS,它代表区块确认个数,越大则等待时间越长。

带结构体的方法调用

由于 Solidity 支持 struct ,我们难免会遇到使用它的合约方法。比如下例:

struct Price {
    uint256 ethAmount;
    uint256 erc20Amount;
    uint256 blockNum;
    uint256 K;
    uint256 theta;
}

function methodA(Price memory _p) external view returns (uint256 result)

这种情况下的 abi 字符串该怎么写呢?原则很简单:将其视为 tuple。与上面合约代码对应的 abi 字符串如下:

"function methodA(tuple(uint256, uint256, uint256, uint256, uint256)) external view returns (uint256)";

可以看出上面的 Price 已经用一个 tuple 来替代了,其中每项的类型跟 struct 中定义的类型相对应。同时在合约调用时,用数组:

await contract.methodA([
  ...,
  ...,
  ...,
  ...,
  ...,
]);

假如更复杂一点:结构体数组怎么办?应用同样的原则:structtuple,但数组保持不变。

"function methodB(tuple(uint256, uint256, uint256, uint256, uint256)[]) external view returns (uint256)";

注意上面的数组标记。

UNPREDICTABLE_GAS_LIMIT

最后,在结束本节之前,让我们看看另一个常见的错误:UNPREDICTABLE_GAS_LIMIT 。它的典型错误信息如下:

ERROR Error: Uncaught (in promise): Error: cannot estimate gas; transaction may fail or may require manual gas limit (error={"code":-32000,"message":"execution reverted"}, method="call", transaction={"to":"...","data":"..."}, code=UNPREDICTABLE_GAS_LIMIT, version=providers/5.0.24)
    at d.makeError (main.a577643548eb15e6832e.js:1)
    at d.throwError (main.a577643548eb15e6832e.js:1)
    at Z (main.a577643548eb15e6832e.js:1)
    at $e.<anonymous> (main.a577643548eb15e6832e.js:1)
    at Generator.throw (<anonymous>)
    at a (main.a577643548eb15e6832e.js:1)
    at l.invoke (polyfills.797d13e303959ed8dd77.js:1)
    at Object.onInvoke (main.a577643548eb15e6832e.js:1)
    at l.invoke (polyfills.797d13e303959ed8dd77.js:1)
    at a.run (polyfills.797d13e303959ed8dd77.js:1)
    at Z (polyfills.797d13e303959ed8dd77.js:1)
    at polyfills.797d13e303959ed8dd77.js:1
    at a (main.a577643548eb15e6832e.js:1)
    at l.invoke (polyfills.797d13e303959ed8dd77.js:1)
    at Object.onInvoke (main.a577643548eb15e6832e.js:1)
    at l.invoke (polyfills.797d13e303959ed8dd77.js:1)
    at a.run (polyfills.797d13e303959ed8dd77.js:1)
    at polyfills.797d13e303959ed8dd77.js:1
    at l.invokeTask (polyfills.797d13e303959ed8dd77.js:1)
    at Object.onInvokeTask (main.a577643548eb15e6832e.js:1)

看到上面这样的信息,十有八九会让开发者摸不着头脑,但最可能的原因是:调用了合约中不存在的方法

解决之道也很简单:检查调用地址(上文中的 to)对应的合约中是否有要调用的方法并进行修改。

  • 方法名是否错误,即调用地址和合约方法不匹配
  • 方法参数是否错误
  • 方法名最后是否带有分号等其他符号,注意:只需复制字符串内容即可,不要把其他符号复制进来。以下是对比:
    • 对:function testMethod(uint256) public view returns (uint256)
    • 错:function testMethod(uint256) public view returns (uint256);

总结

到现在为止,文中的代码示例已经足以让你开始令人激动的 dapp 开发之旅:从安装,到连接钱包,到合约调用,以及常见的错误排查。所有这些都是 dapp 开发者日常不可或缺的内容。

在本系列的下篇,你将看到另一些有趣的代码示例,在开发一些高级特性时,它们必不可少,敬请期待吧。

付费内容

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