老胡茶室
老胡茶室

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

胡键

本系列的上篇已经展示了连接钱包,调用合约和确定交易状态的全过程,对于一般的 dapp 开发已经完全足够。但对于有格调的开发者来讲,这些还不足以满足他们的胃口。那么,在下篇,你们将看到一些更加高级和特别的东西:

  • 如何处理以太坊的事件?
  • 如何监听特定合约的特定事件?
  • 如何查询以太坊的过往历史?
  • 如何获取导致交易产生的合约函数的输入参数?
  • 对于高阶合约函数(即,接受另一个合约的函数为输入值),该如何调用?
  • 如何实现签名(含 EIP712)?
  • 如何获得某个账户的公钥?

相信以上这些重口味问题的答案应该不会让各位失望。

监听事件

通常,事件监听的需求来自于朴素的诉求:及时得到状态更新的通知。这种需求不仅仅局限于异步方法的调用,对于稍微复杂的一些程序,事件机制的引入也会让整个应用的架构得到简化。

Ethers.js 在 Provider 和 Contract 层面都提供了事件的支持,并且方法名称也有重叠。其中用得较多的方法如下:

  • on,对特定事件添加监听器。
  • off,移除某事件全部监听器。
  • once,添加事件监听器,并且在事件处理完成后自动移除。它和 on 的区别在于:
    • on 没有移除事件监听器这一步骤,会继续监听后续事件;
    • once 处理完当前事件之后,不再监听,对后续到来的事件不再处理;

dapp 典型的监听事件架构

虽然 Provider 和 Contract 提供了监听以太坊事件的方法,但要是不加区分到处引用这两个对象来添加事件处理函数,整个应用的代码仍不可避免地会陷入混乱。通常的做法都会结合前端框架的状态管理来做,实现上层业务代码和底层合约代码的解耦,同时又能及时得到通知。

通常做法如下:

  1. 定义状态管理涉及的业务对象,此过程类似 db 或者 domain 的设计。
  2. 在事件处理函数内,基于所监听的事件,更新对应的状态对象。
  3. 在业务代码部分订阅业务对象的更新事件,在该事件处理函数内完成页面状态更新。

对于 Angular 来讲:

  1. 状态更新部分涉及对象
Provider / Contract EventListners > Akita Service
  1. 订阅状态更新涉及对象
Akita Query > Subscribers

对于 Svelte 来讲,更简单,整个过程简化为:

Provider / Contract EventListners > Store  > Reactive 语句 / 变量

因为对 React 和 Vue 不熟,这里就此略过,但机制应该都是一样的。

使用 Provider 监听

Provider 适合粗放型事件监听,值得注意的几个事件:

  • block,新区块生成
  • error,错误发生
  • txHash 值,tx 被确认

其他事件请参见 Ethers.js 的文档。

一般来讲,你不会需要上面的第三个事件(即txHash 值),采用本系列上篇中介绍的方法要更简单一些。

block 事件在你需要一口气跟踪多个状态时最为有效,比如一次交易的变化可能导致多个地方的状态发生变化:

  • 账户余额
  • 交易对内部双边余额的变化
  • 计价合约的参数变化
  • ……

此时没有比 block 更为有效的事件了:交易确认则新区块生成,在该事件内部一次性调用所有关心的状态函数以获取最新状态。如下例:

provider.on("block", async () => {
  console.log("tracking blockchain ...");

  // 注意:这里没有用 await ,因为这几个函数之间没有明显的依赖关联
  // 但你的代码不一定 ……
  trackingEthBalance(provider, currentAccount);
  trackingPairERC20Balances();
  trackingPairEthBalances();
  trackingMarketDetails();
  ...
});

移除事件监听就更简单了:

provider.off("block");

如果关心所有的错误信息,可以追踪 error 事件。但假若只是关心合约执行过程的异常,那么普通的 try ... catch 就足以应对了。

Provider 同样也提供了对于合约事件的监听,但因为不如 Contract 好用,因此用得并不多。这里摘抄一个来自文档的例子(跟踪 DAI 的 Transfer 事件):

filter = {
  address: "dai.tokens.ethers.eth",
  topics: [utils.id("Transfer(address,address,uint256)")],
};
provider.on(filter, (log, event) => {
  ...
});

总的来讲,对于 Provider ,block 事件用得最多,onoff 方法用得最多,其他的可以酌情使用。

使用 Contract 监听

使用 Contract 监听事件非常类似调用合约的过程,同样需要用到 Human Readable ABI 。以 Transfer 事件为例:

// 定义事件 abi
const abi = ["event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"];

// 初始化 Contract 实例
const contract = new ethers.Contract(address, abi, provider);

// 用 on 来监听事件
contract.on("Transfer", (from, to, tokenId, event) => {
  processTransfer(tokenId.toString(), from, to);
});

这里需注意两点:

  • 假如事件定义中有变长类型(如 string ),则从事件处理函数入参中无法获得原始值,它是一个 hash 值。但定长类型,则可以,本例中用的都是定长类型。
  • 上面事件处理函数的最后一个入参:event ,由它可以获得区块和交易相关的信息。假如你想要了解交易的 hash 值,时戳等信息,这个参数就是你要找的。

关于监听用的 provider

注:本节内容仅限于前端,对于后端来讲,一般不会遇到。

前端一般会有一个显式连接钱包的过程,并在这个过程中获得实际的 Provider 对象实例。那么请问:是否需要在连接钱包之前就显示一些以太坊相关的信息?比如:在 Uniswap 中,在你没有连接钱包之前就可以看到交易对之间的兑换价格,同时跟踪这个价格的兑换。

