Build a Coffee Vending dApp With Solidity, Hardhat, and React

A primer for building full-stack dApps

Rajkumar Gaur
Better Programming

--

Photo by Shubham Dhage on Unsplash

Introduction

We will be looking at how to create a dApp. We will use Solidity for writing the smart contracts, Hardhat for development tools and interacting with the smart contracts, and React with Ethers library for the frontend.

We will also be writing unit tests for the smart contract and deploying the contract to a live Goerli Testnet.

Here is the live project if you want to see the result. Let’s start!

Hardhat Setup

Hardhat is needed for compiling, deploying, and testing the smart contracts. You can do these without Hardhat, but Hardhat abstracts these steps by providing APIs and CLI.

Create a new directory and do npm init to initialize package.json.

Install Hardhat using yarn or npm. This will add Hardhat and its dependencies to package.json.

yarn add -D hardhat

Hardhat provides a CLI command for initializing and setting up a boilerplate project. The command below gives three options to choose from, A JavaScript project, A TypeScript project, or an empty hardhat.config.jsfile. We will choose A JavaScript project.

npx hardhat

The following folder structure and files are generated after running the above command:

/
contracts/
Lock.sol
node_modules
scripts/
deploy.js
test/
Lock.js
.gitignore
hardhat.config.js
package.json
README.md
yarn.lock or package-lock.json

The folder structure is self-descriptive. contracts folder will have all your .sol smart contract files, scripts is for writing utilities that might come in handy, generally containing the deploy script. test folder will contain testing scripts for the smart contract.

Another important file to look into is the hardhat.config.js file. This file is used to customize how Hardhat will use tools, networks, folder structure, etc. We will look into more details later as and when we change this file.

We will need Hardhat helper packages to deploy, test, and interact with the smart contract. Install them by the following command:

yarn add -D @nomicfoundation/hardhat-toolbox @nomicfoundation/hardhat-network-helpers @nomicfoundation/hardhat-chai-matchers @nomiclabs/hardhat-ethers @nomiclabs/hardhat-etherscan chai ethers hardhat-gas-reporter solidity-coverage @typechain/hardhat typechain @typechain/ethers-v5 @ethersproject/abi @ethersproject/providers

That’s it for the setup. Let’s start writing smart contracts!

Writing Smart Contracts

Hardhat searches for smart contracts in the contracts folder by default. If you followed the above setup, you might already have a sample smart contract file Lock.sol in there. We will be replacing it with our smart contract.

We will create a new file in contracts folder with .sol extension. I am naming it CoffeeMachine.sol.

Before writing the code, let’s revisit the hardhat.config.js file. The solidity compiler version can be defined in the config file. You might already see the compiler version to be set as 0.8.x (at the time I am writing this article). You can update this version to any compiler version you want.

Also, @nomicfoundation/hardhat-toolbox is imported at the top of the file. This import imports all the Hardhat helper packages we added as dependencies. These tools are required for compiling, deploying, and testing smart contracts.

require("@nomicfoundation/hardhat-toolbox");
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: "0.8.17",
};

Let’s start writing some code in contracts/CoffeeMachine.sol file. Add the license identifier and the pragma directive to specify compiler compatibility of the smart contract.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;

If you use another version like 0.7.* it would not compile and return Source file requires different compiler version (current compiler is 0.8.17+commit.8df45f5f.Emscripten.clang) .

Next, we define the contract body and define variables.

We are making the owner payable to transfer the received ETH to the owner’s account from the contract.

Next, we define the contract functions. We will have four functions:

  • mintTokens — Creates tokens for the caller in exchange for ETH
  • transferTokens — Transfers tokens to another account
  • getTokenBalance — Returns the number of tokens in the caller’s account
  • purchaseCoffee — Subtracts the tokens and gives you a coffee (not really)

We have our contract ready. To test if we have done everything right, compile the contract by running the command below:

npx hardhat compile

This will compile the contract and save the contract ABI information in artifacts/contracts folder. This will be later used for connecting with our front-end application.

Writing Unit Tests

