ethers.js 自学笔记-1

发布于: 2025-07-04 · 23 min read · 更新于: 2025-07-09
合约开发ethers.js

本文根据 WFT 学院的 ehter.js 课程自学及b 站上 五里墩茶社ehtersjs 播放列表, 讲的不够清楚的地方, 进行了记录. 关于基本知识, WTF 学院的文档上已有, 我这里不再一一列举. 主要是补充一下操作中要注意的点.

课前准备

最好就全都给注册一遍

网站

  1. 查询合约, 钱包, 及交易等信息: ether Scan

  2. 合约开发API_KEY 必备地址: Alchemy, Infura, 这两个可以二选一, 但我有时候会碰到 Infura网络问题, 所以两个都注册了.

  3. 小狐狸🦊钱包, chrome浏览器插件, 请自行搜索并注册

  4. 由于课程代码不能暴露敏感的数据, 即使测试账号也不行, 所以我在我自己的项目里安装了 npm i dotenv 并且在示例代码里必须 加上

1import dotenv from "dotenv";
2dotenv.config();
3
4// 连接以太坊主网
5const providerETH = new ethers.JsonRpcProvider(process.env.MAINNET_RPC_URL);
6// 连接Sepolia测试网
7const providerSepolia = new ethers.JsonRpcProvider(process.env.SEPOLIA_RPC_URL);

操作及语法的注意点

第一课

  1. 代码里的const balance = await provider.getBalance(vitalik.eth); 字符串: "vitalik.eth" 是以太坊的创始人, 这里可以替换为合约地址或者钱包地址, "vitalik.eth"的钱包地址为: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045, 这串代码可以替换为 const balance = await provider.getBalance(0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045); 这种钱包地址可以到 https://app.ens.domains/ 上注册一个用户名, 类似 vitalik.eth, 类似于域名

第二课 Provider

  1. 文档里的Infura 和 Alchemy 的jsonRpcProvider获取方式截图, 已经比较久远, 这里放上2025年7月份的最新获取截图, 注册完成后系统会提示你生成第一个 API key, 按照以下点击: 二选一 Infura

    Alchemy

第三课读取合约 Contract

  1. 代码里的 const abiWETH = '[{"constant":true,"inputs":[],"name":"name","outputs":[{"....<太长省略>' 是一串将合约转换为 json 对象, 在 Remix IDE 编写合约后, 点击 Compile 后, 再Artifact (产物文件夹)可以找到: 说白了合约编译后就是一堆类似 js AST 的 json 对象 alt text

  2. 人类可读abi 里的 ERC20 数组, 即下面这段,

1const abiERC20 = [
2    "function name() view returns (string)",
3    "function symbol() view returns (string)",
4    "function totalSupply() view returns (uint256)",
5    "function balanceOf(address) view returns (uint)",
6];

对应的是 ERC20.sol 文件里固有的属性及函数

第四课 Wallet

  1. 文档里提到的三种钱包的创建方法, 实际上只有一种. 第三种创建方式 const wallet3 = ethers.Wallet.fromPhrase(mnemonic.phrase) 由于利用了第一种的 provider const provider = new ethers.JsonRpcProvider(ALCHEMY_GOERLI_URL);的助记词创建, 所以创建出来的钱包和第一种一样, 这个文章里提到了. 但第二种的创建方式:
1const providerSepolia = new ethers.JsonRpcProvider('0x123...')
2
3const privateKey = '0x123...'
4const wallet2 = new ethers.Wallet(privateKey, providerSepolia)

用了旧的账号的 provider 和旧账号的私钥生成的就是和旧账号一模一样, 我也验证过了, 所以实际上第二种方法只是将你的旧账号搬到代码里来而已. 真正能在js 代码里, 生成一个全新账号的, 只有一种方法, 即:

1const wallet1 = ethers.Wallet.createRandom()
  1. 为了验证, 我添加 wallet1 的助记词到我的钱包, 看看是否转账成功:

下面这个尾号...05EDb 的账号减少了 0.0001 eth alt text

