ethers.js 自学笔记-2

发布于: 2025-07-08 · 30 min read · 更新于: 2025-07-12
ethers.jsWeb3

(接上文)

什么是 WETH ?

WETH (Wrapped ETH) 是以太坊上的一种代币,是 ETH 的包装版本。主要作用是:

  • 标准化:ETH 本身不是 ERC-20 代币,而 WETH 是标准的 ERC-20 代币
  • 兼容性:让 ETH 可以在支持 ERC-20 的 DeFi 协议中使用
  • 流动性:便于在 DEX 中交易和提供流动性

简单来说,WETH 就是"包装过的 ETH",1 WETH = 1 ETH。

标准的 WETH 地址

这里

第十五, 十六课 批量转账 批量归结

十五课的脚本有点问题, 现在 goerli 测试网已经停运了, 所以文章里的脚本执行是不通过的, 会报错, 所以我把脚本修改了如下:

1import { ethers } from "ethers";
2import { providerSepoliaAlchemy as provider, walletSepoliaAlchemy as wallet } from "./0_init.js";
3
4// 1. 创建HD钱包
5console.log("\n1. 创建HD钱包")
6// 通过助记词生成HD钱包
7const mnemonic = `air organ twist rule prison symptom jazz cheap rather dizzy verb glare jeans orbit weapon universe require tired sing casino business anxiety seminar hunt`
8const hdNode = ethers.HDNodeWallet.fromPhrase(mnemonic)
9console.log("主HD钱包地址:", hdNode.address);
10
11// 2. 派生20个子钱包地址
12console.log("\n2. 通过HD钱包派生20个子钱包")
13const numWallet = 20
14// 派生路径:m / purpose' / coin_type' / account' / change / address_index
15// 使用标准BIP44路径派生子钱包
16let addresses = [];
17for (let i = 0; i < numWallet; i++) {
18    // 使用deriveChild方法,从当前HD节点派生子钱包
19    const childNode = hdNode.deriveChild(i);
20    addresses.push(childNode.address);
21    console.log(`子钱包 ${i}: ${childNode.address}`);
22}
23console.log("所有派生地址:", addresses);
24
25// 3. 创建provider和主钱包,用于发送ETH
26console.log("3. 创建provider和主钱包")
27console.log(provider)
28
29// 利用私钥和provider创建wallet对象
30console.log("主钱包地址:", wallet.address);
31
32// 4. 批量转账ETH
33const main = async () => {
34    try {
35        console.log("\n4. 检查主钱包ETH余额")
36        const balance = await provider.getBalance(wallet.address);
37        console.log(`主钱包ETH余额: ${ethers.formatEther(balance)} ETH`);
38        
39        // 计算需要的总ETH(20个地址 * 0.0001 ETH + gas费用)
40        const transferAmount = ethers.parseEther("0.0001");
41        const totalNeeded = transferAmount * BigInt(numWallet) + ethers.parseEther("0.001"); // 额外0.001作为gas
42        
43        if (balance < totalNeeded) {
44            console.log("ETH余额不足,无法进行批量转账");
45            console.log("请使用水龙头获取测试ETH:");
46            console.log("1. Sepolia水龙头: https://sepoliafaucet.com/");
47            console.log("2. Alchemy水龙头: https://sepoliafaucet.com/");
48            return;
49        }
50        
51        console.log("\n5. 开始批量转账ETH")
52        const amount = ethers.parseEther("0.0001");
53        
54        for (let i = 0; i < addresses.length; i++) {
55            console.log(`正在转账给地址 ${i + 1}/${numWallet}: ${addresses[i]}`);
56            
57            const tx = await wallet.sendTransaction({
58                to: addresses[i],
59                value: amount
60            });
61            
62            console.log(`交易已发送,哈希: ${tx.hash}`);
63            await tx.wait(); // 等待交易上链
64            console.log(`交易已确认!`);
65        }
66        
67        console.log("\n✅ 批量转账完成!");
68        console.log(`已成功给 ${numWallet} 个地址各转账 0.0001 ETH`);
69        
70    } catch (error) {
71        console.error("转账过程中出现错误:", error.message);
72        if (error.message.includes("insufficient funds")) {
73            console.log("ETH余额不足,请检查钱包余额");
74        } else if (error.message.includes("network")) {
75            console.log("网络连接问题,请检查API Key和网络设置");
76        }
77    }
78}
79
80main()

执行后控制台打印如下:

