本系列的上篇已经展示了连接钱包,调用合约和确定交易状态的全过程,对于一般的 dapp 开发已经完全足够。但对于有格调的开发者来讲,这些还不足以满足他们的胃口。那么,在下篇,你们将看到一些更加高级和特别的东西:
- 如何处理以太坊的事件?
- 如何监听特定合约的特定事件?
- 如何查询以太坊的过往历史?
- 如何获取导致交易产生的合约函数的输入参数?
- 对于高阶合约函数(即,接受另一个合约的函数为输入值),该如何调用?
- 如何实现签名(含 EIP712)?
- 如何获得某个账户的公钥?
相信以上这些重口味问题的答案应该不会让各位失望。
监听事件
通常,事件监听的需求来自于朴素的诉求:及时得到状态更新的通知。这种需求不仅仅局限于异步方法的调用,对于稍微复杂的一些程序,事件机制的引入也会让整个应用的架构得到简化。
Ethers.js 在 Provider 和 Contract 层面都提供了事件的支持,并且方法名称也有重叠。其中用得较多的方法如下:
on
,对特定事件添加监听器。off
,移除某事件全部监听器。once
,添加事件监听器,并且在事件处理完成后自动移除。它和on
的区别在于:on
没有移除事件监听器这一步骤,会继续监听后续事件;once
处理完当前事件之后,不再监听,对后续到来的事件不再处理;
dapp 典型的监听事件架构
虽然 Provider 和 Contract 提供了监听以太坊事件的方法,但要是不加区分到处引用这两个对象来添加事件处理函数,整个应用的代码仍不可避免地会陷入混乱。通常的做法都会结合前端框架的状态管理来做,实现上层业务代码和底层合约代码的解耦,同时又能及时得到通知。
通常做法如下:
- 定义状态管理涉及的业务对象,此过程类似 db 或者 domain 的设计。
- 在事件处理函数内,基于所监听的事件,更新对应的状态对象。
- 在业务代码部分订阅业务对象的更新事件,在该事件处理函数内完成页面状态更新。
对于 Angular 来讲:
- 状态更新部分涉及对象
Provider / Contract EventListners > Akita Service
- 订阅状态更新涉及对象
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
事件用得最多,on
和 off
方法用得最多,其他的可以酌情使用。
使用 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)
,全部from
且tokenId
的事件。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
需要与 contract
的 method1
对应,否则将报错。
调用高阶合约函数
在 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 ,它能提供更友好且结构化的显示方式(见下图),如果能由你做主,则建议优先采用。
普通签名
下面的例子展示了如何签名一条消息,进而得到公钥,最后得到对应钱包地址的过程:
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 的使用场景,避免陷入细节而不知其全貌。因此,还请记得要时不时翻翻文档哦。
本文包含付费内容,需要会员权限才能查看完整内容。