如果,你的答案是:yes ,那么建议采用两个 Provider:

  • 一个用于只读信息,可以直接使用前文介绍的 InfuraProvider 来初始化。
  • 一个用于读写操作,在连接钱包的时候获得。

双 Provider 架构并不少见,而且它让整个应用代码也更简单和清晰,相应的,维护起来也更容易。

历史时光机

以太坊是数据库,当然也就应该可以查询历史。但是,通常我们并不会漫无目地将所有区块都读过来一一检查,只会查询感兴趣的合约某个历史事件。Contract 提供了对这个需求的支持,以上面的 Transfer 事件为例:

const filter = contract.filters.Transfer();
const logs = await contract.queryFilter(filter, startBlock, "latest");

上面的代码很简单,但是有几个地方值得注意:

  • Transfer ABI 字符串中的 indexed 代表了可以搜索的参数,Solidty 规定最多能有三个 indexed 参数。
  • 查询方式很简单,假如你熟悉 Query By Example ,那么就能很快上手。无值(或 null )表示匹配全部,有值则匹配。看几个例子:
    • Transfer(),全部事件。
    • Transfer(from),全部来自 from 的事件。
    • Transfer(from, to),全部 from 转给 to 的事件。
    • Transfer(from, null, tokenId),全部 fromtokenId 的事件。
    • Transfer(from, to tokenId),精确匹配。
  • 不要忘记 ABI 字符串的 indexed 关键字,否则调用会出错。

合约函数入参

当获得事件或查询交易时,有时还要还原导致事件和交易产生的合约参数,此时可以借助 decodeFunctionData 来完成。

contract.on("Event1", async (eventArg1, eventArg2, event) => {
  const inputData = (await event.getTransaction()).data;
  const inputArgs = await contract.interface.decodeFunctionData('method1', inputData);
  ...
});

这里调用 decodeFunctionData 的合约实例可以不用与事件监听实例是一个实例,只需要确保是这个实例的这个方法导致了这个交易的生成就行了。

假如在查询历史的时候调用,那么也很简单,改变获得输入数据的方式就好了,其余不变:

const inputData = (await log.getTransaction()).data;

总之就是:inputData 需要与 contractmethod1 对应,否则将报错。

调用高阶合约函数

在 Solidity 中也可以实现合约的高阶函数:

function highOrderMethod(address targetContract, bytes calldata targetMessage) external payable nonReentrant {
  ...

  // 调用方式
  (bool success,) = targetContract.call(targetMessage);
  if (!success) revert('transaction failed');

  ...
}

所谓 Solidity 的高阶函数就是将合约地址和相应消息([函数名 + 入参数据]编码值)传入。相应的,触发方式如下:

const interface = new ethers.utils.Interface([
  "function method1(uint256, bytes) public payable returns (bool)",
]);

const tx = await executeContractMethodWithEstimatedGas(contract, "highOrderMethod", [
  targetContract,
  interface.encodeFunctionData("method1", [value1, value2]),
  { value: ... },
]);

注意 encodeFunctionData 中如何传入 method1 的入参.关于 executeContractMethodWithEstimatedGas 请参见本系列上篇.

签名、公钥和地址

对于签名和公钥的关系,大多数开发者并不陌生:私钥签名,公钥验证。在以太坊中,以上三者还有这样的关系:

  • 公钥可以通过消息签名和消息摘要恢复。
  • 钱包地址可以由公钥计算得到。

关于签名,也分三种:

  • 普通签名
  • personal_sign
  • EIP 712

至于采用哪种,这完全依赖于验证一方的要求。但目前来说后两者用的较多,尤其是 EIP 712 ,它能提供更友好且结构化的显示方式(见下图),如果能由你做主,则建议优先采用。

eip712

普通签名

下面的例子展示了如何签名一条消息,进而得到公钥,最后得到对应钱包地址的过程:

const signature = await provider.getSigner().signMessage(message);
const digest = ethers.utils.arrayify(ethers.utils.hashMessage(message));
const publicKey = ethers.utils.recoverPublicKey(digest, signature);
const account = ethers.utils.computeAddress(publicKey);

如果只是为了得到 account ,还有更简单的做法:

const account = ethers.utils.verifyMessage(message, signature);

personal_sign

很可惜,Ethers.js 对于它没有直接支持,所以只能 DIY:

const signature = await provider.send("personal_sign", [message, account]);

EIP 712

对 EIP 712 的支持,Ethers.js 提供了 _signTypedData 方法。其下划线风格的表示其未来可能有变更,文档建议在引用库时最好将版本写全。

// chainId、verifyingContract、salt 可不填
const domain = {
  name: "My amazing dApp",
  version: "2",
  chainId: "1",
  verifyingContract: "...",
  salt: "...",
};

const types = {
  MyType: [
    { name: "field1", type: "string" },
    { name: "field2", type: "string" },
  ],
};

// 注意与 types 对应
const value = {
  field1: "...",
  field2: "...",
};

const signature = await provider.getSigner()._signTypedData(domain, types, value);

const digest = ethers.utils.arrayify(_TypedDataEncoder.hash(domain, types, value));
const publicKey = ethers.utils.recoverPublicKey(digest, signature);

console.log(ethers.utils.computeAddress(publicKey));
console.log(ethers.utils.verifyTypedData(domain, types, value, signature));

注意 EIP712 跟其他的区别。

结语

希望这两篇充斥着范例代码的 Ethers.js Cookbook 能节约你的时间并快速掌握手头的问题。但请注意,本文的初衷并非要取代 Ethers.js 开发文档,而是以另一个维度组织知识点以帮助开发者快速入门,理解 API 的使用场景,避免陷入细节而不知其全貌。因此,还请记得要时不时翻翻文档哦。

付费内容

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