1(base) ➜  etherjs-learn git:(main)node 15_multiTransfer.js
2[[email protected]] injecting env (7) from .env – [tip] encrypt with dotenvx: https://dotenvx.com
3
41. 创建HD钱包
5主HD钱包地址: 0x83E5B09c54C4EB904B9bC842Acab9218c2297d6d
6
72. 通过HD钱包派生20个子钱包
8子钱包 0: 0xe9A893f7646e0f3491a907771Bb1fB8a21718E45
9子钱包 1: 0x01119c09C0678Bfe901054594504af61A5fB029b
10子钱包 2: 0x9C4E2C9dc83d60E046A35Ba0bF6A1CA8859E1369
11子钱包 3: 0xff8A2C9e49059e31F9A9213e03EA7338F35577Cb
12子钱包 4: 0x8f0945F6ca61B95C19D135cCc6E5Dc19570a1459
13子钱包 5: 0xeC89A9BC4f07e47c3C520a3Cd3c0cA09f7CE625F
14子钱包 6: 0x4E7acd43514202328337b3Be3398160908276528
15子钱包 7: 0x51338ee6AEE836C7aA42eb1b26A767836f5cb43d
16子钱包 8: 0x90648f4c96769dC0584a3EBe62Cd952c915aF466
17子钱包 9: 0x9cE13df29Fd5b539e6512c06273514E26F910eC7
18子钱包 10: 0x47E516Fd1b6bf111d9DAdd807fF1fF0255FC9172
19子钱包 11: 0xb65870feF5E0F4A22e55F6186476f5023B7ACd88
20子钱包 12: 0x801C4A0630c4960C4f7EdB756e9c98d28E328d31
21子钱包 13: 0x71B95C72D468AC50bB3CB14958eeF253d9104152
22子钱包 14: 0xcB6B4C9c6fa841d5A19C77ABEE3a1CE07c06d45A
23子钱包 15: 0xFA2E002399392D27B56BBeA4bB969D8dB0968892
24子钱包 16: 0xD937d0D1C396c0f5BeA01B34AAfA3008f150F320
25子钱包 17: 0x65d05786DA5556cb4F3F14547D90Ce04A607426E
26子钱包 18: 0x0A3742AEA34eb8388725F90e8eeB4040D62C6c3F
27子钱包 19: 0xa6B97C11d77dEc3c3DF2135B704163111f6522F9
28所有派生地址: [
29  '0xe9A893f7646e0f3491a907771Bb1fB8a21718E45',
30  '0x01119c09C0678Bfe901054594504af61A5fB029b',
31  '0x9C4E2C9dc83d60E046A35Ba0bF6A1CA8859E1369',
32  '0xff8A2C9e49059e31F9A9213e03EA7338F35577Cb',
33  '0x8f0945F6ca61B95C19D135cCc6E5Dc19570a1459',
34  '0xeC89A9BC4f07e47c3C520a3Cd3c0cA09f7CE625F',
35  '0x4E7acd43514202328337b3Be3398160908276528',
36  '0x51338ee6AEE836C7aA42eb1b26A767836f5cb43d',
37  '0x90648f4c96769dC0584a3EBe62Cd952c915aF466',
38  '0x9cE13df29Fd5b539e6512c06273514E26F910eC7',
39  '0x47E516Fd1b6bf111d9DAdd807fF1fF0255FC9172',
40  '0xb65870feF5E0F4A22e55F6186476f5023B7ACd88',
41  '0x801C4A0630c4960C4f7EdB756e9c98d28E328d31',
42  '0x71B95C72D468AC50bB3CB14958eeF253d9104152',
43  '0xcB6B4C9c6fa841d5A19C77ABEE3a1CE07c06d45A',
44  '0xFA2E002399392D27B56BBeA4bB969D8dB0968892',
45  '0xD937d0D1C396c0f5BeA01B34AAfA3008f150F320',
46  '0x65d05786DA5556cb4F3F14547D90Ce04A607426E',
47  '0x0A3742AEA34eb8388725F90e8eeB4040D62C6c3F',
48  '0xa6B97C11d77dEc3c3DF2135B704163111f6522F9'
49]
503. 创建provider和主钱包
51JsonRpcProvider {}
52主钱包地址: 0xbd61c00E4B7C2a77Cd660304ddd852379F705ED6
53
544. 检查主钱包ETH余额
55主钱包ETH余额: 0.278647239098476179 ETH
56
575. 开始批量转账ETH
58正在转账给地址 1/20: 0xe9A893f7646e0f3491a907771Bb1fB8a21718E45
59交易已发送,哈希: 0xd7a988109c7b30bae7804f3ae8dd24f60217c177bab649428b265e80c6e04e3c
60交易已确认!
61正在转账给地址 2/20: 0x01119c09C0678Bfe901054594504af61A5fB029b
62交易已发送,哈希: 0xeb9ed5278f923bfcd2622555955531409eebf402a11dc63a617ed2f7fa938500
63交易已确认!
64正在转账给地址 3/20: 0x9C4E2C9dc83d60E046A35Ba0bF6A1CA8859E1369
65交易已发送,哈希: 0x098ccceb6f2a6066c20e9c7b522fea5b2676251ad8da83a6e0e780c36574830a
66交易已确认!
67正在转账给地址 4/20: 0xff8A2C9e49059e31F9A9213e03EA7338F35577Cb
68交易已发送,哈希: 0x3caef30935d62f26e4b8454430c7382838a9ff3a6a028f9c2ee686c66fbee57a
69交易已确认!
70正在转账给地址 5/20: 0x8f0945F6ca61B95C19D135cCc6E5Dc19570a1459
71交易已发送,哈希: 0x7646bbd71e1b9d836213ebfe14499f8002c5f69619feeb725f084fbff73a214b
72交易已确认!
73正在转账给地址 6/20: 0xeC89A9BC4f07e47c3C520a3Cd3c0cA09f7CE625F
74交易已发送,哈希: 0x4c8b423f2ffa77b245b18e82225f2cac2f26349470ffaf698abe56e82182d7c7
75交易已确认!
76正在转账给地址 7/20: 0x4E7acd43514202328337b3Be3398160908276528
77交易已发送,哈希: 0x06b00c8e62a44027651c0575f2e4950b8023d930547fc3fce150037d94640c5e
78交易已确认!
79正在转账给地址 8/20: 0x51338ee6AEE836C7aA42eb1b26A767836f5cb43d
80交易已发送,哈希: 0xb0f4f37c192523ee0bae0ba2b896ad22d2d9007795a0c3cfac1cbdbea903f4a8
81交易已确认!
82正在转账给地址 9/20: 0x90648f4c96769dC0584a3EBe62Cd952c915aF466
83交易已发送,哈希: 0xfffd6a87323a3d6f4002ddd96cfced7f6743e7ad83e04b2abcf1dfb72e00d36d
84交易已确认!
85正在转账给地址 10/20: 0x9cE13df29Fd5b539e6512c06273514E26F910eC7
86交易已发送,哈希: 0xd3565ddfa75cf9e8124121d710de7163868c8658b59443ff103fd15c34ef3b2c
87交易已确认!
88正在转账给地址 11/20: 0x47E516Fd1b6bf111d9DAdd807fF1fF0255FC9172
89交易已发送,哈希: 0x8a106080adb8c68f9b15c1cb7dd3a7e115f57be70c27f00af96717ddc3f0b189
90交易已确认!
91正在转账给地址 12/20: 0xb65870feF5E0F4A22e55F6186476f5023B7ACd88
92交易已发送,哈希: 0x0da78401ea4c33c41abcfa5e48c72e0e6abbde66a699ab4fc28a7633566a0869
93交易已确认!
94正在转账给地址 13/20: 0x801C4A0630c4960C4f7EdB756e9c98d28E328d31
95交易已发送,哈希: 0xec1eb4a883c088b74bcd3a060ea9bf18cfd0db72138fcb12046cbeb0fa2ba588
96交易已确认!
97正在转账给地址 14/20: 0x71B95C72D468AC50bB3CB14958eeF253d9104152
98交易已发送,哈希: 0x52b889f1bb930d8a78aa8f18d34a5f76b459703a3290aec8d80dff9127928981
99交易已确认!
100正在转账给地址 15/20: 0xcB6B4C9c6fa841d5A19C77ABEE3a1CE07c06d45A
101交易已发送,哈希: 0x871f02be2896e9e5b12b3acc35353ba5d8a6ef87911349abe6c450a37f3eb679
102交易已确认!
103正在转账给地址 16/20: 0xFA2E002399392D27B56BBeA4bB969D8dB0968892
104交易已发送,哈希: 0x5277ee66768d4f8b008c63838dec7b9f485314be28da499c3095e35b314b3f5a
105交易已确认!
106正在转账给地址 17/20: 0xD937d0D1C396c0f5BeA01B34AAfA3008f150F320
107交易已发送,哈希: 0x67e714eb5962a4f3dcfc0a216de1870417bf834588b88e3764780686af7c01fd
108交易已确认!
109正在转账给地址 18/20: 0x65d05786DA5556cb4F3F14547D90Ce04A607426E
110交易已发送,哈希: 0x3c8d24baa3a84f0a28edcb1838447c5542393eb67d67c2bf28edf4a7bb77256d
111交易已确认!
112正在转账给地址 19/20: 0x0A3742AEA34eb8388725F90e8eeB4040D62C6c3F
113交易已发送,哈希: 0xcc833f571ccaf2f50aeb74812f72f52756660dfa77c9e6a0167751f328ad1644
114交易已确认!
115正在转账给地址 20/20: 0xa6B97C11d77dEc3c3DF2135B704163111f6522F9
116交易已发送,哈希: 0x28685753bf0833a95356cff571a714824a181680fdc70ad054d31292b569f249
117交易已确认!
118
119✅ 批量转账完成!
120已成功给 20 个地址各转账 0.0001 ETH

