Ethers库的进阶操作
Ethers库的进阶操作
这篇帖子用来记录WTF Academy ethers102课程的学习笔记。
StaticCall
合约类的staticCall
方法在发送交易之前会检查交易是否会失败,能节省大量gas(因为发送失败的交易并不会把gas
返还给你)。
contract.函数名.staticCall()
方法可以模拟执行一个可能会改变状态的函数,但不实际向区块链提交这个状态改变。这相当于调用以太坊节点的 eth_call
。这通常用于模拟状态改变函数的结果。如果函数调用成功,它将返回函数本身的返回值;如果函数调用失败,它将抛出异常。无论是 view/pure 还是普通的状态改变函数都可以用。
console.log("\n2. 用staticCall尝试调用transfer转账1 DAI,msg.sender为Vitalik地址")
// 发起交易
const tx = await contractDAI.transfer.staticCall("vitalik.eth", ethers.parseEther("1"), {from: await provider.resolveName("vitalik.eth")})
console.log(`交易会成功吗?:`, tx)
识别ERC721合约
在做NFT相关产品时,我们需要筛选出符合ERC721
标准的合约。例如Opensea,他会自动识别ERC721
,并爬下它的名称、代号、metadata等数据用于展示。
通过ERC165标准,智能合约可以声明它支持的接口,供其他合约检查。ERC721
合约中会实现IERC165
接口合约的supportsInterface
函数,并且当查询0x80ac58cd
(ERC721
接口id)时返回true
。也就是说,只需要查这个就能知道对面是否是ERC721:
const selectorERC721 = "0x80ac58cd"
const isERC721 = await contractERC721.supportsInterface(selectorERC721)
console.log("\n2. 利用ERC165的supportsInterface,确定合约是否为ERC721标准")
console.log(`合约是否为ERC721标准: ${isERC721}`)
编码calldata
以下是两种从合约获得特定接口的方法:
// 利用abi生成
const interface = ethers.Interface(abi)
// 直接从contract中获取
const interface2 = contract.interface
接口类封装了一些编码解码的方法。与一些特殊的合约交互时(比如代理合约),你需要编码参数、解码返回值:
获取接口的函数选择器:
interface.getSighash("balanceOf");
// '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
生成合约构造函数的字节码(即构造参数的编码):
interface.encodeDeploy("Wrapped ETH", "WETH");
编码calldata:
interface.encodeFunctionData("balanceOf", ["0xc778417e063141139fce010982780140aa0cd5ab"]);
它编码了一个函数调用(调balanceOf函数,参数是后边那一串),这段编码数据可以作为交易的 data
字段,通过 eth_call
或 sendTransaction
发送到智能合约,相当于手动构造了交易数据。
解码函数返回值:
interface.decodeFunctionResult("balanceOf", resultData)
看到这里你可能云里雾里觉得这东西很抽象,我学这里的时候也卡了好一会。为了解释清楚,看一个具体例子:
const data = interface.encodeFunctionData("balanceOf", ["0xc778417e063141139fce010982780140aa0cd5ab"]);
const resultData = await provider.call({
to: contractAddress,
data: data
});
const balance = interface.decodeFunctionResult("balanceOf", resultData);
console.log("Balance:", balance[0].toString());
provider.call
可以理解为一个发送方法,to
参数自然就是目标合约地址,而data
就是发送的信息。其实任何对合约的调用底层都是这么一个玩意,能发过去的只有一个data字段。那如果此时你想实现调用对面的某个函数,就必须把你的诉求表达为一串编码塞在data里发过去,这就是encodeFunctionData
的作用,它允许你把要调的函数及其参数表达成一串编码。
既然发过去了一串编码,对面在做完一系列操作后会给你返回一串编码——刚才说了,合约发东西的底层逻辑都只是发一串编码而已。你收到了编码也看不懂啊,于是你就需要decodeFunctionResult
把它解码成看得懂的格式。但是解释器怎么知道咋给你解码呢?这就需要你传入你调的是什么函数,它应该返回的参数来告诉解释器解码规则。
批量生成钱包
HD钱包(Hierarchical Deterministic Wallet,多层确定性钱包)是一种数字钱包 ,通常用于存储比特币和以太坊等加密货币持有者的数字密钥。通过它,用户可以从一个随机种子创建一系列密钥对,更加便利、安全、隐私。要理解HD钱包,我们需要简单了解比特币的BIP32,BIP44,和BIP39:
BIP32提出可以用一个随机种子衍生多个私钥,更方便的管理多个钱包。钱包的地址由衍生路径决定,例如
“m/0/0/1”,我感觉这个有点类似huffman tree:

