winston

winston

Getting Started with ethers.js

This is a very brief introduction to Ethers, but it covers many operations that developers typically need to perform.

Getting Ethers#

First, install the Ethers package
Using npm:

npm i ethers

Using yarn:

yarn add ethers

Using pnpm:

pnpm i ethers

Everything in Ethers is exported from its root directory and the ethers object. The package.json also contains more granular import configurations.

Typically, this document assumes that code examples have imported all exported objects from ethers, but you can import the required objects in any way you prefer.

Importing in Node.js:

// Import everything
import { ethers } from "ethers";

// Import only specific objects
import { BrowserProvider, parseUnits } from "ethers";

// Import objects in a specific module
import { HDNodeWallet } from "ethers/wallet";

Importing ESM in the browser:

<script type="module">
  import { ethers } from "https://cdnjs.cloudflare.com/ajax/libs/ethers/5.7.2/ethers.min.js";
  // Write your code here...
</script>

Some Common Terms#

To start engaging with this field, it is useful to understand the types of available objects and their responsibilities.

Provider#

A Provider is a read-only connection established with the blockchain, allowing you to query the blockchain state, such as account, block, or transaction details, query event logs, or use call to evaluate read-only code.

If you are familiar with Web3.js, you should be accustomed to the Provider providing read and write access. However, in Ethers, all write operations are further abstracted into another object: Signer.

Signer#

A Signer encapsulates all operations that interact with accounts. Accounts typically have a private key at some location that can be used to sign various types of payloads.

The private key may reside in memory (using Wallet) or be protected through some IPC layer, such as MetaMask, which proxies interactions on the website to the browser plugin, preventing the website from accessing the private key and allowing interactions only when user authorization is requested.

Transaction#

To make any state change on the blockchain, a transaction must be executed, and each transaction execution requires a fee. The "fee" here covers the associated costs of executing the transaction (such as reading from disk and performing mathematical operations) and the cost of storing updated information.

If a transaction rolls back, the fee still needs to be paid because validators still have to spend resources trying to run the transaction to determine whether it has rolled back, and the details of its failure will still be recorded.

Transactions include: sending Ether to other users, deploying contracts, or performing state change operations on contracts.

Contract#

A contract is a program that has been deployed on the blockchain, containing some code that allocates storage units that can be read from and written to.

When connected to a Provider, it can be read from; if connected to a Signer, state change operations can be called.

Receipt#

Once a Transaction is submitted to the blockchain, it will be placed in the memory pool (mempool) until a validator decides to include it.

The modifications of the transaction only take effect once it has been included in the blockchain and a receipt has been generated. At this point, a receipt is provided that includes transaction details, such as the block the receipt belongs to, the actual amount paid, total fuel used, all events triggered by the transaction, and whether it was successful or rolled back.

Connecting to Ethereum#

The first step to interact with Ethereum is to establish a connection with it using a Provider.

MetaMask (and Other Injectors)#

The fastest and easiest way to try and start developing on Ethereum is to use MetaMask, a browser extension that injects objects into the window, providing:

  • Read-only access to the Ethereum network (Provider)
  • Authorization for write access based on private keys (Signer)

When requesting authorization methods, such as sending a transaction or even requesting a private key address, MetaMask will pop up a window prompting the user for authorization.

let signer = null;

let provider;
if (window.ethereum == null) {

    // If MetaMask is not installed, we use the default Provider,
    // which is supported by multiple third-party services (like INFURA) that do not have the private key,
    // so only read-only access is available.
    console.log("MetaMask not installed; using read-only defaults")
    provider = ethers.getDefaultProvider()

} else {

    // Connect to the EIP-1193 object of MetaMask, which is a standard
    // protocol that allows Ethers to make all read-only requests through MetaMask.
    provider = new ethers.BrowserProvider(window.ethereum)

    // This also provides an opportunity to request access for write operations,
    // which will be executed by the private key managed by MetaMask for the user.
    signer = await provider.getSigner();
}

Custom RPC Backend#

If you are running your own Ethereum node (like Geth) or using a custom third-party service (like INFURA), you can communicate directly using JsonRpcProvider, which uses the link-jsonrpc protocol.

When using your own Ethereum node or developing blockchain-based projects (like Hardhat or Ganache), you can obtain access through the JsonRpcProvider.getSigner method.

Connecting to JSON-RPC URL

// If no url is provided, it will connect to most default nodes
// http://localhost:8545.
provider = new ethers.JsonRpcProvider(url)