以下是自动归结的脚本:

1import { ethers } from "ethers";
2import { providerSepoliaAlchemy as provider, walletSepoliaAlchemy as wallet } from "./0_init.js";
3
4// 1. 创建HD钱包
5console.log("\n1. 创建HD钱包")
6// 通过助记词生成HD钱包
7const mnemonic = `air organ twist rule prison symptom jazz cheap rather dizzy verb glare jeans orbit weapon universe require tired sing casino business anxiety seminar hunt`
8const hdNode = ethers.HDNodeWallet.fromPhrase(mnemonic)
9console.log("主HD钱包地址:", hdNode.address);
10
11// 2. 派生20个子钱包地址
12console.log("\n2. 通过HD钱包派生20个子钱包")
13const numWallet = 20
14// 派生路径:m / purpose' / coin_type' / account' / change / address_index
15// 使用标准BIP44路径派生子钱包
16let addresses = [];
17let childWallets = []; // 存储子钱包对象
18for (let i = 0; i < numWallet; i++) {
19    // 使用deriveChild方法,从当前HD节点派生子钱包
20    const childNode = hdNode.deriveChild(i);
21    addresses.push(childNode.address);
22    // 创建子钱包对象,用于签名交易
23    const childWallet = new ethers.Wallet(childNode.privateKey, provider);
24    childWallets.push(childWallet);
25    console.log(`子钱包 ${i}: ${childNode.address}`);
26}
27console.log("所有派生地址:", addresses);
28
29// 3. 创建provider和主钱包,用于接收ETH
30console.log("\n3. 创建provider和主钱包")
31console.log("主钱包地址:", wallet.address);
32
33// 4. 批量归集ETH
34const main = async () => {
35    try {
36        console.log("\n4. 检查各子钱包ETH余额")
37        let totalBalance = ethers.parseEther("0");
38        let walletsWithBalance = [];
39        
40        // 检查每个子钱包的余额
41        for (let i = 0; i < addresses.length; i++) {
42            const balance = await provider.getBalance(addresses[i]);
43            console.log(`子钱包 ${i} 余额: ${ethers.formatEther(balance)} ETH`);
44            
45            if (balance > ethers.parseEther("0.0001")) { // 只归集有足够余额的钱包
46                walletsWithBalance.push({
47                    index: i,
48                    address: addresses[i],
49                    wallet: childWallets[i],
50                    balance: balance
51                });
52                totalBalance += balance;
53            }
54        }
55        
56        console.log(`\n总可归集余额: ${ethers.formatEther(totalBalance)} ETH`);
57        console.log(`有余额的钱包数量: ${walletsWithBalance.length}`);
58        
59        if (walletsWithBalance.length === 0) {
60            console.log("没有找到有足够余额的钱包进行归集");
61            return;
62        }
63        
64        console.log("\n5. 开始批量归集ETH")
65        
66        for (let i = 0; i < walletsWithBalance.length; i++) {
67            const walletInfo = walletsWithBalance[i];
68            const childWallet = walletInfo.wallet;
69            const balance = walletInfo.balance;
70            
71            // 计算转账金额(保留一些ETH作为gas费用)
72            const gasEstimate = ethers.parseEther("0.001"); // 预估gas费用
73            const transferAmount = balance - gasEstimate;
74            
75            if (transferAmount <= 0) {
76                console.log(`子钱包 ${walletInfo.index} 余额不足支付gas费用,跳过`);
77                continue;
78            }
79            
80            console.log(`正在归集子钱包 ${walletInfo.index}: ${walletInfo.address}`);
81            console.log(`转账金额: ${ethers.formatEther(transferAmount)} ETH`);
82            
83            try {
84                const tx = await childWallet.sendTransaction({
85                    to: wallet.address,
86                    value: transferAmount
87                });
88                
89                console.log(`交易已发送,哈希: ${tx.hash}`);
90                await tx.wait(); // 等待交易上链
91                console.log(`交易已确认!`);
92                
93            } catch (error) {
94                console.error(`子钱包 ${walletInfo.index} 归集失败:`, error.message);
95            }
96        }
97        
98        // 检查归集后的主钱包余额
99        const finalBalance = await provider.getBalance(wallet.address);
100        console.log(`\n✅ 批量归集完成!`);
101        console.log(`主钱包最终余额: ${ethers.formatEther(finalBalance)} ETH`);
102        
103    } catch (error) {
104        console.error("归集过程中出现错误:", error.message);
105        if (error.message.includes("insufficient funds")) {
106            console.log("某些钱包余额不足支付gas费用");
107        } else if (error.message.includes("network")) {
108            console.log("网络连接问题,请检查API Key和网络设置");
109        }
110    }
111}
112
113main()