BIP44
为BIP32
的衍生路径提供了一套通用规范,适配比特币、以太坊等多链。这一套规范包含六级,每级之间用"/"分割:
m / purpose' / coin_type' / account' / change / address_index
里边每一项具体是什么意思其实不用知道。
BIP39
让用户能以一些人类可记忆的助记词的方式保管私钥,而不是一串16进制的数字,如果用过metamask那种助记词恢复钱包肯定就知道这是啥:
//私钥
0x813f8f0a4df26f6455814fdd07dd2ab2d0e2d13f4d2f3c66e7fd9e3856060f89
//助记词
air organ twist rule prison symptom jazz cheap rather dizzy verb glare jeans orbit weapon universe require tired sing casino business anxiety seminar hunt
在批量生成钱包时,首先要生成一组助记词,可以看到这里相比于BIP44标准少了一个address_index
,这就是留给你生成很多钱包的余地:
// 生成随机助记词
const mnemonic = ethers.Mnemonic.entropyToPhrase(ethers.randomBytes(32))
// 创建HD基钱包
// 基路径:"m / purpose' / coin_type' / account' / change"
const basePath = "44'/60'/0'/0"
const baseWallet = ethers.HDNodeWallet.fromPhrase(mnemonic, basePath)
console.log(baseWallet);
有了base
钱包,就可以通过调整address_index
来生成钱包:
const numWallet = 20
// 派生路径:基路径 + "/ address_index"
// 我们只需要提供最后一位address_index的字符串格式,就可以从baseWallet派生出新钱包。V6中不需要重复提供基路径!
let wallets = [];
for (let i = 0; i < numWallet; i++) {
let baseWalletNew = baseWallet.derivePath(i.toString());
console.log(`第${i+1}个钱包地址: ${baseWalletNew.address}`)
wallets.push(baseWalletNew);
}
如果你用过metamask,就知道同一组助记词登进来后其实可以创建几乎无数个新钱包,其实就是基于这个原理。助记词只管前几个参数,你有了一组助记词之后就相当于可以随便拿着它去生成新钱包,而且都是你的,只要助记词不泄露。
批量转账
顾名思义,批量转账就是在同一笔交易中实现向多个地址转账。这样有什么用呢,为什么不老老实实地挨个转一遍?因为省gas。
每次转账都需要签名,而且都涉及状态变更,这都要耗gas。但是如果是批量转账,这些固定开销就只需要耗一次。你想想如果你想要往20个地址转账,我一次一个地址单独问你20次和一次拿20个问完你其实效果是一样的,但是明显后者更阳间一点。
如果要测试这个,首先需要一堆钱包,正好用上上边的批量生成。假设已经生成了20个钱包地址放在addresses = []
里。创建Airdrop和WETH合约的过程这里略过,只看核心代码:
console.log("\n4. 调用multiTransferETH()函数,给每个钱包转 0.0001 ETH")
// 发起交易
const tx = await contractAirdrop.multiTransferETH(addresses, amounts, {value: ethers.parseEther("0.002")})
// 等待交易上链
await tx.wait()
// console.log(`交易详情:`)
// console.log(tx)
const balanceETH2 = await provider.getBalance(addresses[10])
console.log(`发送后该钱包ETH持仓: ${ethers.formatEther(balanceETH2)}\n`)
可以看出其实核心部分只需要调Airdrop合约的multiTransferETH
函数就行,相当没技术含量。
批量归集
批量归集其实就是将多个钱包的ETH
和代币归集到一个钱包中,是批量转账的逆过程。主要用处是让你更加方便地用一堆钱包薅币撸毛。假设你已经有了20个钱包,保存在wallets = []
里。
归集的过程也比较暴力,就是挨个调用每个钱包的sendTransaction
方法:
console.log("\n4. 批量归集20个钱包的ETH")
const txSendETH = {
to: wallet.address,
value: amount
}
for (let i = 0; i < numWallet; i++) {
// 将钱包连接到provider
let walletiWithProvider = wallets[i].connect(provider)
var tx = await walletiWithProvider.sendTransaction(txSendETH)
console.log(`第 ${i+1} 个钱包 ${walletiWithProvider.address} ETH 归集开始`)
}
await tx.wait()
console.log(`ETH 归集结束`)
归集不能用类似Airdrop的合约省gas,其实也好理解:一般发钱才需要你签字确认而收钱不需要,所以签名消耗的gas省不掉。
默克尔树(MerkleTree)脚本
能看到这里我默认你(也可能是未来的我)知道默克尔树是什么,主要是我懒得再写了(
这里演示如何生成叶子数据包含4
个白名单地址的Merkle Tree
:
import { MerkleTree } from "merkletreejs";
// 白名单地址
const tokens = [
"0x5B38Da6a701c568545dCfcB03FcB875f56beddC4",
"0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2",
"0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db",
"0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB"
];
首先创建出四个叶节点,也就是对四个token取哈希:
const leaf = tokens.map(x => ethers.keccak256(x))
有叶节点后,MerkleTree.js
里有一个现成的MerkleTree
类可以直接从叶子生成一整棵树:
const merkletree = new MerkleTree(leaf, ethers.keccak256, { sortPairs: true });
// 获取root
const root = merkletree.getHexRoot()
想获得proof也很简单,可以按叶子序号拿到proof:
const proof = merkletree.getHexProof(leaf[0]);
一个比较有趣的输出方式是:
console.log(merkletree.toString())
这句输出后会显示出树的结构示意图。
我们都知道默克尔树是用来给NFT做白名单的,那从白名单到铸造NFT要经历哪些过程呢?
首先得确定白名单列表,也就是谁有资格铸造这种NFT,之后按上面方法生成默克尔树。之后部署NFT合约,树根需要存在合约里。如果有人想铸造,发送请求后合约会验证这个地址的proof,看它是否在白名单,如果在的话用户就可以调用mint方法铸造NFT。
数字签名脚本
以太坊使用的数字签名算法叫双椭圆曲线数字签名算法(ECDSA
),基于双椭圆曲线“私钥-公钥”对的数字签名算法。数字签名中的SignatureNFT
合约利用ECDSA
验证白名单铸造NFT
。
链下签名的步骤一般是:首先要有一个白名单地址列表,存在后端。每个白名单地址都会有一个唯一的消息,比如:
{
"address": "0x1234...abcd",
"nft_id": 1,
"expiry": 1710000000 // Unix 时间戳
}
签名不单需要消息,还需要钱包,进行链下签名时使用的钱包是项目方维护的签名钱包,这个钱包会用来和每条白名单消息生成签名:
const account = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4" // 这个account是某一个白名单地址
const tokenId = "0"
// 等效于Solidity中的keccak256(abi.encodePacked(account, tokenId))
// 用来签名的消息
const msgHash = ethers.solidityPackedKeccak256(
['address', 'uint256'],
[account, tokenId])
console.log(`msgHash:${msgHash}`)
// 签名
const messageHashBytes = ethers.getBytes(msgHash)
const signature = await wallet.signMessage(messageHashBytes); // 这里调用的是wallet对象的签名方法,wallet就是项目方的签名钱包
console.log(`签名:${signature}`)
签名钱包依次给每个白名单地址签好名后就可以部署NFT合约,签名钱包的公钥需要存在合约里用来验证签名。
代入第一视角,如果你想铸造一个NFT,首先要向项目方的后端(不上链,不耗gas且不透明)发请求让它给你签个名。后端收到请求后会检查你在不在白名单,在的话就签给你。你在拿到签名后就可以调用NFT合约的mint()
方法,mint()
只会检查你给它的签名是不是签名钱包签的,并不会检查你的地址在不在白名单(因为能拿到签名说明后端检查过了),如果验证无误就铸造NFT。
监听Mempool
Mempool
就是交易池,存的都是还没打包的交易。MEV(Maximal Extractable Value)就是针对Mempool设计的,挖矿必备。MEV不仅能排出手续费高的交易优先打包,还有些很不够揍的玩法,比如三明治攻击。
三明治攻击的原理就是,攻击者会在Mempool里找一笔高滑点交易(接受较大的价格浮动范围,这样攻击者有更大的操作空间),趁它还没被打包时立刻购买大量的相同代币来推高市场价格(AMM机制),待买家交易打包后由于代币数量进一步减少,价格会更高。此时攻击者再把这些币卖掉来赚差价。这种做法其实成本很低,得益于闪电贷,攻击者自己都没必要拥有足够大幅度推高市场价格的资金储备,还不必担心由于市场回调被套牢或者没人接盘的问题。
想实现上述操作当氮子儿,就要学会监听Mempool。可以通过如下代码监听未决交易并打印交易哈希:
let i = 0
provider.on("pending", async (txHash) => {
if (txHash && i < 100) {
// 打印txHash
console.log(`[${(new Date).toLocaleTimeString()}] 监听Pending交易 ${i}: ${txHash} \r`);
i++
}
});
或者可以打印交易详情,因为没有上链,所以只能获取一些有限的信息:
let j = 0
provider.on("pending", throttle(async (txHash) => {
if (txHash && j <= 100) {
// 获取tx详情
let tx = await provider.getTransaction(txHash);
console.log(`\n[${(new Date).toLocaleTimeString()}] 监听Pending交易 ${j}: ${txHash} \r`);
console.log(tx);
j++
}
}, 1000));
解码交易数据
上面我们虽然能打印出交易,但是其中的data
字段仍然是一串16进制编码。它实际上编码了这笔交易的内容:包括调用的函数,以及输入的参数。要解码这个东西就要用到Interface
类。Interface
也可以用类似abi
来声明:
const iface = ethers.Interface([
"function balanceOf(address) public view returns(uint)",
"function transfer(address, uint) public returns (bool)",
"function approve(address, uint256) public returns (bool)"
]);
例如,解码交易时需要创建一个能解码Transfer
的Interface
:
const iface = new ethers.Interface([
"function transfer(address, uint) public returns (bool)",
])
由于上述内容可以唯一确定一个函数,那也可以获得它的选择器:
const selector = iface.getFunction("transfer").selector
console.log(`函数选择器是${selector}`)
通过如下方法就能解码出交易信息中的data
:
// 把bigint处理成string好打印
function handleBigInt(key, value) {
if (typeof value === "bigint") {
return value.toString() + "n"; // or simply return value.toString();
}
return value;
}
// 监听Mempool
provider.on('pending', async (txHash) => {
if (txHash) {
const tx = await provider.getTransaction(txHash)
j++
if (tx !== null && tx.data.indexOf(selector) !== -1) {
console.log(`[${(new Date).toLocaleTimeString()}]监听到第${j + 1}个pending交易:${txHash}`)
//解码data的操作在这里
console.log(`打印解码交易详情:${JSON.stringify(iface.parseTransaction(tx), handleBigInt, 2)}`)
console.log(`转账目标地址:${iface.parseTransaction(tx).args[0]}`)
console.log(`转账金额:${ethers.formatEther(iface.parseTransaction(tx).args[1])}`)
provider.removeListener('pending', this)
}
}})
靓号生成器
这一节其实并没有什么太实际的作用,主要是学学正则表达式和如何优化哈希枚举。
js构建和检验正则表达式的格式是:
const regex = /^0x000.*$/ // 表达式,匹配以0x000开头的地址
isValid = regex.test(wallet.address) // 检验正则表达式
我拿其它的更高效的生成器生成过一些有趣的地址,比如0x0000...0000和0x985A...a211等,列出的这两个都是我在用的钱包。
读取任意(尤其是private)合约数据
这一章网站上也讲得稍微复杂点,让我想起了学操作系统和计组的恐怖感觉,所以这里简单写。以太坊所有数据都是公开的,因此 private
变量并不私密,它们没有getter函数,但是仍然可以直接去访问它们的插槽(可以理解为物理地址)来拿到值。
const value = await provider.getStorageAt(contractAddress, slot)
getStorageAt
方法只需传入合约地址和插槽索引就能读出值。
抢先交易(抢跑)脚本
抢跑脚本的目的只有一个,就是监听链上的交易并立刻发送一个高gas的相同交易。
比如编写一个抢先mint NFT的脚本:
const frontRun = async () => {
provider.on('pending', async (txHash) => {
const tx = await provider.getTransaction(txHash)
// 只监听mint交易,且不能是自己发的
if (tx.data.indexOf(getSignature("mint")) !== -1 && tx.from !== wallet.address) {
console.log(`[${(new Date).toLocaleTimeString()}]监听到交易:${txHash}\n准备抢先交易`)
// 构造抢先交易数据
const frontRunTx = {
to: tx.to,
value: tx.value,
// 把gas乘以2,让矿工先打包,其它照旧
maxPriorityFeePerGas: tx.maxPriorityFeePerGas.mul(2),
maxFeePerGas: tx.maxFeePerGas.mul(2),
gasLimit: tx.gasLimit.mul(2),
data: tx.data
}
const aimTokenId = (await contractFM.totalSupply()).add(1)
console.log(`即将被mint的NFT编号是:${aimTokenId}`)//打印应该被mint的nft编号
const sentFR = await wallet.sendTransaction(frontRunTx)
console.log(`正在frontrun交易`)
const receipt = await sentFR.wait()
console.log(`frontrun 交易成功,交易hash是:${receipt.transactionHash}`)
console.log(`铸造发起的地址是:${tx.from}`)
console.log(`编号${aimTokenId}NFT的持有者是${await contractFM.ownerOf(aimTokenId)}`)//刚刚mint的nft持有者并不是tx.from
console.log(`编号${aimTokenId.add(1)}的NFT的持有者是:${await contractFM.ownerOf(aimTokenId.add(1))}`)//tx.from被wallet.address抢跑,mint了下一个nft
console.log(`铸造发起的地址是不是对应NFT的持有者:${tx.from === await contractFM.ownerOf(aimTokenId)}`)//比对地址,tx.from被抢跑
//检验区块内数据结果
const block = await provider.getBlock(tx.blockNumber)
console.log(`区块内交易数据明细:${block.transactions}`)//在区块内,后发交易排在先发交易前,抢跑成功。
}
})
}
这个脚本可能mint到原本属于别人的NFT,但是对具有白名单的NFT无效。