// Obtain write access as an account by getting the signer
signer = await provider.getSigner()

User Interaction#

In Ethereum, all units tend to be integer values because using decimals and floating-point numbers can lead to imprecise and non-obvious results when performing mathematical operations.
Thus, the units used internally (like wei) are suitable for machine-readable purposes and mathematical calculations, and they are often very large and not very human-readable.
For example, imagine dealing with dollars and cents; you would display values like "$2.56". In the blockchain world, we keep all values in cents, so they are internally represented as 256 cents.
Therefore, when accepting user-typed data, it must be converted from its decimal string representation (like "2.56") to the lowest unit integer representation (like 256). The opposite operation is needed when displaying values to users.
In Ethereum, one Ether equals 10 ** 18 wei, and one gwei equals 10 ** 9 wei, so values can quickly become very large, which is why some convenient functions are provided to help convert between various representations.

// Convert the string provided by the user in ether to a wei value
eth = parseEther("1.0")
// 1000000000000000000n

// Convert the string provided by the user in gwei to the corresponding wei value for the maximum base fee
feePerGas = parseUnits("4.5", "gwei")
// 4500000000n

// Convert the wei value to a string value in ether for display in the UI
formatEther(eth)
// '1.0'

// Convert the wei value to a string value in gwei for display in the UI
formatUnits(feePerGas, "gwei")
// '4.5'

Interacting with the Blockchain#

Querying State#

Once you have a Provider, you can read and query data on the blockchain. This can be used to query the current account status, retrieve historical logs, find contract code, etc.

// Check the current block number
await provider.getBlockNumber()
// 16988474

// Get the current balance corresponding to an address or ENS immutable domain
balance = await provider.getBalance("ethers.eth")
// 182334002436162568n

// Since the balance is in wei, you may want to display it
// in ether form.
formatEther(balance)
// '0.182334002436162568'

// Get the next nonce required for sending transactions
await provider.getTransactionCount("ethers.eth")
// 3

Sending Transactions#

To write content to the blockchain, you need access to the private key controlling a certain account. In most cases, these private keys cannot be accessed directly but are requested through a Signer and then dispatched to a service (like MetaMask), which provides strict access control and requires user approval or denial of the operation.

// When sending a transaction, the value is in wei, so parseEther
// converts ether to wei.
tx = await signer.sendTransaction({
  to: "ethers.eth",
  value: parseEther("1.0")
});

// Typically, you may want to wait for the transaction to be mined
receipt = await tx.wait();

Contract#

A Contract is a metaclass, meaning its definition is derived at runtime based on the ABI (Application Binary Interface) passed to it, determining the available methods and properties.

Application Binary Interface (ABI)

Since all operations occurring on the blockchain must be encoded as binary data, we need a concise way to define how to convert between common objects (like strings and numbers) and their binary representations, as well as how to encode calls and interpret contracts.

For any method, event, or error you want to use, a Fragment must be included to tell Ethers how to encode requests and decode results. Any unnecessary methods or events can be safely excluded.

There are several common formats used to describe ABI. The Solidity compiler typically tends to dump JSON representations, but when manually entering ABI, it is often easier to use (and more readable) human-readable ABI representations, which are just Solidity signatures.

Simplified ERC-20 ABI Example:

abi = [
  "function decimals() returns (string)",
  "function symbol() returns (string)",
  "function balanceOf(address addr) returns (uint)"
]

// Create a contract
contract = new Contract("dai.tokens.ethers.eth", abi, provider)

Read-Only Methods (i.e., "view" and "pure" methods)#

Read-only methods are those that cannot change the blockchain state but typically provide a simple interface to retrieve important data related to the contract.

Here is an example of reading the DAI ERC-20 contract:

// Contract ABI (the fragments we care about)
abi = [
  "function decimals() view returns (uint8)",
  "function symbol() view returns (string)",
  "function balanceOf(address a) view returns (uint)"
]

// Create a contract; connect to Provider, so it can only access read-only methods (like view and pure)
contract = new Contract("dai.tokens.ethers.eth", abi, provider)

// Symbol name
sym = await contract.symbol()
// 'DAI'

// The number of decimal places used by the token
decimals = await contract.decimals()
// 18n

// Read the token balance of an account
balance = await contract.balanceOf("ethers.eth")
// 201469770000000000000000n

// Format the balance for human readability, such as displaying in the UI
formatUnits(balance, decimals)
// '201469.77'

