Appearance
L1-15: Build Your Own ETH CLI
1. Problem
Build an Ethereum command-line tool (CLI) from scratch that interacts directly with an Ethereum node via the JSON-RPC protocol. Core features: query balance, retrieve block information, look up transaction details, and get the current gas price. No high-level libraries like ethers.js or viem -- send raw JSON-RPC requests directly.
2. Why
Tools like cast (Foundry), ethers.js, and viem encapsulate a large amount of low-level detail. By building a CLI from scratch, developers gain deep understanding of:
- JSON-RPC protocol: all Ethereum nodes expose data through
eth_*methods -- understanding this protocol is essential knowledge for debugging dApps and on-chain issues - Hexadecimal encoding: Ethereum return values use hex encoding (e.g.,
0xde0b6b3a7640000= 1 ETH); understanding hex <-> decimal conversion is a fundamental skill - Private key to address derivation: ECDSA (secp256k1) public key derivation -> keccak256 hash -> take the last 20 bytes = address
- Transaction construction: understanding the meaning of fields like nonce, gasPrice, gasLimit, value, and data
This is a Personal Challenge -- the goal is not to build a production-grade tool, but to understand the underlying principles through hands-on implementation.
3. Solution
Architecture Design
eth-cli.js
|
├── rpcCall(method, params) → POST JSON-RPC request
├── cmdBalance(address) → eth_getBalance
├── cmdBlock(tag) → eth_getBlockByNumber
├── cmdTx(hash) → eth_getTransactionByHash
├── cmdGas() → eth_gasPrice
└── privateKeyToAddress(key) → private key → address derivation
Core Implementation
JSON-RPC call wrapper:
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;
}
Balance query:
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 price query:
javascript
async function cmdGas() {
const gasPrice = await rpcCall("eth_gasPrice", []);
console.log(`Gas Price: ${hexToDecimal(gasPrice)} wei`);
console.log(` ${Number(BigInt(gasPrice)) / 1e9} gwei`);
}
Supported Commands
| Command | Description | JSON-RPC Method |
|---|---|---|
balance <address> | Query ETH balance | eth_getBalance |
block [tag|number] | Query block info | eth_getBlockByNumber |
tx <hash> | Query transaction details | eth_getTransactionByHash |
gas | Query gas price | eth_gasPrice |
addr <privateKey> | Derive address (educational) | Local computation |
4. Pitfalls Encountered
4.1 Numeric Conversion of Hex-Encoded Values
JSON-RPC returns numeric values (balance, gas, block number) as hex-encoded strings (e.g., 0xde0b6b3a7640000). A direct parseInt(hex) will overflow -- Ethereum wei values frequently exceed JavaScript's Number.MAX_SAFE_INTEGER. You must use BigInt.
4.2 Flexibility of Block Parameters
eth_getBlockByNumber accepts "latest", "earliest", "pending" (string tags) and hex-encoded block numbers (e.g., 0xbc614e). If passing a decimal number, it must first be converted to hex: "0x" + BigInt(tag).toString(16).
4.3 Simplified Private Key to Address Implementation
This CLI uses SHA-256 (rather than keccak256) to simplify private key to address derivation, because Node.js's native crypto module does not support keccak256. Real Ethereum addresses are derived by taking the last 20 bytes of keccak256(publicKey). Production environments should use ethers.js or viem.
4.4 No Transaction Sending
For safety reasons, this CLI does not include transaction sending functionality. Sending transactions requires: estimating gas, signing the transaction (RLP encoding), and sending the raw transaction. These operations involve private key management and are not suitable for demonstration in a CLI.
5. Why the Pitfalls Happen
5.1
JavaScript's Number type is an IEEE 754 double-precision floating-point number with a maximum safe integer of 2^53 - 1. 1 ETH = 10^18 wei, so even small ETH balances far exceed this limit. BigInt (ES2020+) is the only correct way to handle this.
5.2
The JSON-RPC specification requires block number parameters to use hex encoding. If you pass a raw string like "12345678", the node will interpret it as a block tag (which does not exist, causing an error or returning null).
5.3
Node.js's native crypto module supports SHA-256 but not keccak256 (the hash algorithm used by Ethereum). A correct keccak256 implementation requires ethers.keccak256() or the @noble/hashes library.
6. How to Resolve the Pitfalls
javascript
// Correct: use BigInt to handle hex values
function hexToDecimal(hex) {
return BigInt(hex).toString();
}
// Correct: normalize block parameters
function normalizeBlockTag(tag) {
if (/^\d+$/.test(tag)) {
return "0x" + BigInt(tag).toString(16);
}
return tag; // "latest", "earliest", "pending"
}
// Note: this function uses SHA-256 for demonstration only
// Real addresses require keccak256
function privateKeyToAddress(privateKeyHex) {
// For production, use ethers.js or viem
}
7. Technical Highlights
| Point | Description |
|---|---|
| JSON-RPC protocol | {jsonrpc:"2.0", method, params, id} POST request |
| BigInt handling | All Ethereum numeric values (wei, gas) must use BigInt |
| Hex encoding | 0x prefix + hexadecimal; BigInt(hex).toString() converts to decimal |
| wei/ETH conversion | 1 ETH = 10^18 wei; divide by 1e18 to get Ether value |
| EIP-1559 | New transactions use maxFeePerGas + maxPriorityFeePerGas instead of gasPrice |
| RPC endpoints | WebSocket needed for subscriptions, HTTP for request/response |