默克尔树

  1. 文中介绍这个概念我看不太懂, 后来问了 AI 才知道他的作用:

Merkle Tree(默克尔树) 是一种树形数据结构,用于高效地验证大量数据的完整性。

🌳 核心特点:

  • 数据完整性验证:快速验证某个数据是否属于一个大数据集
  • 零知识证明:证明数据存在而不暴露完整数据集
  • 高效性:验证复杂度为 O(log n),而不是 O(n)

alt text

这个图的解读

  1. 由L1 -得出-> Hash 0-0
  2. Hash 0-0 和 Hash0-1 做一次 hash 得到父结点 Hash 0
  3. Hash 0 和 Hash 1 做一次 hash 得到根节点 Top Hash
  4. 由此可知, 只需要知道 Hash 0-1 和 Hash 1 的值, 便可知道 Top Hash的值, 可以判断 L1 是不是属于 Top Hash 这棵哈希树的

默克尔树验证及代码

这节课我复制了WTF 的代码但是老报错, 所以我重新写了一组代码: 运行该文件需要注意的点 :

  1. 下面的 17_contract.json 文件可以参考这里
  2. 由于 WTF 的编译代码不能用, 所以我叫 AI 生成了一个 NFTWhitelistContract.sol 文件, 然后AI又很好心的检测到里面有 openzeppelin 外部库, 所以直接又帮我生成一份不依赖外部库的 SimpleERC721Merkle.sol 文件
  3. 可以将 SimpleERC721Merkle.sol 文件贴到 Remix 上进行编译
  4. 复制 Remix上编译后的这个路径 artifacts/SimpleERC721Merkle.json 的文件, 粘贴生成 17_contract.json 文件

做完以上步骤, 可以运行以下代码:

1import { ethers } from "ethers";
2import { MerkleTree } from "merkletreejs";
3import { providerSepoliaInfura as provider, walletSepoliaInfura as wallet } from "./0_init.js";
4import contractJson from './17_contract.json' assert { type: 'json' };
5
6console.log("=== 默克尔树NFT铸造系统 ===");
7
8// 1. 生成默克尔树
9console.log("1. 生成默克尔树");
10// 白名单地址
11const whitelistAddresses = [
12    "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4", 
13    "0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2",
14    "0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db",
15    "0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB"
16];
17
18// 生成叶子节点(地址的哈希)
19const leaves = whitelistAddresses.map(x => ethers.keccak256(x));
20const merkletree = new MerkleTree(leaves, ethers.keccak256, { sortPairs: true });
21const root = merkletree.getHexRoot();
22
23console.log("白名单地址:");
24console.log(whitelistAddresses);
25console.log("默克尔根:", root);
26
27// 2. 验证地址是否在白名单中
28function verifyWhitelist(address) {
29    const leaf = ethers.keccak256(address);
30    const proof = merkletree.getHexProof(leaf);
31    const isValid = merkletree.verify(proof, leaf, root);
32    
33    console.log(`验证地址: ${address}`);
34    console.log(`叶子节点: ${leaf}`);
35    console.log(`默克尔证明: ${JSON.stringify(proof)}`);
36    console.log(`是否在白名单中: ${isValid}`);
37    
38    return { isValid, proof };
39}
40
41// 3. 测试验证功能
42console.log("2. 测试白名单验证");
43const testAddresses = [
44    "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4", // 在白名单中
45    "0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2", // 在白名单中
46    "0x1234567890123456789012345678901234567890"   // 不在白名单中
47];
48
49testAddresses.forEach(address => {
50    verifyWhitelist(address);
51});
52
53// 4. 从JSON文件获取合约信息
54const bytecodeNFT = contractJson.data.bytecode.object;
55const abiNFT = contractJson.abi;
56
57console.log("3. 合约信息");
58console.log("字节码长度:", bytecodeNFT.length);
59console.log("字节码是否以0x开头:", bytecodeNFT.startsWith("0x"));
60console.log("ABI函数数量:", abiNFT.length);
61
62// 5. 主要功能
63const main = async () => {
64    try {
65        console.log("4. 检查钱包余额");
66        const balanceETH = await provider.getBalance(wallet);
67        console.log("钱包余额:", ethers.formatEther(balanceETH), "ETH");
68        
69        if (bytecodeNFT.length === 0) {
70            console.log("⚠️  字节码为空,请检查JSON文件");
71            return;
72        }
73        
74        console.log("5. 部署NFT合约");
75        const factoryNFT = new ethers.ContractFactory(abiNFT, bytecodeNFT, wallet);
76        
77        // 部署参数:合约名称、符号、默克尔根
78        const contractName = "MerkleNFT";
79        const contractSymbol = "MNFT";
80        
81        console.log("部署参数:");
82        console.log("- 名称:", contractName);
83        console.log("- 符号:", contractSymbol);
84        console.log("- 默克尔根:", root);
85        
86        const nftContract = await factoryNFT.deploy(contractName, contractSymbol, root);
87        await nftContract.waitForDeployment();
88        
89        const contractAddress = await nftContract.getAddress();
90        console.log("✅ 合约部署成功!地址:", contractAddress);
91        
92        console.log("6. 铸造NFT");
93        const tokenId = 1;
94        const mintToAddress = whitelistAddresses[0]; // 使用白名单中的第一个地址
95        
96        // 获取该地址的默克尔证明
97        const { isValid, proof } = verifyWhitelist(mintToAddress);
98        
99        if (!isValid) {
100            console.log("❌ 地址不在白名单中,无法铸造");
101            return;
102        }
103        
104        console.log("铸造参数:");
105        console.log("- 接收地址:", mintToAddress);
106        console.log("- Token ID:", tokenId);
107        console.log("- 默克尔证明:", proof);
108        
109        const mintTx = await nftContract.mint(mintToAddress, tokenId, proof);
110        await mintTx.wait();
111        
112        console.log("✅ NFT铸造成功!");
113        
114        // 验证铸造结果
115        const owner = await nftContract.ownerOf(tokenId);
116        console.log("Token", tokenId, "的所有者:", owner);
117        
118        const balance = await nftContract.balanceOf(mintToAddress);
119        console.log("地址", mintToAddress, "的NFT余额:", balance.toString());
120        
121        // 7. 测试非白名单地址铸造(应该失败)
122        console.log("7. 测试非白名单地址铸造");
123        const nonWhitelistAddress = "0x1234567890123456789012345678901234567890";
124        const { proof: invalidProof } = verifyWhitelist(nonWhitelistAddress);
125        
126        try {
127            const invalidMintTx = await nftContract.mint(nonWhitelistAddress, 2, invalidProof);
128            await invalidMintTx.wait();
129            console.log("❌ 非白名单地址铸造成功(不应该发生)");
130        } catch (error) {
131            console.log("✅ 非白名单地址铸造失败(符合预期)");
132            console.log("错误信息:", error.message);
133        }
134        
135    } catch (error) {
136        console.error("❌ 错误:", error);
137    }
138}
139
140// 8. 离线验证功能(不依赖网络)
141function offlineVerification() {
142    console.log("8. 离线验证功能");
143    
144    // 验证所有白名单地址
145    whitelistAddresses.forEach((address, index) => {
146        const { isValid, proof } = verifyWhitelist(address);
147        console.log(`地址 ${index + 1}: ${isValid ? '✅' : '❌'} ${address}`);
148    });
149    
150    // 验证非白名单地址
151    const nonWhitelistAddress = "0x1234567890123456789012345678901234567890";
152    const { isValid } = verifyWhitelist(nonWhitelistAddress);
153    console.log(`非白名单地址: ${isValid ? '❌' : '✅'} ${nonWhitelistAddress}`);
154}
155
156// 运行离线验证
157offlineVerification();
158
159// 运行主程序
160main();

在 zsh上跑了结果如下:

1(base) ➜  etherjs-learn git:(main)node 17_MerkleTree.js
2(node:33837) ExperimentalWarning: Importing JSON modules is an experimental feature and might change at any time
3(Use `node --trace-warnings ...` to show where the warning was created)
4[[email protected]] injecting env (7) from .env – [tip] encrypt with dotenvx: https://dotenvx.com
5=== 默克尔树NFT铸造系统 ===
6
71. 生成默克尔树
8白名单地址:
9[
10  '0x5B38Da6a701c568545dCfcB03FcB875f56beddC4',
11  '0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2',
12  '0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db',
13  '0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB'
14]
15默克尔根: 0xeeefd63003e0e702cb41cd0043015a6e26ddb38073cc6ffeb0ba3e808ba8c097
16
172. 测试白名单验证
18
19验证地址: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
20叶子节点: 0x5931b4ed56ace4c46b68524cb5bcbf4195f1bbaacbe5228fbd090546c88dd229
21默克尔证明: ["0x999bf57501565dbd2fdcea36efa2b9aef8340a8901e3459f4a4c926275d36cdb","0x4726e4102af77216b09ccd94f40daa10531c87c4d60bba7f3b3faf5ff9f19b3c"]
22是否在白名单中: true
23
24验证地址: 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2
25叶子节点: 0x999bf57501565dbd2fdcea36efa2b9aef8340a8901e3459f4a4c926275d36cdb
26默克尔证明: ["0x5931b4ed56ace4c46b68524cb5bcbf4195f1bbaacbe5228fbd090546c88dd229","0x4726e4102af77216b09ccd94f40daa10531c87c4d60bba7f3b3faf5ff9f19b3c"]
27是否在白名单中: true
28
29验证地址: 0x1234567890123456789012345678901234567890
30叶子节点: 0xb6979620706f8c652cfb6bf6e923f5156eadd5abaf4022a0b19d52ada089475f
31默克尔证明: []
32是否在白名单中: false
33
343. 合约信息
35字节码长度: 19606
36字节码是否以0x开头: false
37file:///Users/ziyouzhiyi/Documents/code/web3/etherjs-learn/17_MerkleTree.js:60
38console.log("ABI函数数量:", abiNFT.length);
39                               ^
40
41TypeError: Cannot read properties of undefined (reading 'length')
42    at file:///Users/ziyouzhiyi/Documents/code/web3/etherjs-learn/17_MerkleTree.js:60:32
43    at ModuleJob.run (node:internal/modules/esm/module_job:222:25)
44    at async ModuleLoader.import (node:internal/modules/esm/loader:316:24)
45    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:123:5)
46
47Node.js v20.13.1
48(base) ➜  etherjs-learn git:(main)node 17_MerkleTree.js
49(node:34015) ExperimentalWarning: Importing JSON modules is an experimental feature and might change at any time
50(Use `node --trace-warnings ...` to show where the warning was created)
51[[email protected]] injecting env (7) from .env – [tip] encrypt with dotenvx: https://dotenvx.com
52=== 默克尔树NFT铸造系统 ===
53
541. 生成默克尔树
55白名单地址:
56[
57  '0x5B38Da6a701c568545dCfcB03FcB875f56beddC4',
58  '0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2',
59  '0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db',
60  '0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB'
61]
62默克尔根: 0xeeefd63003e0e702cb41cd0043015a6e26ddb38073cc6ffeb0ba3e808ba8c097
63
642. 测试白名单验证
65
66验证地址: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
67叶子节点: 0x5931b4ed56ace4c46b68524cb5bcbf4195f1bbaacbe5228fbd090546c88dd229
68默克尔证明: ["0x999bf57501565dbd2fdcea36efa2b9aef8340a8901e3459f4a4c926275d36cdb","0x4726e4102af77216b09ccd94f40daa10531c87c4d60bba7f3b3faf5ff9f19b3c"]
69是否在白名单中: true
70
71验证地址: 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2
72叶子节点: 0x999bf57501565dbd2fdcea36efa2b9aef8340a8901e3459f4a4c926275d36cdb
73默克尔证明: ["0x5931b4ed56ace4c46b68524cb5bcbf4195f1bbaacbe5228fbd090546c88dd229","0x4726e4102af77216b09ccd94f40daa10531c87c4d60bba7f3b3faf5ff9f19b3c"]
74是否在白名单中: true
75
76验证地址: 0x1234567890123456789012345678901234567890
77叶子节点: 0xb6979620706f8c652cfb6bf6e923f5156eadd5abaf4022a0b19d52ada089475f
78默克尔证明: []
79是否在白名单中: false
80
813. 合约信息
82字节码长度: 19606
83字节码是否以0x开头: false
84ABI函数数量: 21
85
868. 离线验证功能
87
88验证地址: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
89叶子节点: 0x5931b4ed56ace4c46b68524cb5bcbf4195f1bbaacbe5228fbd090546c88dd229
90默克尔证明: ["0x999bf57501565dbd2fdcea36efa2b9aef8340a8901e3459f4a4c926275d36cdb","0x4726e4102af77216b09ccd94f40daa10531c87c4d60bba7f3b3faf5ff9f19b3c"]
91是否在白名单中: true
92地址 1: ✅ 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
93
94验证地址: 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2
95叶子节点: 0x999bf57501565dbd2fdcea36efa2b9aef8340a8901e3459f4a4c926275d36cdb
96默克尔证明: ["0x5931b4ed56ace4c46b68524cb5bcbf4195f1bbaacbe5228fbd090546c88dd229","0x4726e4102af77216b09ccd94f40daa10531c87c4d60bba7f3b3faf5ff9f19b3c"]
97是否在白名单中: true
98地址 2: ✅ 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2
99
100验证地址: 0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db
101叶子节点: 0x04a10bfd00977f54cc3450c9b25c9b3a502a089eba0097ba35fc33c4ea5fcb54
102默克尔证明: ["0xdfbe3e504ac4e35541bebad4d0e7574668e16fefa26cd4172f93e18b59ce9486","0x9d997719c0a5b5f6db9b8ac69a988be57cf324cb9fffd51dc2c37544bb520d65"]
103是否在白名单中: true
104地址 3: ✅ 0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db
105
106验证地址: 0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB
107叶子节点: 0xdfbe3e504ac4e35541bebad4d0e7574668e16fefa26cd4172f93e18b59ce9486
108默克尔证明: ["0x04a10bfd00977f54cc3450c9b25c9b3a502a089eba0097ba35fc33c4ea5fcb54","0x9d997719c0a5b5f6db9b8ac69a988be57cf324cb9fffd51dc2c37544bb520d65"]
109是否在白名单中: true
110地址 4: ✅ 0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB
111
112验证地址: 0x1234567890123456789012345678901234567890
113叶子节点: 0xb6979620706f8c652cfb6bf6e923f5156eadd5abaf4022a0b19d52ada089475f
114默克尔证明: []
115是否在白名单中: false
116非白名单地址: ✅ 0x1234567890123456789012345678901234567890
117
1184. 检查钱包余额
119钱包余额: 0.273626490190575529 ETH
120
1215. 部署NFT合约
122部署参数:
123- 名称: MerkleNFT
124- 符号: MNFT
125- 默克尔根: 0xeeefd63003e0e702cb41cd0043015a6e26ddb38073cc6ffeb0ba3e808ba8c097
126✅ 合约部署成功!地址: 0xeb18bcCA8872587847F1145Cb15D0cF3Ff9A6BbA
127
1286. 铸造NFT
129
130验证地址: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
131叶子节点: 0x5931b4ed56ace4c46b68524cb5bcbf4195f1bbaacbe5228fbd090546c88dd229
132默克尔证明: ["0x999bf57501565dbd2fdcea36efa2b9aef8340a8901e3459f4a4c926275d36cdb","0x4726e4102af77216b09ccd94f40daa10531c87c4d60bba7f3b3faf5ff9f19b3c"]
133是否在白名单中: true
134铸造参数:
135- 接收地址: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
136- Token ID: 1
137- 默克尔证明: [
138  '0x999bf57501565dbd2fdcea36efa2b9aef8340a8901e3459f4a4c926275d36cdb',
139  '0x4726e4102af77216b09ccd94f40daa10531c87c4d60bba7f3b3faf5ff9f19b3c'
140]
141✅ NFT铸造成功!
142Token 1 的所有者: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
143地址 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 的NFT余额: 1
144
1457. 测试非白名单地址铸造
146
147验证地址: 0x1234567890123456789012345678901234567890
148叶子节点: 0xb6979620706f8c652cfb6bf6e923f5156eadd5abaf4022a0b19d52ada089475f
149默克尔证明: []
150是否在白名单中: false
151✅ 非白名单地址铸造失败(符合预期)
152错误信息: execution reverted: "Invalid merkle proof" (action="estimateGas", data="0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000014496e76616c6964206d65726b6c652070726f6f66000000000000000000000000", reason="Invalid merkle proof", transaction={ "data": "0x641ce1400000000000000000000000001234567890123456789012345678901234567890000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000", "from": "0xbd61c00E4B7C2a77Cd660304ddd852379F705ED6", "to": "0xeb18bcCA8872587847F1145Cb15D0cF3Ff9A6BbA" }, invocation=null, revert={ "args": [ "Invalid merkle proof" ], "name": "Error", "signature": "Error(string)" }, code=CALL_EXCEPTION, version=6.15.0)

