ethers.js 自学笔记-1
本文根据 WFT 学院的 ehter.js
课程自学及b 站上 五里墩茶社ehtersjs 播放列表, 讲的不够清楚的地方, 进行了记录. 关于基本知识, WTF 学院的文档上已有, 我这里不再一一列举. 主要是补充一下操作中要注意的点.
课前准备
最好就全都给注册一遍
网站
-
查询合约, 钱包, 及交易等信息: ether Scan
-
合约开发
API_KEY
必备地址: Alchemy, Infura, 这两个可以二选一, 但我有时候会碰到 Infura网络问题, 所以两个都注册了. -
小狐狸🦊钱包, chrome浏览器插件, 请自行搜索并注册
-
由于课程代码不能暴露敏感的数据, 即使测试账号也不行, 所以我在我自己的项目里安装了
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);
操作及语法的注意点
第一课
- 代码里的
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
-
文档里的Infura 和 Alchemy 的
jsonRpcProvider
获取方式截图, 已经比较久远, 这里放上2025年7月份的最新获取截图, 注册完成后系统会提示你生成第一个 API key, 按照以下点击: 二选一
第三课读取合约 Contract
-
代码里的
const abiWETH = '[{"constant":true,"inputs":[],"name":"name","outputs":[{"....<太长省略>'
是一串将合约转换为json
对象, 在 Remix IDE 编写合约后, 点击 Compile 后, 再Artifact
(产物文件夹)可以找到: 说白了合约编译后就是一堆类似 js AST 的 json 对象 -
人类可读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
- 文档里提到的三种钱包的创建方法, 实际上只有一种. 第三种创建方式
const wallet3 = ethers.Wallet.fromPhrase(mnemonic.phrase)
由于利用了第一种的 providerconst 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()
- 为了验证, 我添加 wallet1 的助记词到我的钱包, 看看是否转账成功:
下面这个尾号...05EDb 的账号减少了 0.0001 eth
下面这个尾号 ...b8F3e 的账号增加了 0.0001 eth
第六课 部署
- 文档里介绍的在
Remix
里编译ERC20
源码, 并点击编译栏下的bytecode
按钮复制 bytecode 字段,
现在复制出来的已经是一个单纯的字节码字符串, 比如 60806040526012......634300081e0033
(太长省略了...)不用再到 json
文件里去找bytecode 字段了.
- 上面图片中, 我圈出的
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
- 在该交易中, 可以看出
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
- 合约交易监听. 在
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}
- 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
-
实际上
contractXXX.transfer.staticCall()
API 只是转账前一个测试, 因为实际交易会扣除 Gas 费用 (无论成功失败), 而staticCall
就完全不用担心这方面的问题, 因为他不需要花费Gas -
为避免粗暴的打印出所有报错信息, 只提取我们需要的信息, 通过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}
- 函数选择器
-
函数选择器是函数签名(如 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)"));