下面这个尾号 ...b8F3e 的账号增加了 0.0001 eth alt text

第六课 部署

  1. 文档里介绍的在 Remix 里编译 ERC20 源码, 并点击编译栏下的 bytecode 按钮复制 bytecode 字段,

alt text

现在复制出来的已经是一个单纯的字节码字符串, 比如 60806040526012......634300081e0033 (太长省略了...)不用再到 json文件里去找bytecode 字段了.

  1. 上面图片中, 我圈出的 abi 按钮, 可以复制出 json 格式的 abi, 比如:
1[
2	{
3		"inputs": [
4			{
5				"internalType": "string",
6				"name": "name_",
7				"type": "string"
8			},
9			{
10				"internalType": "string",
11				"name": "symbol_",
12				"type": "string"
13			}
14		],
15		"stateMutability": "nonpayable",
16		"type": "constructor"
17    .....
18]

以上的 json格式, 可以利用以下的脚本, 将其转换为人类可读 abi:

1import fs from "fs";
2
3// 读取 JSON ABI 文件
4const jsonAbi = JSON.parse(fs.readFileSync('./6_abi.json', 'utf8'));
5
6// 将 JSON ABI 转换为人类可读格式
7function convertToHumanReadable(abi) {
8    const humanReadableAbi = [];
9    
10    abi.forEach(item => {
11        if (item.type === 'constructor') {
12            // 处理构造函数
13            const inputs = item.inputs.map(input => `${input.type} ${input.name}`).join(', ');
14            humanReadableAbi.push(`constructor(${inputs})`);
15        } else if (item.type === 'function') {
16            // 处理函数
17            const inputs = item.inputs.map(input => `${input.type} ${input.name}`).join(', ');
18            const outputs = item.outputs.map(output => output.type).join(', ');
19            
20            let functionStr = `function ${item.name}(${inputs})`;
21            
22            if (item.stateMutability === 'view' || item.stateMutability === 'pure') {
23                functionStr += ` view`;
24            }
25            if (item.stateMutability === 'payable') {
26                functionStr += ` payable`;
27            }
28            if (outputs) {
29                functionStr += ` returns (${outputs})`;
30            }
31            
32            humanReadableAbi.push(functionStr);
33        } else if (item.type === 'event') {
34            // 处理事件
35            const inputs = item.inputs.map(input => {
36                let eventInput = input.type;
37                if (input.indexed) {
38                    eventInput += ' indexed';
39                }
40                if (input.name) {
41                    eventInput += ` ${input.name}`;
42                }
43                return eventInput;
44            }).join(', ');
45            
46            humanReadableAbi.push(`event ${item.name}(${inputs})`);
47        }
48    });
49    
50    return humanReadableAbi;
51}
52
53// 转换 ABI
54const humanReadableAbi = convertToHumanReadable(jsonAbi);
55
56// 输出结果
57console.log("// 人类可读的 ABI 格式:");
58console.log("const abiERC20 = [");
59humanReadableAbi.forEach((item, index) => {
60    const isLast = index === humanReadableAbi.length - 1;
61    console.log(`    "${item}"${isLast ? '' : ','}`);
62});
63console.log("];");
64
65// 保存到文件
66const outputContent = `// 人类可读的 ABI 格式:
67const abiERC20 = [
68${humanReadableAbi.map(item => `    "${item}"`).join(',\n')}
69];`;
70
71fs.writeFileSync('./human_readable_abi.js', outputContent);
72console.log("\n已保存到 human_readable_abi.js 文件");

输出了结果:

1// 人类可读的 ABI 格式:
2const abiERC20 = [
3    "constructor(string name_, string symbol_)",
4    "event Approval(address indexed owner, address indexed spender, uint256 value)",
5    "event Transfer(address indexed from, address indexed to, uint256 value)",
6    "function allowance(address , address ) view returns (uint256)",
7    "function approve(address spender, uint256 amount) returns (bool)",
8    "function balanceOf(address ) view returns (uint256)",
9    "function burn(uint256 amount)",
10    "function decimals() view returns (uint8)",
11    "function mint(uint256 amount)",
12    "function name() view returns (string)",
13    "function symbol() view returns (string)",
14    "function totalSupply() view returns (uint256)",
15    "function transfer(address recipient, uint256 amount) returns (bool)",
16    "function transferFrom(address sender, address recipient, uint256 amount) returns (bool)"
17];

第六七八课 事件 Event

  1. 在该交易中, 可以看出
11. 获取过去100个区块内的Transfer事件,并打印出1个
2当前区块高度: 8704861
3找到 1 个Transfer事件
4打印事件详情:
5EventLog {
6  provider: JsonRpcProvider {},
7  transactionHash: '0x885321d88d4ae1e09336c1291d468fb23261576c9eafb444695a0d3e1a30e1d4',
8  blockHash: '0x9b03b72d7658c3a5721e168e880cad369673d73b25c6a856000e380869c2b50d',
9  blockNumber: 8704833,
10  removed: false,
11  address: '0xF6b88086F76eC3E772CBE9cF32Ca591E265Cd232',
12  data: '0x00000000000000000000000000000000000000000000003c3a38e5ab72fc0000',
13  topics: [
14    '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
15    '0x000000000000000000000000bd61c00e4b7c2a77cd660304ddd852379f705ed6',
16    '0x0000000000000000000000005a7157d6fd2ad4a9edc4686758be77ae480bfe6a'
17  ],
18  index: 163,
19  transactionIndex: 114,
20  interface: Interface {
21    fragments: [ [EventFragment] ],
22    deploy: ConstructorFragment {
23      type: 'constructor',
24      inputs: [],
25      payable: false,
26      gas: null
27    },
28    fallback: null,
29    receive: false
30  },
31  fragment: EventFragment {
32    type: 'event',
33    inputs: [ [ParamType], [ParamType], [ParamType] ],
34    name: 'Transfer',
35    anonymous: false
36  },
37  args: Result(3) [
38    '0xbd61c00E4B7C2a77Cd660304ddd852379F705ED6',
39    '0x5A7157d6Fd2aD4A9Edc4686758bE77aE480bfe6A',
40    1111000000000000000000n
41  ]
42}
43
442. 解析事件:
45地址 0xbd61c00E4B7C2a77Cd660304ddd852379F705ED6 转账1111.0 WETH 到地址 0x5A7157d6Fd2aD4A9Edc4686758bE77aE480bfe6A

Topics包含3个数据,分别对应事件哈希,发出地址from,和接收地址to;而Data中包含一个数据,对应转账数额amount

  1. 合约交易监听. 在 contractUSDT.once('Transfer', (from, to, value, event)contractUSDT.on('Transfer', (from, to, value, event) 两种事件监听事件中, 回调函数的第四个参数是交易的事件, 上述 EventLog, 属于他其中的一个对象, 整个 第四参数的 key值有:
1console.log("Event 所有对象属性:", Object.keys(event)) // [ 'filter', 'emitter', 'log', 'args', 'fragment' ]

具体值如下: 其中 EventLog 即上一点列举到的一样的东西, 这里不列出:

1ContractEventPayload {
2  filter: 'Transfer',
3  emitter: Contract {
4    target: '0xdac17f958d2ee523a2206206994597c13d831ec7',
5    interface: Interface {
6      fragments: [Array],
7      deploy: [ConstructorFragment],
8      fallback: null,
9      receive: false
10    },
11    runner: JsonRpcProvider {},
12    filters: {},
13    fallback: null,
14    [Symbol(_ethersInternal_contract)]: {}
15  },
16  log: EventLog {
17    ....
18  },
19  args: Result(3) [
20    '0x313603FA690301b0CaeEf8069c065862f9162162',
21    '0x4eE9775Db5c1e73EA170678C109Ef079b2Ad4811',
22    5581839755n
23  ],
24  fragment: EventFragment {
25    type: 'event',
26    inputs: [ [ParamType], [ParamType], [ParamType] ],
27    name: 'Transfer',
28    anonymous: false
29  }
30}
  1. Bloom Filter , 简单来说就是第一个参数 from 的地址, 第二参数 to 个是 的地址, 他们除了可以是字符串, 还可以是数组:
1const filterBinanceIn = contractUSDT.filters.Transfer(from, to);

举几个例子说明:

4.1 从 accountBinanceAddress 转出的所有交易:

1const filterBinanceIn = contractUSDT.filters.Transfer(accountBinanceAddress);
2
3// 等价于: 其中null 表示任何地址, 可以省略
4const filterBinanceIn = contractUSDT.filters.Transfer(accountBinanceAddress, null);

4.2 监听所有进入 accountBinanceAddress 的交易:

1// null 相当于
2const filterBinanceIn = contractUSDT.filters.Transfer(null, accountBinance);

4.3 监听多个确定地址间的转账:

1// 最复杂的形式: 监听从 A 或 B 转出,转入 C 或 D 的交易:
2const filter = contractUSDT.filters.Transfer([addressA, addressB], [addressC, addressD]);
3
4// 监听特定地址转出到任意地址
5// 监听从 A 或 B 转出的所有交易
6const filter = contractUSDT.filters.Transfer([addressA, addressB], null);
7
8// 监听任意地址转入特定地址
9// 监听转入 A 或 B 的所有交易
10const filter = contractUSDT.filters.Transfer(null, [addressA, addressB]);

第十一课 staticCall

  1. 实际上 contractXXX.transfer.staticCall() API 只是转账前一个测试, 因为实际交易会扣除 Gas 费用 (无论成功失败), 而 staticCall 就完全不用担心这方面的问题, 因为他不需要花费Gas

  2. 为避免粗暴的打印出所有报错信息, 只提取我们需要的信息, 通过AI 的帮助, 我将示例代码优化了一下, 利用 Promise 的特性捕获精确的错误信息, 作用和 try-catch 一样, 避免直接打印出一大推错误信息, 而且前面的程序错了, 也会中断程序运行.

1// 辅助函数:使用 Promise.resolve().then() 处理捕获的错误信息, 替代 try-catch
2const tryStaticCall = async (description, callFn) => {
3    console.log(description)
4    return Promise.resolve()
5        .then(async () => {
6            const result = await callFn()
7            console.log(`交易会成功吗?:`, result)
8            return result
9        })
10        .catch(error => {
11            console.log(`交易失败:`, error.reason || error.message)
12            return null
13        })
14}
15
16// ... 其他代码
17
18const main = () => {
19         // ... 其他代码
20
21
22        // 2. 用staticCall尝试调用transfer转账1 DAI,msg.sender为Vitalik,交易将成功
23        await tryStaticCall(
24            "\n2. 用staticCall尝试调用transfer转账1 DAI,msg.sender为Vitalik地址",
25            async () => {
26                const vitalikAddress = await provider.resolveName("vitalik.eth")
27                console.log(`Vitalik的地址: ${vitalikAddress}`)
28                
29                return await contractDAI.transfer.staticCall(
30                    address, // 转账给测试钱包
31                    ethers.parseEther("1"), 
32                    {from: vitalikAddress} // Vitalik作为发送方
33                )
34            }
35        )
36
37        // ..... 其他代码
38
39}
  1. 函数选择器
  • 函数选择器是函数签名(如 transfer(address,uint256))的 Keccak256 哈希的前4个字节(8个16进制字符)。

  • 它用来告诉合约“我要调用哪个函数”。

  • 计算方式, 代码示例(JS):

1Apply to 11_static_ca...
2const ethers = require("ethers"); // 取签名字符串:transfer(address,uint256)
3const selector = ethers.id("transfer(address,uint256)").slice(0,10); // 取前4字节(8位16进制)
4console.log(selector); // 用 keccak256 哈希, 得出: 0xa9059cbb 
  • 用solidity 简化为:
1bytes4 constant SELECTOR = bytes4(keccak256("transfer(address,uint256)"));

评论区