web3 tutorial [09/10] - ethers.js: calling smart contracts from the web

by parttimelarry

Putting it all together

So we’ve written a smart contract in Solidity and deployed it to the Goerli testnet using Alchemy. We’ve also created an appointment scheduling interface with React and Material-UI. Currently our frontend and backend are completely disconnected. Let’s change that. We want our UI to read data from and write data to the blockchain by calling our smart contract. To do this, we will be using Ethers.js.

What is Ethers.js?

Ethers.js is a JavaScript library for interacting with the Ethereum blockchain. For our purposes, we will be using the ethers.js Contract object. The Contract object will allow us call our smart contract using JavaScript.

Calling Your Backend: ABI’s vs. API’s

When developing a “traditional” web application, we often fetch data from a REST API. We make an HTTP request and get an HTTP response, often in JSON format. In an Ethereum dapp, we will call our smart contract using an ABI. ABI stands for Application Binary Interface.

When you compile your Solidity code, it generates an ABI file in the artifacts/ directory. It should look similar to the one below. As you can see, our ABI is a JSON file that describes our smart contract. This JSON file is output by the Solidity compiler. By reading this specification, a library like Ethers knows how to represent our smart contract in JavaScript. The ABI contains an array of functions. Each has a name, a type (eg. constructor), and a list of inputs and outputs.

{
  "_format": "hh-sol-artifact-1",
  "contractName": "Calend3",
  "sourceName": "contracts/Calend3.sol",
  "abi": [
    {
      "inputs": [],
      "stateMutability": "nonpayable",
      "type": "constructor"
    },
    {
      "inputs": [
        {
          "internalType": "string",
          "name": "title",
          "type": "string"
        },
        {
          "internalType": "uint256",
          "name": "startTime",
          "type": "uint256"
        },
        {
          "internalType": "uint256",
          "name": "endTime",
          "type": "uint256"
        }
      ],
      "name": "createAppointment",
      "outputs": [],
      "stateMutability": "payable",
      "type": "function"
    },
    {
      "inputs": [],
      "name": "getAppointments",
      "outputs": [
        {
          "components": [
            {
              "internalType": "string",
              "name": "title",
              "type": "string"
            },
            {
              "internalType": "address",
              "name": "attendee",
              "type": "address"
            },
            {
              "internalType": "uint256",
              "name": "startTime",
              "type": "uint256"
            },
            {
              "internalType": "uint256",
              "name": "endTime",
              "type": "uint256"
            },
            {
              "internalType": "uint256",
              "name": "amountPaid",
              "type": "uint256"
            }
          ],
          "internalType": "struct Calend3.Appointment[]",
          "name": "",
          "type": "tuple[]"
        }
      ],
      "stateMutability": "view",
      "type": "function"
    },
    {
      "inputs": [],
      "name": "getRate",
      "outputs": [
        {
          "internalType": "uint256",
          "name": "",
          "type": "uint256"
        }
      ],
      "stateMutability": "view",
      "type": "function"
    },
    {
      "inputs": [],
      "name": "owner",
      "outputs": [
        {
          "internalType": "address payable",
          "name": "",
          "type": "address"
        }
      ],
      "stateMutability": "view",
      "type": "function"
    },
    {
      "inputs": [
        {
          "internalType": "uint256",
          "name": "_rate",
          "type": "uint256"
        }
      ],
      "name": "setRate",
      "outputs": [],
      "stateMutability": "nonpayable",
      "type": "function"
    }
  ],
  "bytecode": "0x608060405234801561001057600080fd5b5033600160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550610e6e806100616000396000f3fe60806040526004361061004a5760003560e01c806334fcf4",
  "deployedBytecode": "0x60806040526004361061004a5760003560e01c806334fcf4371461004f57806359c35bdf14610078578063679aefce146100945780638da5cb5b146100bf57806396b80d08146100ea575b600080fd5b",
  "linkReferences": {},
  "deployedLinkReferences": {}
}

Using Ethers.js

To get started with ethers.js, let’s first make our ABI file accessible to our frontend. To do this, we’ll update our deploy.js script so that it copies the generated ABI file to our frontend directory.