Testing is an important step for verifying the modular functionality of your code. Writing tests with Hardhat will feel very JavaScript-ey if that’s a term :)

Hardhat uses Mocha and Chai behind the scenes for testing. If you are used to writing JavaScript tests with Mocha, then writing tests for the smart contract will feel trivial.

Create a new file called CoffeeMachine.js in the test directory. We will be importing expect and ethers to use in the tests. We will also describe the tests in the usual way with describe and it blocks.

const { expect } = require("chai"); // used to assert test results
const { ethers } = require("hardhat"); // used to deploy and interact with the contract
describe("CoffeeMachine", function () {
it("should mint tokens for the user", async function () {
const [owner, addr1] = await ethers.getSigners(); // getting the available accounts
const CM = await ethers.getContractFactory("CoffeeMachine"); // fetch the contract
const cm = await CM.deploy(owner); // deploy the contract
await expect(
cm.connect(addr1).mintTokens({
value: ethers.utils.parseEther("0.0012"),
}) // connect to addr1, and mint tokens
)
.to.changeEtherBalance(owner, ethers.utils.parseEther("0.001")) // expect the owner to have received 0.001 (the remainer 0.0002 is sent back to the user)
.to.changeEtherBalance(addr1, ethers.utils.parseEther("-0.001")); // expect the user to have 0.001 less ETH

expect(await cm.connect(addr1).getTokenBalance()).to.equal(2); // expect user to gain 2 tokens (0.001 / 0.0005 = 2 tokens)
});
})

Note how we have the method changeEtherBalance method in Chai. This is injected by Hardhat and is not available on the Chai package. These methods are injected into the Chai package by Hardhat’s @nomicfoundation/hardhat-chai-matchers.

If there are multiple tests, we should resue the deployed contract instead of repeatedly writing the first three lines. Hardhat provides fixtures that deploy the contract one time and resets the contract state before each test.

Fixtures are functions where you can define the contract deployment code (or any code you want to be reused). Then, this fixture will be called from every test to get the contract instance using the loadFixture from @nomicfoundation/hardhat-network-helpers .

