我曾经不止一次在以前的文章中鼓励大家在开发 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);
注意:若设置了 cacheProvider
为 true
,那么下次调用 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
的功能还能用于创建钱包,但它不是本系列的重点,故就此略过。后续代码不会再区分 Signer
和 Wallet
,因为对本系列关注的重点来讲,二者没有区别。
合约调用
调用合约主要有两个步骤:
- 引入合约 abi
- 调用合约方法
合约 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([
...,
...,
...,
...,
...,
]);
假如更复杂一点:结构体数组怎么办?应用同样的原则:struct
变 tuple
,但数组保持不变。
"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 开发者日常不可或缺的内容。
在本系列的下篇,你将看到另一些有趣的代码示例,在开发一些高级特性时,它们必不可少,敬请期待吧。
本文包含付费内容,需要会员权限才能查看完整内容。