async function main() {
  const Contract = await hre.ethers.getContractFactory("Calend3");
  const contract = await Contract.deploy();

  await contract.deployed();

  console.log("Calend3 deployed to:", contract.address);

  saveFrontendFiles();
}

function saveFrontendFiles() {
  const fs = require("fs");

  const abiDir = __dirname + "/../frontend/src/abis";

  if (!fs.existsSync(abiDir)) {
    fs.mkdirSync(abiDir);
  }

  const artifact = artifacts.readArtifactSync("Calend3");

  fs.writeFileSync(
    abiDir + "/Calend3.json",
    JSON.stringify(artifact, null, 2)
  );
}

This script reads the Calend3.json in the artifacts directory and copies it to a directory where our webapp can access them. Notice how it creates a new directory in frontend/src named abis.

Let’s redeploy our smart contract now and verify that this file is copied. Also, take note of the contract address because we will need it in a moment.

npx hardhat run scripts/deploy.js --network goerli
Calend3 deployed to: 0x71E713E992a0183767df0aF2C58C5b224e8d7Ac3

Creating an ether.js Contract object

Now that our ABI is in place, let’s create an ethers.js Contract object to use it. First, we must import the ABI file in components/Calendar.js. We’ll also import ethers and the useState and useEffect hooks.

import { useState, useEffect } from 'react';
import { ethers } from "ethers";
import abi from "../abis/Calend3.json";

Now we will create an instance of the ethers.Contract object. With this contract object, we will be able to call our smart contract functions the same way we call a JavaScript function.

The contract object requires a contract address, a web3 provider, and a contract ABI. The contract address was given to us when we deployed our contract to the Goerli testnet. The ethers web3 provider accepts the window.ethereum object injected by Metamask.

Add the following code below the import statements in components/Calendar.js:

const contractAddress = "0xYOURCONTRACTADDRESS";
const contractABI = abi.abi;
const provider = new ethers.providers.Web3Provider(window.ethereum);
const contract = new ethers.Contract(contractAddress, contractABI, provider.getSigner());  

Now that we have everything configured, we are ready to call our functions using the contract object.

Admin User and Setting a Rate

The first thing we will do is set a minutely rate for the appointments. We could set this when we deploy our smart contract, but in this case we want to give the admin a slider where they can adjust the rate from the web.

Admin UI State Variables

We will need to keep track of a few state variables. We’ll use hooks to update the state of these variables when the admin interacts with the app.

isAdmin and setIsAdmin()

First, we will show an “Admin” button if the connected user owns the smart contract. Let’s create a variable called isAdmin to store whether or not the user is an admin. When the app loads, we will compare the contract owner’s address to the connected wallet’s address. If they are the same, we will use setIsAdmin() to set isAdmin to true. We will also allow the user show and hide the admin panel, so we’ll create another state variable called showAdmin for this purpose.

const [isAdmin, setIsAdmin] = useState(false);
const [showAdmin, setShowAdmin] = useState(false);

The third thing we’ll need to track is the current minutely rate. When the page first loads, no rate is set. We must check the current rate by calling our smart contract. Once we know the rate, we will use setRate to update the state of our UI. We will also call setRate each time the admin moves the slider and saves a new rate.

const [rate, setRate] = useState(false);

Define all of your state variables at the top of the Calendar component like so:

  const Calendar = () => {
    // admin rate setting functionality
    const [showAdmin, setShowAdmin] = useState(false);
    const [isAdmin, setIsAdmin] = useState(false);
    const [rate, setRate] = useState(false);

    const getData = async () => {
      // get contract owner and set admin if connected account is owner
      const owner = await contract.owner();
      setIsAdmin(owner.toUpperCase() === account.toUpperCase());

      const rate = await contract.getRate();
      setRate(ethers.utils.formatEther(rate.toString()));
    } 
  }

Making Mistakes and Fixing Them

When we reload the page, we notice that the JavaScript developer console shows an error. I unintentionally ran into a few errors when writing this tutorial. Rather than edit them out, I like to leave these in sometimes so that we can practice fixing some issues. The first error is:

Uncaught (in promise) TypeError: contract.owner is not a function

