web3 tutorial [07/10] - payable: appointments and payments

by parttimelarry

Appointment Data Structures

Our smart contract can now store rates, but how do we store the appointments? An appointment is a bit more complex since it isn’t a single string or numerical field. Let’s start by discussing each piece of data that makes up an appointment, then group them together into a more complex data type.

Strings - Apointment Title

The first piece of data we need to store is the title of the appointment. This could be “Larry / John 1:1” or “Mario Kart Livestream”. We will represent this as a string just as we would in other programming languages.

Time - Meeting Times

Each meeting will have a startTime and an endTime. But how do we store dates and times in Solidity? There is no native datetime type in Solidity. We will use a unix timestamp, which is the number of second since Unix Epoch (00:00:00 UTC on 1 January 1970). Since this number is a positive integer, we will use a uint for both startTime and endTime.

Integers - Amount Paid

In Calend3, meetings are not free to schedule. Time is money. So to book an appointment, you gotta pay. But how much? We want to allow rates that are less than 1 ETH, but don’t want to deal with decimals. So we will store our money values in wei - the smallest denomination of Ether. There are 1,000,000,000,000,000,000 (10^18) wei in 1 Ether. Solidity lets you write 0.1 ether for simplicity and stores it as 100000000000000000 for you.

Addresses - Attendee

Who are we meeting? We will store the Ethereum address of the person scheduling the appointment. For the purpose of this tutorial, the person scheduling the appointment is the attendee and meetings will be 1:1.

Structs - Grouping Data Together

Notice how all of these appointment fields are grouped together for a single appointment. How do we keep them all together? There’s a data type for that! It’s called a struct. This data type will already be familiar to you if you are a C programmer. A struct lets you create a user-defined complex data type that is a grouping of multiple variables.

struct Appointment {
    string title;     // title of the meeting
    address attendee; // person you are meeting
    uint startTime;   // start time of meeting
    uint endTime;     // end time of the meeting
    uint amountPaid;  // amount paid for the meeting
}

Arrays - A list of appointments

But wait. There’s more. We’re not just going to have one appoinment. We want a fully booked calendar with many appointments so we can rake in $ETH all day. Since we will be storing many appointments, we will use an appointments array to store a list of Appointment structs:

Appointment[] appointments;

Putting it all together, our smart contract now looks like this:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Calend3 {
    uint rate;
    address public owner;

    struct Appointment {
        string title;     // title of the meeting
        address attendee; // person you are meeting
        uint startTime;   // start time of meeting
        uint endTime;     // end time of the meeting
        uint amountPaid;  // amount paid for the meeting
    }

    Appointment[] appointments;

    constructor() {
        owner = msg.sender;
    }

    function getRate() public view returns (uint) {
         return rate;
    }   
    
    function setRate(uint _rate) public {
        require(msg.sender == owner, "Only the owner can set the rate");
        rate = _rate;
    }
}

Appointment Functions

Get Appointment List

Now that we have the data structures in place to store our appointments, we need functions for creating and retrieving them. Let’s start with the function for getting the appointment list:

function getAppointments() public view returns (Appointment[] memory) {
    return appointments;
}

We defined a public function called getAppointments(). It returns the appointments that are stored on the blockchain. We specified Appointment[] as the return data type. Note that Solidity now requires “memory” to be specified here or you will get a compilation error. The memory keyword specifies a temporary place to store data.

Adding an Appointment

Next we need to write a function to create an appointment. This function should have inputs for each appointment field that is entered by the user. These include the title, startTime, and endTime. In the body of the function, we create a new Appointment and set each element of the struct to the corresponding input.

We don’t need to provide an input for the attendee address since the address will just be the msg.sender (the person that called the function). To calculate the amount paid, we first calculate the number of minutes by subtracting the meeting startTime from the endTime (in seconds) and dividing by that number by 60 (the number of seconds in a minute). Then we just multiply the number of minutes by the rate that was set by the calendar owner.

Once we have our Appointment struct filled with data, we use the push() function to append it to the appointments array.

function createAppointment(string memory title, uint startTime, uint endTime) public {
    Appointment memory appointment;
    appointment.title = title;
    appointment.startTime = startTime;
    appointment.endTime = endTime;
    appointment.amountPaid = ((endTme - startTime) / 60) * rate; 
    appointment.attendee = msg.sender; // address of person calling contract

    appointments.push(appointment);
}