State-Changing Methods#

Modify the state of the ERC-20 contract

abi = [
  "function transfer(address to, uint amount)"
]

// Connect a Signer; can issue transactions that change state,
// which will cost the account some ether
contract = new Contract("dai.tokens.ethers.eth", abi, signer)

// Send 1 DAI
amount = parseUnits("1.0", 18);

// Send the transaction
tx = await contract.transfer("ethers.eth", amount)

// At this point, the transaction has been sent to the mempool,
// but has not yet been included. So we...

// ...wait for the transaction to be included.
await tx.wait()

Force call (simulate) a state-changing method

abi = [
  "function transfer(address to, uint amount) returns (bool)"
]

// Only connect to Provider, so we only need read access
contract = new Contract("dai.tokens.ethers.eth", abi, provider)

amount = parseUnits("1.0", 18)

// Using static calls has many limitations, but can generally be used to pre-check transactions.
await contract.transfer.staticCall("ethers.eth", amount)
// true

// We can also simulate a transaction initiated by another account
other = new VoidSigner("0x643aA0A61eADCC9Cc202D1915D942d35D005400C")
contractAsOther = contract.connect(other.connect(provider))
await contractAsOther.transfer.staticCall("ethers.eth", amount)
// true

Listening to Events#

When adding a listener for named events, the event parameters are destructured. There is always an additional parameter passed to the listener, which is EventPayload, containing more information about the event, including filters and methods to remove that listener.

Listening to ERC-20 events:

abi = [
  "event Transfer(address indexed from, address indexed to, uint amount)"
]

// Create a contract; connect to Provider, so it can only access read-only methods (like view and pure)
contract = new Contract("dai.tokens.ethers.eth", abi, provider)

// Start listening for any transfer events
contract.on("Transfer", (from, to, _amount, event) => {
  const amount = formatEther(_amount, 18)
  console.log(`${ from } => ${ to }: ${ amount }`);

  // The `event.log` has the entire EventLog

  // Optionally, convenience method to stop listening
  event.removeListener();
});

// Same as above
contract.on(contract.filters.Transfer, (from, to, amount, event) => {
  // See above
})

// Listen for any transfer events to "ethers.eth"
filter = contract.filters.Transfer("ethers.eth")
contract.on(filter, (from, to, amount, event) => {
  // `to` will always be equal to the address of "ethers.eth"
});

// Listen for any events, regardless of whether they are in the ABI, as unknown events may be captured, so parameters will not be destructured.
contract.on("*", (event) => {
  // The `event.log` has the entire EventLog
});

Querying Historical Events#

When querying over a wide range of blocks, some backends may be too slow, potentially returning errors or not providing results depending on the server policies of that backend.

Querying historical events for ERC-20:

abi = [
  "event Transfer(address indexed from, address indexed to, uint amount)"
]

// Create a contract; connect to Provider, so it can only access read-only methods (like view and pure)
contract = new Contract("dai.tokens.ethers.eth", abi, provider)

// Query if there are any transfers in the last 100 blocks
filter = contract.filters.Transfer
events = await contract.queryFilter(filter, -100)

// Get the size of the event array
events.length
// 90

// The first matching event
events[0]
// EventLog { ... }

// Query any transfer events to "ethers.eth" over all time periods
filter = contract.filters.Transfer("ethers.eth")
events = await contract.queryFilter(filter)

// The first matching event
events[0]
// EventLog { ... }

Signing Messages#

Private keys can do many things, not only sign transactions to authorize them but also be used to sign other forms of data, and can verify that data and serve other purposes.

For example, signing a message can be used to prove ownership of an account, allowing a website to use it to verify users and log them in.

// Our signer; signing messages does not require a Provider
signer = new Wallet(id("test"))
// Wallet {
//   address: '0xC08B5542D177ac6686946920409741463a15dDdB',
//   provider: null
// }

message = "sign into ethers.org?"

// Sign the message
sig = await signer.signMessage(message);
// '0xefc6e1d2f21bb22b1013d05ecf1f06fd73cdcb34388111e4deec58605f3667061783be1297d8e3bee955d5b583bac7b26789b4a4c12042d59799ca75d98d23a51c'

// Verify the message; note that the address matches the signer
verifyMessage(message, sig)
// '0xC08B5542D177ac6686946920409741463a15dDdB'

Many other advanced protocols built on signed messages are used to allow a private key to authorize other users to transfer their tokens, thus allowing others to pay the transaction fees for the transfer.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.