Skip to content
On this page

L1-15: Build Your Own ETH CLI(构建自己的 ETH CLI)

1. 问题

从零构建一个以太坊命令行工具(CLI),通过 JSON-RPC 协议直接与以太坊节点交互。核心功能:查询余额、获取区块信息、查询交易详情、获取当前 Gas 价格。不使用 ethers.js 或 viem 等高级库,而是直接发送 JSON-RPC 请求。

2. 原因

cast(Foundry)、ethers.jsviem 等工具封装了大量底层细节。通过从零构建 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 用于请求/响应

Built with AiAda