ethers.js - Everyone's Doing It

ethers.js - Everyone's Doing It


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

If you're a recent web2 (ew!) turned web3 frontend engineer - like myself - 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.

  • on-chain data + on-chain API = decentralized protocol = sweet sweet programmable disintermediated cooperation

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):

<https://mainnet.infura.io/v3/><YOUR_API_KEY>   - ethereum mainnet
<https://rinkeby.infura.io/v3/><YOUR_API_KEY>   - rinkeby testnet

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:

POST <https://rinkeby.infura.io/v3/><YOUR_API_KEY>
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "eth_getBalance",
  "params": ["<YOUR_ADDRESS>", "latest"],
}

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

  • address: a hex-encoded address
  • block: a hex-encoded block number, or block tag (earliest, latest, or pending)

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:

const rinkebyUrl = '<https://rinkeby.infura.io/v3/><YOUR_API_KEY>'
const account = '<YOUR_ADDRESS>'

const getBalance = async (address: string, block = 'latest') =>
  fetch(rinkebyUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify({
      jsonrpc: '2.0',
      id: 1,
      method: 'eth_getBalance',
      params: [address, block],
    }),
  })
    .then((response) => response.json())
    .then((json) => json.result)
    
const balance = await getBalance(account) // 0x0de0b6b3a7640000 (1 ETH)

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(
  '0x0de0b6b3a7640000',
  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

  • to: the address of the contract
  • input: "input data", or "call data", whatever that means

Our request will look like this:

POST <https://rinkeby.infura.io/v3/><YOUR_API_KEY>
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "eth_call",
  "params": [
    {
      to: "0x1B1B40ac6d0F20AF5CcCDD6B7636DdF31CF50605",
      input: "0x36d3dc4b0000000000000000000000000000000000000000000000000000000000000002"
    },
  ],
}

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
console.log(input) 

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

const rinkebyUrl = '<https://rinkeby.infura.io/v3/><YOUR_API_KEY>'
const account = '<YOUR_ADDRESS>'
const addXAddress = '0x1B1B40ac6d0F20AF5CcCDD6B7636DdF31CF50605'

const keccak256 = (input: string) => {
  // from your homework
}

const hexToInt = (hex: string) => parseInt(hex, 16)
const intToHex = (int: number) => int.toString(16)
const weiToEth = (wei: number) => wei / Math.pow(10, 18)

const uint = (n: number) => {
  if (n < 0) throw new Error('I said unsigned!')
  return Math.round(n)
}

const encodeMethod = (method: string) => keccak256(method).slice(0, 10)

const encodeUIntParam = (n: number) => {
  const hex = intToHex(uint(n))
  return hex.padStart(64, '0')
}

const getBalance = async (address: string, block = 'latest') =>
  fetch(rinkebyUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify({
      jsonrpc: '2.0',
      id: 1,
      method: 'eth_getBalance',
      params: [address, block],
    }),
  })
    .then((response) => response.json())
    .then((json) => json.result)

const addX = async (y: number) =>
  fetch(rinkebyUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify({
      jsonrpc: '2.0',
      id: 1,
      method: 'eth_call',
      params: [
        {
          to: addXAddress,
          input: encodeMethod('addX(uint256)') + encodeUIntParam(y),
        },
      ],
    }),
  })
    .then((response) => response.json())
    .then((json) => json.result)

const balance = await getBalance(account))
const inWei = hexToInt(balance)
const inEth = weiToEth(inWei)

const weiPlusX = hexToInt(await addX(inWei))
const ethPlusX = hexToInt(await addX(inEth))

weiPlusX // annnd it's wrong! cause 10^18 > Number.MAX_SAFE_INTEGER ¯\_(ツ)_/¯
ethPlusX // 3! at least we got one right

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:

import { ethers } from 'ethers'
import { AddX__factory } from './typechain' // omitting for dramatic effect

const rinkebyUrl = '<https://rinkeby.infura.io/v3/><YOUR_API_KEY>'
const account = '<YOUR_ADDRESS>'
const addXAddress = '0x1B1B40ac6d0F20AF5CcCDD6B7636DdF31CF50605'

const provider = new ethers.providers.JsonRpcProvider(rinkebyUrl)
const adder = AddX__factory.connect(addXAddress, provider)

const balance = await provider.getBalance(account) // as a BigNumber
const plusX = await adder.addX(balance) // also as a BigNumber

plusX.toString() // 1000000000000000002! accurate wei balance plus x

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:

  • well-typed, human-readable Contract objects for interacting with smart contracts
  • a BigNumber type + utilities for safely working with on-chain values

And, not shown:

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


About Rift Finance

Rift is a decentralized protocol that restructures incentives to improve liquidity across DeFi. The Rift Protocol allows DAOs to deploy governance tokens from their treasuries to pair with tokens from liquidity providers. By working together, DAOs receive the liquidity they seek, and LPs receive double returns and reduced risk. DAOs across several leading Layer 1 blockchains, including Ethereum, NEAR, Terra, Fantom, and Injective, utilize Rift to unlock sustainable liquidity.

Website | Twitter | Discord | Telegram | Docs | Blog | Careers