老胡茶室
老胡茶室

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

胡键

上一篇起,一个关于 Ethers.js 开发的系列事实上诞生了。既然如此,我也乐得顺水推舟,干脆将其打造成一个使用 Ethers.js 开发以太坊应用的 Cookbook,以此来记录日常开发中的收获。于是乎,标题不再是上、下、续之类的麻烦字眼,直接替换成了自增数字,😄。

闲话说完,言归正传。通过本篇你可以了解到:

  • transaction 的取消或加速
  • EIP1559 对 GasPrice 的影响
  • 什么是 HD Wallet 以及它的用途
  • 对 contract 日志实现任意查询
  • contract 方法调用的权限控制

如何取消或加速 Transaction

说到取消或加速 Transaction 的原因,有人可能会列出一大堆:

  • 太慢了,希望快点结束;
  • 后悔了,想取消;
  • 市场变了,按原来的输入明显会失败,不如取消重来;
  • ……

但在我看来,最大的原因还是迟迟不能被 confirm 的 Transaction 会影响当前 Wallet 的使用,导致整个 wallet 被堵住无法使用。如果它是个人用的,影响的也就是单个人而已;但如果它是被系统使用的服务器端 wallet,那影响面可想而知。

在介绍 Transaction 的取消和交易之前,必须先了解一个基本概念:nonce。单看字面意思,dapp 开发的初学者可能会摸不着头脑。但假如把它换成另一个词 sequence,相信就好懂多了。

简单讲,nonce 的基本作用:

  • 表示当前地址已提交 transaction 的个数。它和 sequence 一样是自增的,每提交一次,个数加一。
  • 防止重放攻击,由于每次提交之后都会变化且包含在 transaction 中,这样就杜绝了过往 transaction 签名被重用的可能性。

同时,transaction 只能按顺序执行,前面的不被确认,后面的就无法被执行,就算可以不断提交,但依旧也只是进入到等待队列,对于实际的活动帮助并不大,反而还会留下一大堆未来需要处理的麻烦事。

假如你的 dapp 只有前端且由用户负责发起 transaction ,典型如 uniswap,那么作为开发者,你是不必过于关心 nonce 或 wallet 是否被堵住这类琐碎的。这并不是说使用这类 dapp 的 wallet 并不会被堵住,而是说该操心的不是你的代码,而是使用者本身。

但倘若你的 dapp 涉及到服务端且存在服务端代码需要发起 transaction 时,你的麻烦就来了。

首先,你需要有办法知道服务端的 wallet 是否被堵住。这个简单,使用下面的代码就可以办到:

const [confirmedNonce, totalNonce] = await Promise.all([
  provider.getTransactionCount(address),
  provider.getTransactionCount(address, "pending"),
]);

既然 nonce 等于当前已提交的 transaction 个数,那么使用 Ethers 的 getTransactionCount 就可以获取其值。注意示例中的 pending 参数:

  • 有它,表示将处于“待确认”的 transaction 也计入在内。
  • 否则,表示“已提交”的个数

由此,判断一个 wallet 是否被堵住的方法也就简单了:

confirmedNonce === totalNonce;

接下来,另一个需要注意的就是并发性的问题。

前面说过,对于一个 wallet 而言,transaction 只能按顺序提交和确认,并且每次提交完之后,nonce 会自动加一。通常情况下,nonce 的处理会在 wallet 应用或类似 Ethers 这样的 provider 中自动完成,开发者无需过于关心。但这种处理方式对于包含有服务端的 wallet 应用来说就失效了。此时,开发者必须自行应对处理。

究其原因,以太坊不允许同时存在多个具备同一个 nonce 的事务。看看以下的事件流:

  1. 假设当前服务端 wallet 的 nonce 是 1。
  2. 这时,有两个请求几乎同时到来。在它俩看来,nonce = 1。
  3. 接着,它们都发起了 transaction,此时这两个对象中的 nonce 都为 2。因为 Ethers 都是在 1 的基础上进行的自增操作。

接下来,你就会看到一个异常,其错误消息为:replacement fee too low 。应对方式并不复杂:

  • 方法 1:降低并行化,这种方法已经在这里说过,就不再赘述。
  • 方法 2:不依赖 Ethers 的 nonce 计数,自行完成。这种方法的实现方式也很多,典型:
    • 依赖全局值完成计数
    • 单独的 nonce 服务,通过它来获得每一个 nonce,本质上和上面的方式差不多。