const { expect } = require("chai");
const { ethers } = require("hardhat");
const { loadFixture } = require("@nomicfoundation/hardhat-network-helpers");
describe("CoffeeMachine", function () {
// fixture to be called from tests
async function deployCoffeeMachineFixture() {
// write your deployment code
const addresses = await ethers.getSigners();
const CM = await ethers.getContractFactory("CoffeeMachine");
const cm = await CM.deploy(addresses[0].address);
// return the instances to be used by tests
return { cm, addresses };
}

it("should mint tokens for the user", async function () {
// rewritten to use fixtures
const { cm, addresses } = await loadFixture(deployCoffeeMachineFixture);
const [owner, addr1] = addresses;
await expect(
cm.connect(addr1).mintTokens({
value: ethers.utils.parseEther("0.0012"),
})
)
.to.changeEtherBalance(owner, ethers.utils.parseEther("0.001"))
.to.changeEtherBalance(addr1, ethers.utils.parseEther("-0.001"));
expect(await cm.connect(addr1).getTokenBalance()).to.equal(2);
});
it("should not mint tokens for the owner", async function () {
// rewritten to use fixtures
const { cm } = await loadFixture(deployCoffeeMachineFixture);
await expect(
cm.mintTokens({
value: ethers.utils.parseEther("0.001"),
})
).to.be.revertedWith("cant mint tokens for the owner");
});
}

You can view the whole test file on the GitHub repo. It has other tests for this smart contract.

To run the tests, run the npx hardhat test command, and if you did everything right, you should see the below output.

Deploying the Contract

Hardhat provides an environment for running different scripts that are run using npx hardhat run scriptname.js --network networkname. A sample deploy script is provided, which is a good starting point for writing helper scripts.

We will change the scripts/deploy.js script with code to deploy our smart contract.

const path = require("path");
async function main() {
// ethers is available in the global scope
const [deployer] = await ethers.getSigners();
console.log(
"Deploying the contracts with the account:",
await deployer.getAddress()
);
const CM = await ethers.getContractFactory("CoffeeMachine");
const cm = await CM.deploy(deployer.address);
await cm.deployed();
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

The code might seem very familiar as we used similar code when deploying the contract in our tests. Notice that we are not importing ethers here, it is injected into the global object when the script is run using npx hardhat run script.js. But, if you want to run the script using node script.js , then do add the ethers import.

The deployed contract contains the contract address and artifacts used to connect to frontend applications or interact with the contract. We will see that when connecting to our frontend.

Connecting the Frontend

We will be using React for our frontend and ethers.js library for interacting with the contract from the frontend.

Create a new React app using the npx create-react-app frontend command. I am creating the frontend folder inside the root of our project. The file structure looks like the one below now:

/
contracts/
test/
scripts/
frontend/
others...

Install the ethers.js library to dependencies. This is the only library we will require for interacting with the smart contract. If you are wondering if this is the same library we saw earlier in the tests and scripts file, you are right. But that was a little different, as Hardhat simplified it by abstracting the code.

yarn add ethers

We will import ethers in our App.jsx file and start interacting with the contract. Instead of looking through all the code, I think for this article, we should be only going through the places we are using ethers, other than that, pure React and CSS.

ethers will require the contract’s artifacts and address for connecting to the deployed contract. We can get this from the deployment script we wrote earlier. Let’s revisit that and save the contract artifacts and address to our frontend.

const path = require("path");
const fs = require("fs");
async function main() {
// ethers is available in the global scope
const [deployer] = await ethers.getSigners();
console.log(
"Deploying the contracts with the account:",
await deployer.getAddress()
);
const CM = await ethers.getContractFactory("CoffeeMachine");
const cm = await CM.deploy(deployer.address);
await cm.deployed();
saveFrontendFiles(cm);
}
// we add this part to save artifacts and address
function saveFrontendFiles(cm) {
const contractsDir = path.join(__dirname, "/../frontend/src/contracts");
if (!fs.existsSync(contractsDir)) {
fs.mkdirSync(contractsDir);
}
fs.writeFileSync(
contractsDir + "/contract-address.json",
JSON.stringify({ CM: cm.address }, null, 2)
);
// `artifacts` is a helper property provided by Hardhat to read artifacts
const CMArtifact = artifacts.readArtifactSync("CoffeeMachine");
fs.writeFileSync(
contractsDir + "/CM.json",
JSON.stringify(CMArtifact, null, 2)
);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

Two files will be generated in the frontend folder after running the npx hardhat run scripts/deploy.jsscript. CM.json provides the meta-data of the contract. Only this file and the contract address must interact with the contract.

The smart contract can be deployed anywhere, but the frontend will only care about these two files. When the smart contract is redeployed, make sure these files are updated too.

{
"_format": "hh-sol-artifact-1",
"contractName": "CoffeeMachine",
"sourceName": "contracts/CoffeeMachine.sol",
"abi": [
{
"inputs": [
{
"internalType": "address payable",
"name": "_owner",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "MinimumValue",
"type": "error"
},
{
"inputs": [],
"name": "getTokenBalance",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "mintTokens",
"outputs": [],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address payable",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "price",
"type": "uint256"
}
],
"name": "purchaseCoffee",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "transferTokens",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
],
"bytecode": "0x608060405234801561001057600080fd5b50604051610a60380380610a608339818101604052810190610032919061008d565b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550506100ff565b600081519050610087816100e8565b92915050565b60006020828403121561009f57600080fd5b60006100ad84828501610078565b91505092915050565b60006100c1826100c8565b9050919050565b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b6100f1816100b6565b81146100fc57600080fd5b50565b6109528061010e6000396000f3fe60806040526004361061004a5760003560e01c806382b2e2571461004f5780638da5cb5b1461007a578063bec3fa17146100a5578063ee3f3c00146100ce578063eeb9635c146100f7575b600080fd5b34801561005b57600080fd5b50610064610101565b60405161007191906106d8565b60405180910390f35b34801561008657600080fd5b5061008f610148565b60405161009c919061067d565b60405180910390f35b3480156100b157600080fd5b506100cc60048036038101906100c791906105b4565b61016c565b005b3480156100da57600080fd5b506100f560048036038101906100f091906105f0565b61029e565b005b6100ff610379565b005b6000600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054905090565b60008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b80600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205410156101ee576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016101e5906106b8565b60405180910390fd5b80600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600082825461023d919061078b565b9250508190555080600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008282546102939190610704565b925050819055505050565b80600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020541015610320576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610317906106b8565b60405180910390fd5b80600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600082825461036f919061078b565b9250508190555050565b60008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415610408576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016103ff90610698565b60405180910390fd5b6601c6bf52634000341015610449576040517fa2c48c2a00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60006601c6bf526340003461045e919061075a565b905060006601c6bf5263400034610475919061080d565b905060008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff166108fc82346104bd919061078b565b9081150290604051600060405180830381858888f193505050501580156104e8573d6000803e3d6000fd5b503373ffffffffffffffffffffffffffffffffffffffff166108fc829081150290604051600060405180830381858888f1935050505015801561052f573d6000803e3d6000fd5b5081600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600082825461057f9190610704565b925050819055505050565b600081359050610599816108ee565b92915050565b6000813590506105ae81610905565b92915050565b600080604083850312156105c757600080fd5b60006105d58582860161058a565b92505060206105e68582860161059f565b9150509250929050565b60006020828403121561060257600080fd5b60006106108482850161059f565b91505092915050565b610622816107d1565b82525050565b6000610635601e836106f3565b91506106408261089c565b602082019050919050565b60006106586016836106f3565b9150610663826108c5565b602082019050919050565b61067781610803565b82525050565b60006020820190506106926000830184610619565b92915050565b600060208201905081810360008301526106b181610628565b9050919050565b600060208201905081810360008301526106d18161064b565b9050919050565b60006020820190506106ed600083018461066e565b92915050565b600082825260208201905092915050565b600061070f82610803565b915061071a83610803565b9250827fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0382111561074f5761074e61083e565b5b828201905092915050565b600061076582610803565b915061077083610803565b9250826107805761077f61086d565b5b828204905092915050565b600061079682610803565b91506107a183610803565b9250828210156107b4576107b361083e565b5b828203905092915050565b60006107ca826107e3565b9050919050565b60006107dc826107e3565b9050919050565b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b6000819050919050565b600061081882610803565b915061082383610803565b9250826108335761083261086d565b5b828206905092915050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601260045260246000fd5b7f63616e74206d696e7420746f6b656e7320666f7220746865206f776e65720000600082015250565b7f6163636f756e742062616c616e6365206973206c6f7700000000000000000000600082015250565b6108f7816107bf565b811461090257600080fd5b50565b61090e81610803565b811461091957600080fd5b5056fea2646970667358221220b4d914b8ae953fe3146979c85d8f16a3d5aeadd3db91fd0da9a6381e7da4c75e64736f6c63430008040033",
"deployedBytecode": "0x60806040526004361061004a5760003560e01c806382b2e2571461004f5780638da5cb5b1461007a578063bec3fa17146100a5578063ee3f3c00146100ce578063eeb9635c146100f7575b600080fd5b34801561005b57600080fd5b50610064610101565b60405161007191906106d8565b60405180910390f35b34801561008657600080fd5b5061008f610148565b60405161009c919061067d565b60405180910390f35b3480156100b157600080fd5b506100cc60048036038101906100c791906105b4565b61016c565b005b3480156100da57600080fd5b506100f560048036038101906100f091906105f0565b61029e565b005b6100ff610379565b005b6000600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002054905090565b60008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b80600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000205410156101ee576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016101e5906106b8565b60405180910390fd5b80600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600082825461023d919061078b565b9250508190555080600160008473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060008282546102939190610704565b925050819055505050565b80600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020541015610320576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610317906106b8565b60405180910390fd5b80600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600082825461036f919061078b565b9250508190555050565b60008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415610408576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016103ff90610698565b60405180910390fd5b6601c6bf52634000341015610449576040517fa2c48c2a00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60006601c6bf526340003461045e919061075a565b905060006601c6bf5263400034610475919061080d565b905060008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff166108fc82346104bd919061078b565b9081150290604051600060405180830381858888f193505050501580156104e8573d6000803e3d6000fd5b503373ffffffffffffffffffffffffffffffffffffffff166108fc829081150290604051600060405180830381858888f1935050505015801561052f573d6000803e3d6000fd5b5081600160003373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020600082825461057f9190610704565b925050819055505050565b600081359050610599816108ee565b92915050565b6000813590506105ae81610905565b92915050565b600080604083850312156105c757600080fd5b60006105d58582860161058a565b92505060206105e68582860161059f565b9150509250929050565b60006020828403121561060257600080fd5b60006106108482850161059f565b91505092915050565b610622816107d1565b82525050565b6000610635601e836106f3565b91506106408261089c565b602082019050919050565b60006106586016836106f3565b9150610663826108c5565b602082019050919050565b61067781610803565b82525050565b60006020820190506106926000830184610619565b92915050565b600060208201905081810360008301526106b181610628565b9050919050565b600060208201905081810360008301526106d18161064b565b9050919050565b60006020820190506106ed600083018461066e565b92915050565b600082825260208201905092915050565b600061070f82610803565b915061071a83610803565b9250827fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0382111561074f5761074e61083e565b5b828201905092915050565b600061076582610803565b915061077083610803565b9250826107805761077f61086d565b5b828204905092915050565b600061079682610803565b91506107a183610803565b9250828210156107b4576107b361083e565b5b828203905092915050565b60006107ca826107e3565b9050919050565b60006107dc826107e3565b9050919050565b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b6000819050919050565b600061081882610803565b915061082383610803565b9250826108335761083261086d565b5b828206905092915050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601260045260246000fd5b7f63616e74206d696e7420746f6b656e7320666f7220746865206f776e65720000600082015250565b7f6163636f756e742062616c616e6365206973206c6f7700000000000000000000600082015250565b6108f7816107bf565b811461090257600080fd5b50565b61090e81610803565b811461091957600080fd5b5056fea2646970667358221220b4d914b8ae953fe3146979c85d8f16a3d5aeadd3db91fd0da9a6381e7da4c75e64736f6c63430008040033",
"linkReferences": {},
"deployedLinkReferences": {}
}
{
"CM": "0x5FbDB2315678afecb367f032d93F642f64180aa3"
}

We deployed our contract earlier using the npx hardhat run scripts/deploy.js command, but let’s go into some details and see how we should properly deploy the contract to use it in the frontend.

When we run any script using hardhat run, the default hardhat network is used that comes with the hardhat package and is available locally for testing. For listening to requests from the frontend we want this hardhat network to be up and listening. We start a local hardhat node by running the following command:

npx hardhat node

This starts a JSON-RPC server at http://localhost:8545 or http://127.0.0.1:8545. This node will be listening for requests from the frontend, and we want to deploy our code to this specific node. To do that, use the same deploy script we used earlier but include the network flag like below:

npx hardhat run scripts/deploy.js --network localhost

Providing the network as localhost is very important. Otherwise, it will use a new hardhat network instance to deploy the code and not the hardhat node that we are running already. It will give the following output in our node's terminal:

We are ready to connect to this smart contract! Let’s return to the frontend and import the contract artifacts and address to connect. Also, let’s write the connectWallet method inside our React functional component.

import React, { useEffect, useState } from "react";
import contractAddress from "./contracts/contract-address.json"; // address generated from the deploy script
import CMArtifact from "./contracts/CM.json"; // artifacts generated from the deploy script
import { ethers } from "ethers";
function App () {
// other state management code...

const connectWallet = async () => {
// `ethereum` property is injected by Metamask to the global object
// This helps to interact with wallet accounts, balances, connections, etc
const [selectedAddress] = await window.ethereum.request({
method: "eth_requestAccounts", // get the currently connected address
});
setSelectedAddress(selectedAddress);

// provider provides a read-only abstraction of the blockchain
// it provides read-only access to contract, block, and network data
const provider = new ethers.providers.Web3Provider(window.ethereum);
// the signer is required, so that the transactions are done on behalf of
// the selected address. `ethers.Contract` returns a `Contract` object
// that is used to call the available functions in the smart contract
const cm = new ethers.Contract(
contractAddress.CM, // contract address
CMArtifact.abi, // contract abi (meta-data)
provider.getSigner(0) // Signer object signs and sends transactions
);
setCm(cm);
};

// we want to connect the wallet after page loads
useEffect(() => {
connectWallet()
}, [])
}

If you don’t already have MetaMask installed, install it from the chrome web store and set it up with login credentials.

The next step is to add the localhost network to MetaMask. Click on the circle icon at the top-right corner and navigate to Settings > Networks > Add Network, this will open a new page asking for the network configuration. Navigate to Networks > Test Networks > Localhost 8545 and update the Chain ID to 31337 like below:

The Network Name is just an identifier for the network, so this can be any name. RPC URL is the URL of our RPC node running on localhost. Chain ID is used to distinguish between different chains on the same network. The next step is to select the localhost network from the network dropdown in the MetaMask main menu.

Open our React web page in the browser, and MetaMask will pop up, asking to connect the wallet on this page. Allow the connection by clicking on Next and then Connect. We have successfully connected our wallet to our localhost network!

Now we want to be able to call the Contract’s methods from our frontend. We will be creating other functions in our React component and calling these functions on their respective onClick events.

import React, { useEffect, useState } from "react";
import contractAddress from "./contracts/contract-address.json"; // address generated from the deploy script
import CMArtifact from "./contracts/CM.json"; // artifacts generated from the deploy script
import { ethers } from "ethers";
function App () {
// other state management code...

// This error code means the user clicked on close or cancel
// when MetaMask promptes for approving the transaction.
const ERROR_CODE_TX_REJECTED_BY_USER = 4001;

const fetchTokenBalance = async () => {
// call Contract's methods using the `Contract` instance we created
// when connecting to the wallet.
const balance = await cm.getTokenBalance();
setBalance(balance); // to show the token balance to the user
};

const buyTokens = async () => {
try {
// call Contract's methods using the `Contract` instance we created
// when connecting to the wallet.
const tx = await cm.mintTokens({
// ETH is sent in the transaction using the value property in an object
value: ethers.utils.parseEther((0.0005 * tokensToBuy).toString()),
});
setTxBeingSent(tx.hash); // transaction hash to show user (optional, just for user-friendly-ness)
const receipt = await tx.wait(); // wait for transaction to get executed
if (receipt.status === 0) {
throw new Error("Transaction failed");
}
fetchTokenBalance(); // update the balance after success
} catch (err) {
if (err.code !== ERROR_CODE_TX_REJECTED_BY_USER) { // check if user rejected the transaction
console.error(err);
setTransactionError(err);
}
} finally {
setTxBeingSent(undefined);
}
};

const transferTokens = async () => {
try {
// Pass the arguments to Contract's methods as we pass to any normal function.
// It takes a last optional parameter { value: ... } as we saw in the `buyTokens` function
// As we dont need to send any ETH for this trnasaction we can omit that argument
const tx = await cm.transferTokens(giftAddress, giftTokens);
const receipt = await tx.wait();
if (receipt.status === 0) {
throw new Error("Transaction failed");
}
fetchTokenBalance();
} catch (err) {
if (err.code !== ERROR_CODE_TX_REJECTED_BY_USER) {
console.error(err);
setTransactionError(err);
}
} finally {
setTxBeingSent(undefined);
}
};
}

The above code lets us interact with the contract. We need to call other functions from our dApp, but I haven’t included those in the above Gist because it will be redundant as the logic remains the same.

I think other helper functions/state-management in our React component should be explained. Here’s the code:

import React, { useEffect, useState } from "react";
import contractAddress from "./contracts/contract-address.json"; // address generated from the deploy script
import CMArtifact from "./contracts/CM.json"; // artifacts generated from the deploy script
import { ethers } from "ethers";
function App () {
// other state management code...

// we want to connect the wallet after page loads
useEffect(() => {
connectWallet()
}, [])

// Fetch new token balance everytime the contract is changed.
// It will only change when the account is changed and contract is redeployed with the new signer
useEffect(() => {
if (cm) {
fetchTokenBalance();
}
}, [cm]);
// Listen for account changed event
window.ethereum.on("accountsChanged", (accounts) => {
if (accounts[0] && accounts[0] !== selectedAddress) {
connectWallet(); // connect wallet and redeploy the contract with the new signer
}
});
// Check if MetaMask is installed
if (window.ethereum === undefined) {
return <div>No wallet detected</div>;
}
}

That covers our front-end part. Of course, other things like CSS, JSX, and state management exist. But these are out of the scope of this article. I will add the GitHub repo, where you can find the full code.

Connecting to a Test Network

You will often want to connect to the main net or the test net network after testing your contract on the localhost network. Hardhat provides a way to configure networks in the Hardhat config file. These networks can be used to deploy the contract to those respective networks.

Deploying to a live network will require the URL for that remote RPC node and at least one account to be used as the contract’s deployer or owner.

Let’s get the RPC node URL from a service provider like Alchemy or Infura. I will be using Alchemy. Sign in or create a new account on Alchemy and create a new App on the dashboard. Select the chain as Etehreum and the network as Goerli while creating the app since we will be deploying to the Goerli test net.

Click on the View Key button for that app and fetch the HTTPS URL from the app info.

Add the Goerli network like we added the localhost network to MetaMask. Make sure you are on the Goerli network on MetaMask, export the private key from the account in MetaMask, and copy it.

We plug in these values now to our Hardhat config like below:

require("@nomicfoundation/hardhat-toolbox");
const ALCHEMY_API_KEY = "REPLACE_THIS_WITH_YOUR_KEY";
const GOERLI_PRIVATE_KEY = "REPLACE_THIS_WITH_YOUR_GOERLI_ACCOUNT";
// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more
/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
solidity: "0.8.4",
networks: {
// the network name
goerli: {
url: `https://eth-goerli.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, // the node URL
accounts: [GOERLI_PRIVATE_KEY], // the accounts accessible via `ethers`
},
},
};

Important note: Never share your API keys and account private keys with anyone or commit them to platforms like GitHub. If someone can access your private key, it’s essentially the same as accessing your account. Use .env file for storing the private keys, and don't commit the file to git.

Note that the accounts property can have multiple accounts inside the array. This does not mean that other accounts on the Goerli network cant interact with the contract. This means that these are the accounts that can be accessed by ethers.getSigners() during deployments and testing.

We must pass the network name configured in the Hardhat config file to deploy the contract to this network. For example, you will need to pass the --network goerli flag while running the deploy command.

npx hardhat run scripts/deploy.js --network goerli

You will also need to add some ether on your Goerli test accounts to deploy and test your smart contract on the Goerli network. Free GoerliETH can be redeemed from the following faucets.

If you refresh our React page after running the above deploy script, you should be able to see the connected Goerli account and interact with the contract on the Goerli network.

You might also notice that the transactions take some time to execute after deploying to the Goerli network. This is because the localhost network was a simulation by Hardhat, but now we have our contract on a live network, and transactions will take time to complete.

Deploying the Front End

The frontend can be deployed just like any React app on platforms like Heroku or Netlify. Just make sure you include the contract address and the contract ABI in the frontend.

I deployed this on Netlify by pushing the whole code to GitHub (not just the front-end folder) and declaring the base directory as /frontend and the build directory as /frontend/buildon Netlify deployment settings.

Whenever you redeploy the contract or make any changes to the code, remember to push it to GitHub so it will redeploy the frontend with the latest contract ABI and address information.

Conclusion

We have seen how to write, test and deploy the smart contract. We also connected our frontend to the contract and developed a full-stack dApp. Let me know in the comments if you face any issues. Thanks for reading!

Useful Links

--

--