从上一篇起,一个关于 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 的事务。看看以下的事件流:
- 假设当前服务端 wallet 的 nonce 是 1。
- 这时,有两个请求几乎同时到来。在它俩看来,nonce = 1。
- 接着,它们都发起了 transaction,此时这两个对象中的 nonce 都为 2。因为 Ethers 都是在 1 的基础上进行的自增操作。
接下来,你就会看到一个异常,其错误消息为:replacement fee too low 。应对方式并不复杂:
- 方法 1:降低并行化,这种方法已经在这里说过,就不再赘述。
- 方法 2:不依赖 Ethers 的 nonce 计数,自行完成。这种方法的实现方式也很多,典型:
- 依赖全局值完成计数
- 单独的 nonce 服务,通过它来获得每一个 nonce,本质上和上面的方式差不多。
到此,nonce 的背景知识说的差不多了,回到问题的本身:如何取消和加速 transaction?
其实,解决方案的答案已经隐藏在上面提到的错误消息中:replacement fee too low。这句话是否隐含着这样的意思:
- transaction 是可以替换的。
- 但是需要有一定的费用,而且不能比之前的低。
是的,取消和加速一个 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 文章中的图给出了非常形象的表示。
从开发角度看,该图反映了 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 的设置代码,请记住以下三个规则:
- 忘记 gasPrice。
- 简单情况下使用 maxPriorityFeePerGas 就行了。
- 想完全控制,则同时使用 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 最大的好处:
- 由一个 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 字符串,那么一切就迎刃而解了。整个思路过程:
- 获得其中的 indexed 参数值。
- 依据其参数动态显示界面
- 收集用户输入的参数值,拼接成符合函数调用的输入。
借助 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 方法的地址不定,但只有那些经过授权后的地址方能完成调用。
对于这个需求,以上两种方式已不再能够满足需求。本质上,它是在调用方和被调用方之间引入了一个第三方:授权方。这种场景可以通过以下的交互方式完成:
- 调用前,调用方向授权方发起请求。
- 若通过,则授权方向调用方授权,允许其调用 contract 方法。所谓授权的过程也就是返回给调用方一个签名。
- 调用方在调用 contract 方法时传入签名和能让签名消息产生的参数。
- contract 构造消息并验证签名来自其认可的授权地址,然后处理方法调用。
整个过程如下:
注意:用于验证的消息必须是授权方和 contract 各自单独构造,不能由外部传入,这是为了防止伪造签名。因此,对于消息构造,双方需要有一个统一规则,确保可以构造出同样的消息。
这个流程丰富了 contract 交互的场景:凡事依赖权威认可的场合,都可以采用它。考虑这样的场景:contract 的某个方法依赖外部可变数据源,只有在某一条件下代码逻辑才会执行。
我猜你很快会说用预言机就行了。没错是这样,但对于实时性和数据精度要求不高的场景,可以更简单:只需在调用前让授权方对当时的数据签名,调用时将数据和签名同时传入给 contract 即可。
最后提醒一点:签名的安全性,主要防止两个情形:
- 给 A 的签名被 B 拿去使用,导致验证机制失效。这一点完全可能,因为所有交易数据公开,通过 etherscan 这类浏览器可以很方便的获取到这些签名。
- 老签名被重复使用。
解决办法也很简单:调用依赖信息和调用方地址纳入到签名消息的内容中。这里的调用依赖信息指的是本地调用受影响的信息,即调用前后一定会变化的信息,典型如 balance 之类的。
结语
相比本系统前三篇,相信本篇的各个用例不会令各位失望。希望通过这些用例的展示,可以帮助各位在 dapp 开发中开发出令人激动人心的新特性。
本文包含付费内容,需要会员权限才能查看完整内容。