到此,nonce 的背景知识说的差不多了,回到问题的本身:如何取消和加速 transaction?

其实,解决方案的答案已经隐藏在上面提到的错误消息中:replacement fee too low。这句话是否隐含着这样的意思:

  1. transaction 是可以替换的。
  2. 但是需要有一定的费用,而且不能比之前的低。

是的,取消和加速一个 transaction 的方法就是:替换具有同值 nonce 的事务。两者之间的差别在于 transaction 的数据,共同点则是:足够的交易费,至少比前一个值高 10%(注意,如想快速取消或加速,最好的方式是跟当前的交易费进行比较,而不是历史值)。

  • 取消:给自己转账 0 eth。
  • 加速:使用原有的数据。

请注意:Ethers(包括 web3.js) 并没有提供一种方法查到哪些 transaction 处于 pending 状态(你可以监听 pending 事件,但不能查找,一旦错过也就是不知道了。)。这意味着:对于加速操作,你必须要有办法知道对应的 txHash 或者是 nonce 加对应的 transction 数据,否则无解。对于取消操作来说,则无此限制,通过前面提到的两个 nonce 值就能得出有几个 transaction 处于 pending,只需构造几个包含同样 nonce 号的 transaction 就行了。

取消 transaction:

const tx = await wallet.sendTransaction({
  to: wallet.address,
  nonce,
  value: parseEther("0"),
  // eip1559
  maxPriorityFeePerGas: ...,
  // pre eip1559
  // gasPrice: ...,
});

加速 transaction:

const tx = await wallet.sendTransaction({
  to: blockedTx.to,
  data: blockedTx.data,
  nonce: blockedTx.nonce,
  value: blockedTx.value,
  gasLimit: blockedTx.gasLimit,
  // eip1559
  maxPriorityFeePerGas: ...,
  // pre eip1559
  // gasPrice: ...,
});

可以看出来,加速操作比起取消操作要复杂得多,几乎复制了老 transaction 的关键内容。并且也请记得保留新的 transaction 的 hash 值以备后用。

细心的同学应该已经注意到 price 部分的代码了,这便是下一节的重点。

EIP1559 及其对 gas price 的影响

同样的,假如你用到了服务端的 wallet,你大概率要关心 EIP1559,因为在 EIP1559 已经普及的今天不再是简单设置一个大的 gasPrice 就完事了。而为了保证 wallet 不被堵住,你得学会如何去设置一个合理的 gas price。

关于 EIP1559 的介绍,这篇 Consensys 文章中的图给出了非常形象的表示。

eip1559

从开发角度看,该图反映了 gas price 的计算变化:

  • 之前,单个 gasPrice。
  • 如今,两部分组成:baseFee 和 Tip。

其中的 baseFee 用户无法控制,tip 越高者优先。这一点同样体现在了 Ethers 的 api 变化之中。对于支持 EIP1559 的网络,获得 price 数据需使用 provider.getFeeData()。该方法的返回值包括:

  • lastBaseFeePerGas
  • maxFeePerGas
  • maxPriorityFeePerGas
  • gasPrice

前三项适用于 EIP1559,最后一项在 lastBaseFeePerGas 为 null 时使用,用于兼容性保证。其中的关系:

  • lastBaseFeePerGas 即上一个 block 的 baseFee
  • maxPriorityFeePerGas 即 tip
  • maxFeePerGas

因此,在 EIP1559 实施之后,关于 gas price 的设置代码,请记住以下三个规则:

  1. 忘记 gasPrice。
  2. 简单情况下使用 maxPriorityFeePerGas 就行了。
  3. 想完全控制,则同时使用 maxPriorityFeePerGas 和 maxFeePerGas。

知道了代码修改的规则,接下来就是如何设置一个“合适”的值,这并不简单。

虽然 Ethers 的 getFeeData 方法可以获得 price 数据,但不建议直接拿来使用。因为其返回的 maxPriorityFeePerGas 是一个固定值:

if (block && block.baseFeePerGas) {
  // We may want to compute this more accurately in the future,
  // using the formula "check if the base fee is correct".
  // See: https://eips.ethereum.org/EIPS/eip-1559
  lastBaseFeePerGas = block.baseFeePerGas;
  maxPriorityFeePerGas = BigNumber.from("1500000000");
  maxFeePerGas = block.baseFeePerGas.mul(2).add(maxPriorityFeePerGas);
}

一个更好的方法是直接从外部获取,比如从 infura 这类服务可以调用相应的方法得到一个预估值。在此基础上再乘以多少系数全凭个人喜好。

const maxPriorityFeePerGas = ethers.BigNumber.from(
  await provider.send("eth_maxPriorityFeePerGas", [])
);

另一种方法就是基于历史数据进行推测,Alchemy 的这篇文章给出了一个简单的示例。

最后说说何时同时使用 maxPriorityFeePerGas 和 maxFeePerGas。对此,前面给的理由是“全面控制”。所谓的全面控制其实控制的是:时间和成本。其中:

  • maxPriorityFeePerGas,相当于控制了时间,因为越高则越快。
  • maxFeePerGas,则用于控制总成本。

由于 base fee 不受用户控制,当此时 block 中记录非常多时,其值会上升。而用户支付的总费用实际上是 base fee + tip,这就会导致成本上升很快,超额支付。同时设置 maxFeePerGas 会确保总成本受控。

对于 maxFeePerGas,推荐设置的公式如前代码所示:(2 * baseFeePerGas) + maxPriorityFeePerGas。

HD Wallet 及其用途

请不要下意识的把 HD Wallet 想象成 Hardware Wallet,两者完全不是一码事。HD Wallet 的全称是:Hierarchical Deterministic (HD) Wallet。简单的讲,由一个主钱包生成多个子钱包,其中使用到的技术便是派生密钥。《精通以太坊》一书的一幅图很形象地展示了其特性。

HD Wallet

HD Wallet 最大的好处:

  • 由一个 seed 就能恢复一组 wallet
  • 不必暴露主钱包

这样给用户既带来了安全隐私的保护,同时也带来了管理上的方便。而且,这项技术也能方便的用于测试场景,比如 Ganache 中就用它来完成测试账户的生成。与 HD Wallet 相关有一系列的标准:BIP-32、BIP-39 和 BIP-44 等,开发者一般情况下不需要关注这些,只需知道如何生成子钱包即可。对于每个 wallet 的使用,没有太大区别。

使用 Ethers 生成 HD Wallet 代码示例:

const wallet = ethers.Wallet.createRandom();
const words = wallet.mnemonic.phrase;
const node = ethers.utils.HDNode.fromMnemonic(words);
const result = new Array<{ address: string; privateKey: string }>();

for (let i = 0; i < count; i++) {
  const path = `m/44'/60'/0'/0/${i}`;
  const account = node.derivePath(path);
  result.push({ address: account.address, privateKey: account.privateKey });
}
return result;

注意其中关键的一步:node.derivePath(path),每个子钱包共用一个父路径。

如何实现对 Contract 日志的任意查询

关于如何查询 contract 的历史,在之前的文章中已经有过介绍,这里不再赘述。本节讨论的重点是如何实现任意查询,而非对于特定事件的查询。

从前文我们已经知道,使用 Ethers 查询某一特定历史事件,必须知道:

  • address
  • event abi
  • indexed 参数的查询值

显然,如果能实现上述三个参数的通用化,我们的目的就自然而然的达到了。

对于前两个参数,非常简单:address 自不必说,而 Ethers 本身又支持 human readble abi,其本身就是普通的字符串。那么,接下来重点看一下第三个参数。

由于其本身依赖于传入的 event abi,很自然的一个想法就是:如果能解析该 abi 字符串,那么一切就迎刃而解了。整个思路过程:

  1. 获得其中的 indexed 参数值。
  2. 依据其参数动态显示界面
  3. 收集用户输入的参数值,拼接成符合函数调用的输入。

借助 Ethers 的 abi 函数可以轻易的帮助我们完成这些。

const jsonAbi = JSON.parse(new Interface([eventAbi]).format(FormatTypes.json) as string);

上述代码会返回事件 abi 的 json 数组表示,由于我们这里只用到了一个 abi 字符串,因此其长度为 1。

由于只有 indexed 参数才能作为事件的查询参数,故我们可以通过遍历其 inputs 数组得到:

const indexeds = jsonAbi[0].inputs.filter((input: any) => input.indexed);

之后就可以通过每个元素的 type 和 name 动态生成界面。在用户输入完之后,通过将所有输入收集到一个数组中。注意,根据 Ethers 查询接口的要求,未填参数用 null 替代。

最后就是实际的查询函数代码,注意其中 filter 的构造过程。

async function query(
  contractAddress: string,
  wallet: Wallet,
  eventAbi: string,
  args: any[],
  start: number | "earliest",
  end?: number | "latest"
): Promise<any> {
  const jsonAbi = JSON.parse(new Interface([eventAbi]).format(FormatTypes.json) as string);
  const contract = new Contract(contractAddress, [eventAbi], wallet);

  let logs: Array<Event>;
  const filter = contract.filters[jsonAbi[0].name](...args);
  if (start < 0) {
    logs = await contract.queryFilter(filter, start);
  } else {
    logs = await contract.queryFilter(filter, start, end);
  }

  return logs.map((log) => {
    return {
      txHash: log.transactionHash,
      args: JSON.stringify(log.args),
    };
  });
}

借助同样的 abi 操作技术,我们也可以构造执行任意 contract 方法的函数,这里就留给各位同学作为练习吧。

contract 方法调用的权限控制和验证

contract 方法也是代码,因此,有权限控制和验证的需要也就不奇怪了。

但作为 dapp 开发者,有一点尤其需要注意:与常规运行在封闭环境中的代码不一样,contract 的代码是公开可见的,你无法限制调用者的调用方式。也就是说,对于 dapp 中的 contract,你不能指望所有人都只通过你设定的 ui 来调用。

这种情况给 contract 方法调用的权限控制和验证带来了新的挑战,让我们分典型场景来进行讨论。

场景 1:限定某些 contract 的方法只能被某些地址调用

至少有两种方案:

  • 方法 1:使用 openzepplin 的 RBAC 实现,更灵活也更复杂。
  • 方法 2:使用 modifier + 自行管理地址数据

对于方法 2,假如管理的地址数据过多,还可以进一步优化,通过 merkel proof 减少链上数据的存储,降低成本。关于 merkel proof,会在本系列未来的篇章中说明。

场景 2:可调用 contract 方法的地址不定,但只有那些经过授权后的地址方能完成调用。

对于这个需求,以上两种方式已不再能够满足需求。本质上,它是在调用方和被调用方之间引入了一个第三方:授权方。这种场景可以通过以下的交互方式完成:

  1. 调用前,调用方向授权方发起请求。
  2. 若通过,则授权方向调用方授权,允许其调用 contract 方法。所谓授权的过程也就是返回给调用方一个签名。
  3. 调用方在调用 contract 方法时传入签名和能让签名消息产生的参数。
  4. contract 构造消息并验证签名来自其认可的授权地址,然后处理方法调用。

整个过程如下:

flow

注意:用于验证的消息必须是授权方和 contract 各自单独构造,不能由外部传入,这是为了防止伪造签名。因此,对于消息构造,双方需要有一个统一规则,确保可以构造出同样的消息。

这个流程丰富了 contract 交互的场景:凡事依赖权威认可的场合,都可以采用它。考虑这样的场景:contract 的某个方法依赖外部可变数据源,只有在某一条件下代码逻辑才会执行。

我猜你很快会说用预言机就行了。没错是这样,但对于实时性和数据精度要求不高的场景,可以更简单:只需在调用前让授权方对当时的数据签名,调用时将数据和签名同时传入给 contract 即可。

最后提醒一点:签名的安全性,主要防止两个情形:

  • 给 A 的签名被 B 拿去使用,导致验证机制失效。这一点完全可能,因为所有交易数据公开,通过 etherscan 这类浏览器可以很方便的获取到这些签名。
  • 老签名被重复使用。

解决办法也很简单:调用依赖信息和调用方地址纳入到签名消息的内容中。这里的调用依赖信息指的是本地调用受影响的信息,即调用前后一定会变化的信息,典型如 balance 之类的。

结语

相比本系统前三篇,相信本篇的各个用例不会令各位失望。希望通过这些用例的展示,可以帮助各位在 dapp 开发中开发出令人激动人心的新特性。

付费内容

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