Appearance
L1-15: Build Your Own ETH CLI(构建自己的 ETH CLI)
1. 问题
从零构建一个以太坊命令行工具(CLI),通过 JSON-RPC 协议直接与以太坊节点交互。核心功能:查询余额、获取区块信息、查询交易详情、获取当前 Gas 价格。不使用 ethers.js 或 viem 等高级库,而是直接发送 JSON-RPC 请求。
2. 原因
cast(Foundry)、ethers.js、viem 等工具封装了大量底层细节。通过从零构建 CLI,开发者能深入理解:
- JSON-RPC 协议:所有以太坊节点都通过
eth_*方法暴露数据——理解这个协议是调试 dApp 和链上问题的必备知识 - 十六进制编码:以太坊返回值使用 hex 编码(如
0xde0b6b3a7640000= 1 ETH),理解 hex <-> decimal 转换是基本技能 - 私钥到地址的推导:ECDSA (secp256k1) 公钥推导 -> keccak256 哈希 -> 取最后 20 字节 = 地址
- 交易构建:理解 nonce、gasPrice、gasLimit、value、data 等字段的含义
这是一个个人挑战(Personal Challenge),目标不是写生产级工具,而是通过亲手实现来理解底层原理。
3. 方案
架构设计
eth-cli.js
|
├── rpcCall(method, params) → POST JSON-RPC 请求
├── cmdBalance(address) → eth_getBalance
├── cmdBlock(tag) → eth_getBlockByNumber
├── cmdTx(hash) → eth_getTransactionByHash
├── cmdGas() → eth_gasPrice
└── privateKeyToAddress(key) → 私钥→地址推导
核心实现
JSON-RPC 调用封装:
javascript
const RPC_URL = process.env.ETH_RPC_URL || "http://localhost:8545";
let requestId = 1;
async function rpcCall(method, params = []) {
const response = await axios.post(RPC_URL, {
jsonrpc: "2.0",
method: method,
params: params,
id: requestId++,
});
if (response.data.error) throw new Error(response.data.error.message);
return response.data.result;
}
余额查询:
javascript
async function cmdBalance(address) {
const balanceWei = await rpcCall("eth_getBalance", [address, "latest"]);
const weiDecimal = hexToDecimal(balanceWei);
console.log(`Wei: ${weiDecimal}`);
console.log(`Ether: ${weiToEther(balanceWei)} ETH`);
}
Gas 价格查询:
javascript
async function cmdGas() {
const gasPrice = await rpcCall("eth_gasPrice", []);
console.log(`Gas Price: ${hexToDecimal(gasPrice)} wei`);
console.log(` ${Number(BigInt(gasPrice)) / 1e9} gwei`);
}
支持的命令
| 命令 | 说明 | JSON-RPC 方法 |
|---|---|---|
balance <address> | 查询 ETH 余额 | eth_getBalance |
block [tag|number] | 查询区块信息 | eth_getBlockByNumber |
tx <hash> | 查询交易详情 | eth_getTransactionByHash |
gas | 查询 Gas 价格 | eth_gasPrice |
addr <privateKey> | 推导地址(教育用) | 本地计算 |
4. 遭遇的陷阱
4.1 hex 编码的数值转换
JSON-RPC 返回的数值(余额、Gas、区块号)都是 hex 编码字符串(如 0xde0b6b3a7640000)。直接 parseInt(hex) 会溢出——以太坊的 wei 值经常超过 JavaScript 的 Number.MAX_SAFE_INTEGER。必须使用 BigInt。
4.2 区块参数的灵活性
eth_getBlockByNumber 接受 "latest"、"earliest"、"pending"(字符串标签)和 hex 编码的区块号(如 0xbc614e)。如果传入十进制数字需要先转换为 hex:"0x" + BigInt(tag).toString(16)。
4.3 私钥到地址的简化实现
本 CLI 使用 SHA-256(而非 keccak256)简化私钥到地址的推导,因为 Node.js 原生 crypto 模块不支持 keccak256。真实以太坊地址通过 keccak256(publicKey) 取最后 20 字节得出。生产环境应使用 ethers.js 或 viem。
4.4 无交易发送功能
为了安全考虑,这个 CLI 不包含发送交易的功能。发送交易需要:估算 Gas、签名交易(RLP 编码)、发送原始交易。这些操作涉及私钥管理,不适合在 CLI 中演示。
5. 陷阱的原因
5.1
JavaScript 的 Number 类型是 IEEE 754 双精度浮点数,最大安全整数为 2^53 - 1。1 ETH = 10^18 wei,所以即使是小额 ETH 余额也远超此限制。BigInt(ES2020+)是唯一正确的处理方式。
5.2
JSON-RPC 规范要求区块号参数使用 hex 编码。如果直接传字符串 "12345678",节点会将其解释为区块标签(不存在则会报错或返回 null)。
5.3
Node.js 原生 crypto 模块支持 SHA-256 但不支持 keccak256(以太坊使用的哈希算法)。正确的 keccak256 实现需要使用 ethers.keccak256() 或 @noble/hashes 库。
6. 如何解决陷阱
javascript
// 正确:使用 BigInt 处理 hex 数值
function hexToDecimal(hex) {
return BigInt(hex).toString();
}
// 正确:转换区块参数
function normalizeBlockTag(tag) {
if (/^\d+$/.test(tag)) {
return "0x" + BigInt(tag).toString(16);
}
return tag; // "latest", "earliest", "pending"
}
// 注意:此函数使用 SHA-256 仅为演示
// 真实地址需要用 keccak256
function privateKeyToAddress(privateKeyHex) {
// 生产环境请使用 ethers.js 或 viem
}
7. 技术要点
| 要点 | 说明 |
|---|---|
| JSON-RPC 协议 | {jsonrpc:"2.0", method, params, id} POST 请求 |
| BigInt 处理 | 所有以太坊数值(wei、gas)必须用 BigInt |
| hex 编码 | 0x 前缀 + 十六进制,BigInt(hex).toString() 转为十进制 |
| wei/ETH 转换 | 1 ETH = 10^18 wei,除以 1e18 得到 Ether 值 |
| EIP-1559 | 新交易使用 maxFeePerGas + maxPriorityFeePerGas 替代 gasPrice |
| RPC 端点 | 需要 WebSocket 用于订阅,HTTP 用于请求/响应 |