Putting it all together, your smart contract should now look like this:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Calend3 {
    uint rate;
    address public owner;

    struct Appointment {
        string title;     // title of the meeting
        address attendee; // person you are meeting
        uint startTime;   // start time of meeting
        uint endTime;     // end time of the meeting
        uint amountPaid;  // amount paid for the meeting
    }

    Appointment[] appointments;

    constructor() {
        owner = msg.sender;
    }

    function getRate() public view returns (uint) {
         return rate;
    }   
    
    function setRate(uint _rate) public {
        require(msg.sender == owner, "Only the owner can set the rate");
        rate = _rate;
    }

    function getAppointments() public view returns (Appointment[] memory) {
        return appointments;
    }

    function createAppointment(string memory title, uint startTime, uint endTime) public {
        Appointment memory appointment;
        appointment.title = title;
        appointment.startTime = startTime;
        appointment.endTime = endTime;
        appointment.amountPaid = ((endTime - startTime) / 60) * rate; 
        appointment.attendee = msg.sender; // address of person calling contract

        appointments.push(appointment);
    }
}

Let’s Test Again (Like We Did Last Summer)

Now that we’ve added data types and functions for managing appointments, we should probably run some tests to make sure it works. Let’s write a test to add a couple of appointments and retrieve them from storage.

Reusing deployment code with beforeEach()

We will be creating many standalone tests that each require the same logic for contract deployment. Rather than repeating these lines of code for each test, we can use befortEach() to run the same block of code before each test. Let’s refactor our test.js to look like the code snippet below. Notice how we can separate our permission test and give it its own description.

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Calend3", function () {
  let Contract, contract;
  let owner, addr1, addr2;

  // `beforeEach` will run before each test
  beforeEach(async function () {
    [owner, addr1, addr2] = await ethers.getSigners();

    Contract = await ethers.getContractFactory("Calend3");
    contract = await Contract.deploy();
    await contract.deployed();
  });

  it("Should set rate", async function () {
    const tx = await contract.setRate(1000);

    // wait until the transaction is mined
    await tx.wait();

    // verify rate is set correctly
    expect(await contract.getRate()).to.equal(1000);
  });

  it("Should fail if non-owner rate", async function () {
    // call setRate using a different account address
    // this should fail since this address is not the owner
    await expect(
      contract.connect(addr1).setRate(500))
      .to.be.revertedWith('Only the owner can set the rate');
  });
});

Testing Appointment Functions

Now that we have done this refactoring, let’s write a test that creates a couple of appointments. We will call contract.createAppointment() twice with different inputs, then call contract.getAppointments() and verify that two appointments are returned.

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Calend3", function () {
  let Contract, contract;
  let owner, addr1, addr2;

  // `beforeEach` will run before each test
  beforeEach(async function () {
    [owner, addr1, addr2] = await ethers.getSigners();

    Contract = await ethers.getContractFactory("Calend3");
    contract = await Contract.deploy();
    await contract.deployed();
  });

  it("Should set rate", async function () {
    const tx = await contract.setRate(1000);

    // wait until the transaction is mined
    await tx.wait();

    // verify rate is set correctly
    expect(await contract.getRate()).to.equal(1000);
  });

  it("Should fail if non-owner rate", async function () {
    // call setRate using a different account address
    // this should fail since this address is not the owner
    await expect(
      contract.connect(addr1).setRate(500))
      .to.be.revertedWith('Only the owner can set the rate');
  });

  it("Should add two appointments", async function () {
    const tx = await contract.createAppointment("Meeting with Part Time Larry", 1644143400, 1644150600);
    await tx.wait();

    const tx2 = await contract.createAppointment("Breakfast at Tiffany's", 1644154200, 1644159600);
    await tx2.wait();

    const appointments = await contract.getAppointments();

    expect(appointments.length).to.equal(2);
  });
});

How are we gonna pay for this?

So we stored a rate and an amountPaid, but no money actually exchanged hands. How do we add payments? One of the nice things about web3 applications it that we have payment functionality built-in already, we just need to use it. We don’t need to apply for an API key, make requests to a 3rd party, or ask for anyone’s personal information.