可以看到, 函数 function verifyWhitelist(address) 中的 const isValid = merkletree.verify(proof, leaf, root); 这一步是关键, 这里传入的 3个参数分别为:

  1. 该地址的验证证明, .getHexProof(ethers.keccak256(addr)) , 该地址必须经keccak256编码后, 再用 merkletree 的getHexProof 16进制的编码
  2. 叶子节点经过 keccak256 编码的hash值
  3. 根节点哈希值, 由 .getHexRoot() 生成

数字签名

数字签名对比上面的默克尔树验证, 节省了 Gas 值, 因为纯粹是后端进行验证. 不需要链上的操作. WTF 代码中, 真正数字签名的代码, 只有下面这几行涉及到:

1
2// 钱包地址:
3const account = "0xbd61c00E4B7C2a77Cd660304ddd852379F705ED6"
4const tokenId = "0"
5// 生成签名
6const msgHash = ethers.solidityPackedKeccak256(
7    ['address', 'uint256'],
8    [account, tokenId])
9
10// 其他代码...
11
12// mint 时做身份检查:
13const tx = await contractNFT.mint(account, tokenId, signature, { value: 0 })

也把我写的 solidity 源码贴出来: 特别留意其中的 mint 函数校验部分

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.20;
3
4import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
5import "@openzeppelin/contracts/access/Ownable.sol";
6import "@openzeppelin/contracts/utils/Counters.sol";
7
8contract SignatureNFT is ERC721, Ownable {
9    using Counters for Counters.Counter;
10    Counters.Counter private _tokenIds;
11    
12    uint256 public maxSupply = 1000;
13    uint256 public mintPrice = 0 ether;
14    
15    mapping(address => bool) public hasMinted;
16    
17    event TokenMinted(address indexed to, uint256 indexed tokenId);
18    
19    constructor() ERC721("SignatureNFT", "SNFT") Ownable(msg.sender) {}
20    
21    // 铸造函数 - 带签名验证
22    function mint(address account, uint256 tokenId, bytes calldata signature) public payable {
23        require(_tokenIds.current() < maxSupply, "Max supply reached");
24        require(msg.value >= mintPrice, "Insufficient payment");
25        require(!hasMinted[account], "Already minted");
26        require(tokenId == 0, "Only tokenId 0 is allowed");
27        require(account == msg.sender, "Can only mint to self");
28        
29        // [!!!重点!!!] 验证签名
30        bytes32 messageHash = keccak256(abi.encodePacked(account, tokenId));
31        bytes32 ethSignedMessageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));
32        
33        (uint8 v, bytes32 r, bytes32 s) = splitSignature(signature);
34        address signer = ecrecover(ethSignedMessageHash, v, r, s);
35        
36        require(signer == owner(), "Invalid signature");
37        
38        hasMinted[account] = true;
39        _tokenIds.increment();
40        uint256 newTokenId = _tokenIds.current();
41        
42        _safeMint(msg.sender, newTokenId);
43        emit TokenMinted(msg.sender, newTokenId);
44    }
45    
46    // 分割签名
47    function splitSignature(bytes memory sig) internal pure returns (uint8 v, bytes32 r, bytes32 s) {
48        require(sig.length == 65, "Invalid signature length");
49        
50        assembly {
51            r := mload(add(sig, 32))
52            s := mload(add(sig, 64))
53            v := byte(0, mload(add(sig, 96)))
54        }
55    }
56    
57    // 批量铸造(仅所有者)
58    function mintBatch(address to, uint256 quantity) public onlyOwner {
59        require(_tokenIds.current() + quantity <= maxSupply, "Exceeds max supply");
60        
61        for (uint256 i = 0; i < quantity; i++) {
62            _tokenIds.increment();
63            uint256 newTokenId = _tokenIds.current();
64            _safeMint(to, newTokenId);
65            emit TokenMinted(to, newTokenId);
66        }
67    }
68    
69    // 设置铸造价格
70    function setMintPrice(uint256 _price) public onlyOwner {
71        mintPrice = _price;
72    }
73    
74    // 设置最大供应量
75    function setMaxSupply(uint256 _maxSupply) public onlyOwner {
76        require(_maxSupply >= _tokenIds.current(), "Cannot reduce below current supply");
77        maxSupply = _maxSupply;
78    }
79    
80    // 提取合约余额
81    function withdraw() public onlyOwner {
82        uint256 balance = address(this).balance;
83        payable(owner()).transfer(balance);
84    }
85    
86    // 获取当前铸造的 token 数量
87    function totalSupply() public view returns (uint256) {
88        return _tokenIds.current();
89    }
90    
91    // 获取剩余可铸造数量
92    function remainingSupply() public view returns (uint256) {
93        return maxSupply - _tokenIds.current();
94    }
95}