This occured because we did not make the owner public in our smart contract. Since we need to access the owner of the contract from the web, we need to make the owner variable in Calend3.sol public:

address payable public owner;

Now that this has been updated, we need to redeploy the contract. Since we have updated our deploy.js to automatically copy the latest ABI file, we don’t need to copy it manually after our change. We do, however, need to update the contract address in Calendar.js redeploying it will change the address.

When we refresh the app, we see yet another error:

Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'toUpperCase')

This occurs because the account variable that we defined in App.js is not available in Calendar.js. It needs to get there somehow. In App.js we will pass the connected account to the Calendar component as a prop. In App.js, find the line where we place the Calendar component and pass in an account prop:

{account && <Calendar account={account} />}

Then we can receive any props passed into the Calendar component by adding props as a parameter in Calendar.js:

const Calendar = (props) => {

Once the props are available, we can access account with props.account:

setIsAdmin(owner.toUpperCase() === props.account.toUpperCase());

At this point, we have read data from the blockchain and set state variables for rate and isAdmin. We will now use these variables to add a rate slider.

Admin UI Slider Component

Now that we have our state variables and hooks in place, let’s add the slider component to our app. We’ll use the Material UI Slider component, which is documented here. We’ll define our own component called Admin that contains the slider. We’ll show and hide the Admin component depending on whether the user is an admin or not.

First, let’s import a few Material UI components that we will be using:

import { Box, Button, Slider } from '@material-ui/core';
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';

If you get an error about @material-ui/core, you can install it with npm in your frontend/ directory:

npm install @material-ui/core

Now let’s create the Admin component and use Box, Button and Slider within it. We’ll also define two functions called saveRate() and handleSliderChange(). The first function handleSliderChange() handles updating the rate variable when the slider moves. The second functino saveRate() calls the smart contract setRate() function and saves the value to the blockchain.

const handleSliderChange = (event, newValue) => {
    setRate(newValue);
};

const saveRate = async () => {
    const tx = await contract.setRate(ethers.utils.parseEther(rate.toString()));
}

const Admin = () => {
    return <div>
        <Box>
            <h3>Set Your Minutely Rate</h3>
            <Slider defaultValue={parseFloat(rate)} 
                step={0.001} 
                min={0} 
                max={.1} 
                valueLabelDisplay="auto"
                onChangeCommitted={handleSliderChange} />
            <br /><br />
            <Button onClick={saveRate} variant="contained">save configuration</Button>
        </Box>
    </div>
}

Next we will add some logic to our JSX to display the Admin component above the calendar:

return (<div>
    <div>
    {isAdmin && <Button onClick={() => setShowAdmin(!showAdmin)} variant="contained" startIcon={<SettingsSuggestIcon />}>
        Admin
    </Button>}
    </div>
    {showAdmin && <Admin />}
    <div id="calendar">

Once this is done, you should be able to see the admin section when the owner of the contract is connected to the Calendar dapp. If a different wallet is connected, the admin section should not appear.

Additional Styling

To make the slider more user-friendly, we can customize it and add some “marks” on the slider. Also, this is a good point to add any styles to make the app your own. Give it a unique look and feel.

const marks = [
  {
    value: 0.00,
    label: 'Free',
  },
  {
    value: 0.02,
    label: '0.02 ETH/min',
  },
  {
    value: 0.04,
    label: '0.04 ETH/min',
  },
  {
      value: 0.06,
      label: '0.06 ETH/min',
  },
  {
      value: 0.08,
      label: '0.08 ETH/min',
  },
  {
    value: 0.1,
    label: 'Expensive',
  },
];
<Slider defaultValue={parseFloat(rate)} 
    step={0.001} 
    min={0} 
    max={.1} 
    valueLabelDisplay="auto"
    marks={marks}
    onChangeCommitted={handleSliderChange} />

Fetching Appointments

To fetch appointments, we just need to call getAppointments() on our smart contract. We’ll need to transform our data slightly to match the format expected the React Scheduler component. Let’s add a call to contract.getAppointments() in the getData() function that runs on load.

First, define a state variable to hold the appointments. Initially, the appointments array will be empty, so we initialize it using useState([]). Once we fetch the appointments from the smart contract, we will call setAppointments() to update the state of appointments.

// appointment setting and storage
const [appointments, setAppointments] = useState([]);
const Calendar = () => {
    // admin rate setting functionality
    const [showAdmin, setShowAdmin] = useState(false);
    const [isAdmin, setIsAdmin] = useState(false);
    const [rate, setRate] = useState(false);
    const [appointments, setAppointments] = useState([]);

    const getData = async () => {
        // get contract owner and set admin if connected account is owner
        const owner = await contract.owner();
        setIsAdmin(owner.toUpperCase() === props.account.toUpperCase());

        const rate = await contract.getRate();
        setRate(ethers.utils.formatEther(rate.toString()));

        const appointmentData = await contract.getAppointments();
        console.log('got appointments');
        console.log(appointmentData);
    }

    useEffect(() => {
        getData();
    }, []);

If all went according to plan, you should see ‘got appointments’ in your web browser’s JavaScript console.

Transforming Appointment Data

Note how the mock data we used has a slightly different format than the data returned by our smart contract.

const schedulerData = [
    { startDate: '2022-02-24T09:45', endDate: '2022-02-24T11:00', title: 'Dogecoin Integration' },
    { startDate: '2022-02-25T12:00', endDate: '2022-02-25T13:30', title: 'A Podcast Appearance' },
  ];

The React scheduler requires the data to be in the above format. To handle this, we will write a small function called transformAppointmentData() to transform the data before feeding it into the Scheduler component.

const transformAppointmentData = (appointmentData) => {
    let data = [];
    appointmentData.forEach(appointment => {
      data.push({
        title: appointment.title,
        startDate: new Date(appointment.startTime * 1000),
        endDate: new Date(appointment.endTime * 1000),
      });
    });

    setAppointments(data);
}

Then we simply call transformAppointmentData from getData():

const appointmentData = await contract.getAppointments();
console.log('got appointments');
console.log(appointmentData);
transformAppointmentData(appointmentData);

Now instead of using the mock schedulerData we hardcoded in previously, we can pass the real data in to the Scheduler component:

<Scheduler data={appointments}>

At this point, we have all the necessary code to render appointments on the calendar. But we haven’t created any appointments yet! Let’s change that.

Creating an Appointment

So far the appointments array is empty. This is because we haven’t created an appointment yet. We will use the same technique to call createAppointment() when the appointment form is saved. If you recall, the React Scheduler component submits data that has the following structure:

{
    "added": {
        "title": "test appointment",
        "startDate": "2022-02-08T18:00:00.000Z",
        "endDate": "2022-02-08T18:30:00.000Z",
        "allDay": false,
        "notes": "some notes"
    }
}

So all we need to do is feed this data to contract.createAppointment(). We will also calculate the cost of the meeting on the frontend and pass it in an input named msg. This will result in the user sending ether on appointment creation. This value is validated on the backend by our smart contract, so if the user attempts to send less ether than required, the transaction will fail.

Create Appointment

const saveAppointment = async (data) => {
  const appointment = data.added;
  const title = appointment.title;
  const startTime = appointment.startDate.getTime() / 1000;
  const endTime = appointment.endDate.getTime() / 1000;

  try {
      const cost = ((endTime - startTime) / 60) * rate;
      const msg = {value: ethers.utils.parseEther(cost.toString())};
      let transaction = await contract.createAppointment(title, startTime, endTime, msg);
      
      await transaction.wait();
  } catch (error) {
      console.log(error);
  }
}

At this point, our appointment creation function works, but it isn’t very user-friendly. There are a few problems:

  1. We have to refresh the page to see the appointment appear on the calendar.
  2. We don’t have any indication of when the transaction has been completed
  3. The user has no way to verify the transaction.

Dialogs and Progress Indicators

To improve the user experience, let’s add a Dialog that shows the status of the transaction. Once the transaction is complete, we will display an Etherscan link with the transaction hash so that the user can verify it on the blockchain. First, we’ll create a few state variables to store the status of the UI. We’ll need to track:

  1. Whether the Dialog should be shown or not
  2. Whether the user is on the sign transaction step
  3. Whether the transaction has completed mining
  4. The transaction hash
const [showDialog, setShowDialog] = useState(false);
const [showSign, setShowSign] = useState(false);
const [mined, setMined] = useState(false);
const [transactionHash, setTransactionHash] = useState("");

Now let’s import the Material UI components that we will be using: Dialog and CircularProgress:

import Dialog from '@mui/material/Dialog';
import CircularProgress from '@mui/material/CircularProgress';

Then, we update the state appropriately during each stage of the saveAppointment process:

const saveAppointment = async (data) => {
  const appointment = data.added;
  const title = appointment.title;
  const startTime = appointment.startDate.getTime() / 1000;
  const endTime = appointment.endDate.getTime() / 1000;

  setShowSign(true);
  setShowDialog(true);
  setMined(false);

  try {
      const cost = ((endTime - startTime) / 60) * rate;
      const msg = {value: ethers.utils.parseEther(cost.toString())};
      let transaction = await contract.createAppointment(title, startTime, endTime, msg);
      
      setShowSign(false);

      await transaction.wait();

      setMined(true);
      setTransactionHash(transaction.hash);
  } catch (error) {
      console.log(error);
  }
}

We’ll create our own ConfirmDialog component and show different messages to the user depending on the status of the transaction.

const ConfirmDialog = () => {
  return <Dialog open={true}>
      <h3>
        {mined && 'Appointment Confirmed'}
        {!mined && !showSign && 'Confirming Your Appointment...'}
        {!mined && showSign && 'Please Sign to Confirm'}
      </h3>
      <div style={{textAlign: 'left', padding: '0px 20px 20px 20px'}}>
          {mined && <div>
            Your appointment has been confirmed and is on the blockchain.<br /><br />
            <a target="_blank" href={`https://goerli.etherscan.io/tx/${transactionHash}`}>View on Etherscan</a>
            </div>}
        {!mined && !showSign && <div><p>Please wait while we confirm your appoinment on the blockchain....</p></div>}
        {!mined && showSign && <div><p>Please sign the transaction to confirm your appointment.</p></div>}
      </div>
      <div style={{textAlign: 'center', paddingBottom: '30px'}}>
      {!mined && <CircularProgress />}
      </div>
      {mined && 
      <Button onClick={() => {
          setShowDialog(false);
          getData();
        }
        }>Close</Button>}
    </Dialog>
  }

Wow, that was a long section. We’ll wrap this up in the next section by adding Notifications. Your final Calendar.js should look something like this:

import { useState, useEffect } from 'react';
import { ethers } from "ethers";
import abi from "../abis/Calend3.json";

import { ViewState, EditingState, IntegratedEditing } from '@devexpress/dx-react-scheduler';
import { Scheduler, WeekView, Appointments, AppointmentForm } from '@devexpress/dx-react-scheduler-material-ui';

import { Box, Button, Slider } from '@material-ui/core';
import Dialog from '@mui/material/Dialog';
import CircularProgress from '@mui/material/CircularProgress';
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';

const contractAddress = "0xYOURCONTRACTADDRESS";
const contractABI = abi.abi;
const provider = new ethers.providers.Web3Provider(window.ethereum);
const contract = new ethers.Contract(contractAddress, contractABI, provider.getSigner());

const Calendar = (props) => {

    // state for admin and rate
    const [isAdmin, setIsAdmin] = useState(false);
    const [showAdmin, setShowAdmin] = useState(false);
    const [rate, setRate] = useState(false);
    const [appointments, setAppointments] = useState([]);

    const [showDialog, setShowDialog] = useState(false);
    const [showSign, setShowSign] = useState(false);
    const [mined, setMined] = useState(false);
    const [transactionHash, setTransactionHash] = useState("");

    const getData = async () => {
        const owner = await contract.owner();
        console.log(owner.toUpperCase());

        setIsAdmin(owner.toUpperCase() === props.account.toUpperCase());

        console.log(props.account.toUpperCase());

        const rate = await contract.getRate();

        console.log(rate.toString());
        setRate(ethers.utils.formatEther(rate.toString()));

        const appointmentData = await contract.getAppointments();

        console.log('appointments', appointmentData);

        transformAppointmentData(appointmentData);
    }

    const transformAppointmentData = (appointmentData) => {
        let data = [];
        appointmentData.forEach(appointment => {
          data.push({
            title: appointment.title,
            startDate: new Date(appointment.startTime * 1000),
            endDate: new Date(appointment.endTime * 1000),
          });
        });
    
        setAppointments(data);
    }

    useEffect(() => {
        getData();
    }, []);

    const saveAppointment = async (data) => {
        console.log('appointment saved');
        console.log(data);

        const appointment = data.added;
        const title = appointment.title;
        const startTime = appointment.startDate.getTime() / 1000;
        const endTime = appointment.endDate.getTime() / 1000;
      
        setShowSign(true);
        setShowDialog(true);
        setMined(false);

        try {
            const cost = ((endTime - startTime) / 60) * (rate * 100) / 100;
            console.log('cost: ', cost);

            const msg = {value: ethers.utils.parseEther(cost.toString())};
            console.log('message: ', msg);
            let transaction = await contract.createAppointment(title, startTime, endTime, msg);
            
            setShowSign(false);
            
            await transaction.wait();

            setMined(true);
            setTransactionHash(transaction.hash);
        } catch (error) {
            console.log(error);
        }
    }
    
    const saveRate = async () => {
        console.log('saving rate of ', ethers.utils.parseEther(rate.toString()));
        
        const tx = await contract.setRate(ethers.utils.parseEther(rate.toString()));
    }

    const handleSliderChange = (event, newValue) => {
        console.log('slider changed to', newValue);
        setRate(newValue);
    };

    const marks = [
    {
        value: 0.00,
        label: 'Free',
    },
    {
        value: 0.02,
        label: '0.02 ETH/min',
    },
    {
        value: 0.04,
        label: '0.04 ETH/min',
    },
    {
        value: 0.06,
        label: '0.06 ETH/min',
    },
    {
        value: 0.08,
        label: '0.08 ETH/min',
    },
    {
        value: 0.1,
        label: 'Expensive',
    },
    ];

    const Admin = () => {
        return <div id="admin">
            <Box>
                <h3>Set Your Minutely Rate</h3>
                <Slider defaultValue={parseFloat(rate)} 
                    step={0.001} 
                    min={0} 
                    max={.1}
                    marks={marks}
                    valueLabelDisplay="auto"
                    onChangeCommitted={handleSliderChange} />
                <br /><br />
                <Button id={"settings-button"} onClick={saveRate} variant="contained">
                    <SettingsSuggestIcon /> save configuration</Button>
            </Box>
        </div>
    }

    const ConfirmDialog = () => {
        return <Dialog open={true}>
            <h3>
              {mined && 'Appointment Confirmed'}
              {!mined && !showSign && 'Confirming Your Appointment...'}
              {!mined && showSign && 'Please Sign to Confirm'}
            </h3>
            <div style={{textAlign: 'left', padding: '0px 20px 20px 20px'}}>
                {mined && <div>
                  Your appointment has been confirmed and is on the blockchain.<br /><br />
                  <a target="_blank" href={`https://goerli.etherscan.io/tx/${transactionHash}`}>View on Etherscan</a>
                  </div>}
              {!mined && !showSign && <div><p>Please wait while we confirm your appoinment on the blockchain....</p></div>}
              {!mined && showSign && <div><p>Please sign the transaction to confirm your appointment.</p></div>}
            </div>
            <div style={{textAlign: 'center', paddingBottom: '30px'}}>
            {!mined && <CircularProgress />}
            </div>
            {mined && 
            <Button onClick={() => {
                setShowDialog(false);
                getData();
              }
              }>Close</Button>}
          </Dialog>
        }

    return <div>
        {isAdmin && <Admin />}
        <div id="calendar">
            <Scheduler data={appointments}>
                <ViewState />
                <EditingState onCommitChanges={saveAppointment} />
                <IntegratedEditing />
                <WeekView startDayHour={9} endDayHour={19}/>
                <Appointments />
                <AppointmentForm />
        </Scheduler>
        </div>

        {showDialog && <ConfirmDialog />}
    </div>;
}

export default Calendar;