Skip to content
On this page

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

CommandDescriptionJSON-RPC Method
balance <address>Query ETH balanceeth_getBalance
block [tag|number]Query block infoeth_getBlockByNumber
tx <hash>Query transaction detailseth_getTransactionByHash
gasQuery gas priceeth_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

PointDescription
JSON-RPC protocol{jsonrpc:"2.0", method, params, id} POST request
BigInt handlingAll Ethereum numeric values (wei, gas) must use BigInt
Hex encoding0x prefix + hexadecimal; BigInt(hex).toString() converts to decimal
wei/ETH conversion1 ETH = 10^18 wei; divide by 1e18 to get Ether value
EIP-1559New transactions use maxFeePerGas + maxPriorityFeePerGas instead of gasPrice
RPC endpointsWebSocket needed for subscriptions, HTTP for request/response

Built with AiAda