而原来的代码我也弃用了, 写了这份新的代码, 因为原来的代码是从部署开始的, 我改造了一下, 部署在 Remix 里即可, 而我们的只关系铸造验证签名的逻辑, 不部署. 在终端运行后如下:

1(base) ➜  etherjs-learn git:(main)node 18_signature.js
2[[email protected]] injecting env (7) from .env – [tip] encrypt with dotenvx: https://dotenvx.com
3使用合约地址: 0x05a93f8573631eae3f5aa045e8ea027547d57279
4合约所有者: 0xbd61c00E4B7C2a77Cd660304ddd852379F705ED6
5当前钱包地址: 0xbd61c00E4B7C2a77Cd660304ddd852379F705ED6
6铸造地址: 0xbd61c00E4B7C2a77Cd660304ddd852379F705ED6
7Token ID: 0
8消息哈希: 0x38ab276f75354c8585b4547873729bbc4b95984ad74f4eb03942be9a0a8e8777
9签名: 0xf6ff63b010d1a4529bece50317dd70fc5c2eca8e5c4d2c3a543b931e3ec5200b30dc2d1d837cf3a72c1ef3edabb7f86f0d23015020d0a73e651b4c0bd2dee6081c
10恢复的签名者地址: 0xbd61c00E4B7C2a77Cd660304ddd852379F705ED6
11签名者是否匹配: true
12钱包ETH余额: 0.265221514033323046 ETH
13是否已经铸造过: false
14
15开始铸造NFT...
16NFT名称: SignatureNFT
17NFT代号: SNFT
18铸造中,等待交易上链...
19交易哈希: 0x84fb8e3cde000995e365ae12743944b2ddf60b65c81b67aa35e6a1b843b10dfe
20✅ 铸造成功!
21地址 0xbd61c00E4B7C2a77Cd660304ddd852379F705ED6 的NFT余额: 1
22合约总供应量: 1

附上如何获得合约地址的截图:

alt text

当然, 也可以在 ether scan 上粘贴自己的钱包代码查找: alt text

监听Mempool

这课的代码主要是这个:

1    provider.on("pending", throttle(async (txHash) => {
2        if (txHash && j <= 100) {
3            // 获取tx详情
4            let tx = await provider.getTransaction(txHash);
5            console.log(`\n[${(new Date).toLocaleTimeString()}] 监听Pending交易 ${j}: ${txHash} \r`);
6            console.log(tx);
7            j++
8            }
9    }, 1000));

利用provider 进行监听交易, 获取Gas 值高的交易进行打包, WTF 课程里的代码监听的是主网, 现在连接不了, 所以我只能改用 wss 的 sepolia 测试网

他会自动挑选出 gasPrice 值较高的交易: alt text

评论区