Skip to content

Solidity Smart Contract Development - Mastering ethers.js

Published: at 07:00 AM

Solidity Smart Contract Development - Mastering ethers.js

Introduction

In my previous article on Solidity fundamentals, we explored the core syntax and concepts of smart contract development. As a programmer with over 10 years of experience, I’ve found that understanding the tools and libraries that bridge our contracts with applications is crucial for effective blockchain development.

After working extensively with various blockchain frameworks and considering the industry trends, I’ve observed that while Python-based tools like Brownie offer excellent functionality, the JavaScript ecosystem with Hardhat has become the dominant choice for most teams. This is largely due to its extensive plugin system, better documentation, and widespread community adoption.

Today, I want to share my experience with ethers.js, a powerful JavaScript library that serves as the foundation for much of the Ethereum development ecosystem. Understanding ethers.js is essential before diving into frameworks like Hardhat, as it provides the core functionality for contract compilation, deployment, and interaction.

In this article, we’ll explore how to use ethers.js to interact with Ethereum test networks, specifically deploying contracts to Rinkeby through the Alchemy platform and performing various contract operations.

What is ethers.js?

ethers.js is a complete Ethereum wallet implementation and utilities library in JavaScript and TypeScript. It provides a clean, modular approach to interacting with the Ethereum blockchain and its ecosystem.

The library is available on GitHub with comprehensive documentation, making it an excellent choice for both beginners and experienced developers.

Installation

Install ethers.js using yarn or npm:

yarn add ethers

yarn_add_ethers

Basic Usage

Import the library using require or ES6 imports:

const ethers = require("ethers");

Solidity Contract Compilation

Contract Source Code

Let’s start with a simple storage contract that demonstrates basic Solidity functionality:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.7;

contract SimpleStorage {
    uint256 favoriteNumber;
    bool favoriteBool;

    struct People {
        uint256 favoriteNumber;
        string name;
    }

    People public person = People({favoriteNumber: 2, name: "Arthur"});

    People[] public people;

    mapping(string => uint256) public nameToFavoriteNumber;

    function store(uint256 _favoriteNumber) public returns (uint256) {
        favoriteNumber = _favoriteNumber;
        return favoriteNumber;
    }

    function retrieve() public view returns (uint256) {
        return favoriteNumber;
    }

    function addPerson(string memory _name, uint256 _favoriteNumber) public {
        people.push(People({favoriteNumber: _favoriteNumber, name: _name}));
        nameToFavoriteNumber[_name] = _favoriteNumber;
    }
}

This contract demonstrates several Solidity concepts:

Reading Contract Source Files

After writing and syntax-checking your Solidity contract, you need to compile it into ABI (Application Binary Interface) and bytecode for deployment.

Install the Solidity compiler with a specific version:

yarn add solc@0.8.7-fixed

Compile the contract using the solcjs command:

yarn solcjs --bin --abi --include-path node_modules/ --base-path . -o . SimpleStorage.sol

Since compilation is a frequent operation, I recommend adding a script to your package.json:

{
  "scripts": {
    "compile": "yarn solcjs --bin --abi --include-path node_modules/ --base-path . -o . SimpleStorage.sol"
  }
}

Now you can simply run yarn compile to generate the compiled contract files.

Getting Compilation Results

After compilation, you’ll have .abi and .bin files containing the contract’s ABI and bytecode respectively.

Reading bytecode and ABI

const fs = require("fs-extra");

const abi = fs.readFileSync("./SimpleStorage_sol_SimpleStorage.abi", "utf-8");
const binary = fs.readFileSync(
  "./SimpleStorage_sol_SimpleStorage.bin",
  "utf-8"
);

These files are essential for contract deployment and interaction.

Setting Up Rinkeby Test Network Environment (Alchemy)

For smart contract testing, we need to deploy to an actual blockchain network. I recommend using Alchemy’s Rinkeby testnet for development and testing.

Alchemy Platform

Visit Alchemy’s website, register and log in to access the dashboard showing your applications.

alchemy_dashboard

Click “Create App” to set up a new Rinkeby test network node:

alchemy_create_app

After creation, click “View Details” to access your app information. Click “View Key” in the top right to get your node connection details. Save the HTTP URL for later use.

alchemy_app_detail

Creating Rinkeby Test Account (MetaMask)

MetaMask Setup

With your test network environment ready, create an account using MetaMask, obtain test tokens, and record your account’s private key for development use.

metamask_private_key

Important: Never use real private keys or mainnet accounts for development. Always use dedicated test accounts.

Getting Test Tokens

To test contract deployment and transactions, you’ll need test tokens. Use these faucets:

Connecting to Test Node and Wallet

Connecting to the Node

ethers.js provides simple methods to connect to your test node. Use your Alchemy HTTP URL:

const ethers = require("ethers");

const provider = new ethers.providers.JsonRpcProvider(
  process.env.ALCHEMY_RPC_URL
);

Connecting the Wallet

Connect to your test wallet using your MetaMask private key:

const ethers = require("ethers");

const wallet = new ethers.Wallet(process.env.RINKEBY_PRIVATE_KEY, provider);

Security Note: Always use environment variables for sensitive data like private keys and RPC URLs.

Solidity Contract Deployment

Creating a Contract Factory

Use ethers.js to create a contract factory:

const contractFactory = new ethers.ContractFactory(abi, binary, wallet);

Deploying the Contract

Deploy your contract to the Rinkeby network:

// Deploy the contract
const contract = await contractFactory.deploy();

// Wait for deployment confirmation
await contract.deployTransaction.wait(1);

console.log(`Contract deployed to: ${contract.address}`);
console.log(`Deployment transaction: ${contract.deployTransaction.hash}`);

The wait(1) ensures we wait for at least one confirmation before proceeding.

Interacting with Contracts

Once deployed, you can interact with your contract using various methods.

Reading Data (View Functions)

Call the retrieve() function to read stored data:

const currentFavoriteNumber = await contract.retrieve();
console.log(`Current favorite number: ${currentFavoriteNumber}`);

Writing Data (State-Changing Functions)

Call the store() function to update contract state:

console.log("Updating favorite number...");
const transactionResponse = await contract.store("7");
const transactionReceipt = await transactionResponse.wait(1);

console.log(`Transaction confirmed: ${transactionReceipt.transactionHash}`);

Adding Complex Data

Use the addPerson() function to demonstrate struct and mapping usage:

console.log("Adding a person...");
const addPersonTx = await contract.addPerson("Alice", 42);
await addPersonTx.wait(1);

// Verify the data was added
const aliceFavoriteNumber = await contract.nameToFavoriteNumber("Alice");
console.log(`Alice's favorite number: ${aliceFavoriteNumber}`);

Creating Transactions from Raw Data

For advanced use cases, you can construct transactions manually:

Building the Transaction

const nonce = await wallet.getTransactionCount();
const tx = {
  nonce: nonce,
  gasPrice: 20000000000, // 20 gwei
  gasLimit: 1000000,
  to: null, // null for contract creation
  value: 0,
  data: "0x" + binary,
  chainId: 4, // Rinkeby chain ID
};

Signing the Transaction

const signedTx = await wallet.signTransaction(tx);
console.log("Transaction signed");

Sending the Transaction

const sentTxResponse = await wallet.sendTransaction(tx);
const receipt = await sentTxResponse.wait(1);

console.log(`Contract deployed at: ${receipt.contractAddress}`);

Best Practices and Considerations

Based on my experience working with ethers.js in production environments, here are some key recommendations:

Error Handling

Always wrap blockchain operations in try-catch blocks:

try {
  const contract = await contractFactory.deploy();
  await contract.deployTransaction.wait(1);
  console.log("Contract deployed successfully");
} catch (error) {
  console.error("Deployment failed:", error.message);
}

Gas Optimization

Monitor gas usage and optimize accordingly:

// Estimate gas before sending transactions
const gasEstimate = await contract.estimateGas.store(42);
console.log(`Estimated gas: ${gasEstimate.toString()}`);

// Use gas estimate in transaction
const tx = await contract.store(42, { gasLimit: gasEstimate });

Environment Configuration

Use proper environment variable management:

require("dotenv").config();

const ALCHEMY_RPC_URL = process.env.ALCHEMY_RPC_URL;
const PRIVATE_KEY = process.env.RINKEBY_PRIVATE_KEY;

if (!ALCHEMY_RPC_URL || !PRIVATE_KEY) {
  throw new Error(
    "Please set ALCHEMY_RPC_URL and RINKEBY_PRIVATE_KEY in your .env file"
  );
}

Summary

Working with ethers.js provides a solid foundation for Ethereum development. While production projects typically use higher-level frameworks like Hardhat or Truffle, understanding the underlying ethers.js library is invaluable for:

As someone who has worked with various blockchain development tools over the years, I find ethers.js strikes an excellent balance between simplicity and functionality. Its modular design makes it easy to understand and extend, while its comprehensive feature set covers virtually all Ethereum development needs.

The skills you develop working directly with ethers.js will serve you well when transitioning to frameworks like Hardhat, as these tools are built on top of ethers.js and share many of the same concepts and patterns.