The user simply connects their wallet to our dapp. When they execute our smart contract, we prompt them with the cost of the transaction. They sign or reject the transaction. If they approve, then they are charged Eth. In return, they get their thing. Going back to the vending machine analogy, they input some meeting details and some Ether, and in return they got a meeting on our calendar.

Payable

To actually send the Ether, first we must make the contract owner address payable. This means that the address can accept ether. Then, in the constructor, we cast the msg.sender to address payable.

address payable owner;

constructor() {
    owner = payable(msg.sender); // contract creator can be paid
}

We must make the createAppointment() function payable. When the client/user calls createAppointment, they will need to send a message (msg) with a value. This value is a number and represents an amount of ether. Let’s update createAppointment() to look like this:

function createAppointment(string memory title, uint startTime, uint endTime) public payable {
    Appointment memory appointment;
    appointment.title = title;
    appointment.startTime = startTime;
    appointment.endTime = endTime;
    appointment.amountPaid = ((endTime - startTime) / 60) * rate;
    appointment.attendee = msg.sender; // address of person calling contract

    require(msg.value >= appointment.amountPaid, "We require more ether"); // validate the amount of ETH

    (bool success,) = owner.call{value: msg.value}(""); // send ETH to the owner
    require(success, "Failed to send Ether");

    appointments.push(appointment);
}

Notice that we require() msg.value to be greater than the amount paid. If the user tries call this contract with an amount of Ether that is too low for the length of the appointment, then we will fail with the error message “We require more ether”.

If they send the correct amount, we can go ahead and send the ether value to the owner of the contract using owner.call{}. We will use an additional require() to make sure this was successful. If not, we will roll back the transaction.

If all succeeded, than we can proceed and add the appointment to our Appointment[] array and store it on the blockchain. We could add some additional error checking to this function, but I think this is a good stopping point to do some testing.

Testing Ether Transfer Between Accounts

Now that our payment logic is in place and being enforced, let’s update our createAppointment() test in test.js to make sure we can successfully send ether.

Also, remember how hardhat gives us 20 test accounts? Let’s have multiple users create appointments. We’ll use connect() to call createAppointment() from different test accounts.

it("Should add two appointments", async function () {
  const tx1 = await contract.setRate(ethers.utils.parseEther("0.001"));
  await tx1.wait();
  
  const tx2 = await contract.connect(addr1).createAppointment("Meeting with Part Time Larry", 1644143400, 1644150600, {value: ethers.utils.parseEther("2")});
  await tx2.wait();

  const tx3 = await contract.connect(addr2).createAppointment("Breakfast at Tiffany's", 1644154200, 1644159600, {value: ethers.utils.parseEther("1.5")});
  await tx3.wait();

  const appointments = await contract.getAppointments();
  expect(appointments.length).to.equal(2);
});

Let’s also make sure that the ether balances for addr1 and addr2 go down when they pay for an appointment, and that the ether balance of the contract owner goes up!

  it("Should add two appointments", async function () {
    const tx1 = await contract.setRate(ethers.utils.parseEther("0.001"));
    await tx1.wait();

    const tx2 = await contract.connect(addr1).createAppointment("Meeting with Part Time Larry", 1644143400, 1644150600, {value: ethers.utils.parseEther("2")});
    await tx2.wait();

    const tx3 = await contract.connect(addr2).createAppointment("Breakfast at Tiffany's", 1644154200, 1644159600, {value: ethers.utils.parseEther("1.5")});
    await tx3.wait();

    const appointments = await contract.getAppointments();

    expect(appointments.length).to.equal(2);

    const ownerBalance = await ethers.provider.getBalance(owner.address);
    const addr1Balance = await ethers.provider.getBalance(addr1.address);
    const addr2Balance= await ethers.provider.getBalance(addr2.address);

    console.log(ownerBalance);
    console.log(addr1Balance);
    console.log(addr2Balance);
  });

If all goes well, you should see output like the following when you run npx hardhat test:

npx hardhat test
  calend3
    ✓ Should set the minutely rate
    ✓ Should fail if non-owner sets rate
BigNumber { value: "10003495662956532660632" }
BigNumber { value: "9997999749063762602628" }
BigNumber { value: "9998499818227205344040" }

Looking pretty good so far. Feel free to write more tests to verify the account values and test any edge cases. I’m going to continue on to other topics so that I can finish writing this tutorial :). In the next section, we will create the UI for scheduling appointments using a Material UI calendar component.