ethers.js - everyone's doing it

Kevin Halliday

May 4, 2022

January 3, 2023

ethers.js - everyone's doing it

And I'm sure everyone has their reasons. They just never told me.  

When building frontends to Ethereum, you might find yourself using ethers.js without really appreciating why you need it. So, in the name of appreciation, let's not need it.

I chose ethers.js, but web3.js offers a similar tool set. See this comparison if you need help deciding between the two.

Talking To Nodes

One of ethers main gigs is to play translator for our conversations with nodes. A node is a running instance of some blockchain client. A client is an implementation of the protocol that runs the chain. All nodes on the chain should store all the data we're interested in, and the rules by which it may be updated. That is, apparently, the whole point.

To interface with decentralized protocols, we have to interface with some node. And to interface with some node, we have to speak ETH JSON-RPC.

JSON-RPC is transport agnostic, but all the cool popular nodes support HTTP. You may, though probably won't, have reliable access to some node at some reliable URL. For those of us that don't, there are node service providers.

Whose node?

Node service providers, like Infura or Alchemy, host URLs we can all use to talk to nodes. Here are the Infura URLs for mainnet and Rinkeby (key gated, but the keys are free):

You can send these URLs HTTP POST requests with valid ETH JSON-RPC request bodies, trust they forward those requests to an Ethereum node, and trust they forward an unmodified response back to you. Decentralization!

And say what?

Any valid ETH JSON-RPC request. For example, to ask for our balance:

We specify the method name, eth_getBalance, along with two parameters:

The response will look something like this:

	"jsonrpc": "2.0",
	"id": 1,
	"result": "0x0de0b6b3a7640000" // 1 ETH 

And like a good little engineer, we’ll write a getBalance function for our ethers-allergic app:

Note the result 0x0de0b6b3a7640000 doesn't exactly look like a number. It is, like most inputs/outputs in the ETH JSON-RPC, a hex-encoded unsigned integer. To get at the real balance, we can:

const balance = parseInt(  
	16, // base 16 for hexidecimal, though parseInt will handle hex on its own

balance // 1000000000000000000, or 10^18

Note that our balance looks slightly larger than 1. The integer representation of our balance is indeed 10^18, just not 10^18 Ether.

Math on-chain is still just math on some computer, so we still need to avoid the imprecision inherent to floating point arithmetic, especially when building financial tools. To address this, Ethereum and other EVM chains represent balances in "wei", where:

10^18 wei == 1 ether

This allows math to stay in the realm of wei integers while representing precise decimal amounts of ether.

Okay, little annoying. But not too bad. We can just:

const hexToInt = (hex: string) => parseInt(hex, 16)
const weiToEth = (wei: number) => wei / Math.pow(10, 18)
const balance = await getBalance(address)
const inWei = hexToInt(balance)
const inEth = weiToEth(inWei)

Great. But didn't we just say working with floats (all numbers in javascript are encoded as IEEE 754 double-precision floating point numbers) for balances is dangerous? And won't our balance in wei be greater than Number.MAX_SAFE_INTEGER? Shut up, nerd.

Talking to Decentralized Protocols

Okay, so we can ask for our balance. Fun, but not that fun. To build the UIs of the disintermediated global economy, we need more. We need to interface with decentralized protocols, or smart contracts.

ETH JSON-RPC is stateless and knows nothing of the APIs defined by the smart contracts for which it plays courier. Like how HTTP knows nothing of ETH JSON-RPC. To interact with smart contracts, we specify the actions to take as data within an ETH JSON-RPC request.

Take this AddX contract - shown implemented in solidity. Deployed on Rinkeby at 0x1b1b40ac6d0F20AF5CcCDD6B7636DdF31CF50605, initialized with x = 2.

// SPDX-License-Identifier: Unlicensed
pragma solidity ^0.8.0;

contract AddX {
	uint256 private x;    
	constructor(uint256 _x) {        
		x = _x;
	function addX(uint256 y) public view returns (uint256) {        
		return x + y;    
  function setX(uint256 _x) public {        
		x = _x;    

Let's try the addX method. It's a view function, which means it doesn't update any contract state. Which means we can call it without signing a transaction. Which is great, because we don't know how to do that. We'll use eth_call.

eth_call - Executes a new message call immediately without creating a transaction on the block chain.

And specify a single parameter, with the following fields

Our request will look like this:

The result being:

	"jsonrpc": "2.0",
	"id": 1,
	"result": "0x0000000000000000000000000000000000000000000000000000000000000004"

It worked!

2 + 2 = 0x0000000000000000000000000000000000000000000000000000000000000004

An input of "0x36d3dc4b0000...0002" means call addX with the first (and only) parameter set to 2. If that isn't immediately obvious, maybe web3 just isn't for you.

So how do we get this input? Here's what I reverse engineered then later confirmed with the docs

// implement keccak256 (sha3)
const keccak256 = (input: string) => {  
	// so easy I leave this as an excercise for the reader

// get keccak256 hash of the function signature
const sigHash = keccak256('addX(uint256)') // 0x36d3dc4be.....99d5c143ea94

// take the first 4 bytes == 8 characters, not including the "0x"
const firstFourBytes = sigHash.slice(0, 10) // 0x36d3dc4b

// Each hex character is 4 bits, so 2 characters is byte. Note this
// calculation is agnostic towards how your js engine is _actually_ 
// storing the string representation of the hexadecimal.

// append the hex encoded integer param, padded to 32 bytes, or 64 characters
const intToHex = (int: number) => int.toString(16)
const param1 = intToHex(2).padStart(64, 0)
const input = firstFourBytes + param1 

// 0x36d3dc4b0000000000000000000000000000000000000000000000000000000000000002

So, putting it all together, we can make this great app that gets our balance and adds it to x - riveting!

So after all that tedious, pretty use-case-specific javascripting, we still didn't get it right. We could fix our "app" by avoiding native js numbers altogether. And, instead, use our favorite arbitrary precision arithmetic library, like bignumber.js or decimal.js. But we'd still have to manage translation to and from ETH JSON-RPC - which was the real pain. And we haven't even tried:

Maybe we need ethers

Here's the same app, but with ether's help:

Yes, importing the contract factory does make this seem shorter than it really is. But that’s autogenerated, and therefore not a direct burden. And less code is the least of the benefits gained.

Just in this snippet, Ethers gives us:

And, not shown:

So, in conclusion, thank you ricmoo for making my life much easier. I do need ethers.