Building a Decentralized Todo List Application on Ethereum

Building a Decentralized Todo List Application on Ethereum

Welcome to the exciting world of decentralized applications (dApps) on the Ethereum blockchain! In this step-by-step guide, we'll walk through the process of creating a decentralized todo list application using the Hardhat development framework.

We will cover interesting topics like setting up your development environment, writing a Solidity smart contract, testing it, and deploying it to the Sepolia testnet. Code along for better understanding!

Prerequisites

Before we dive in, ensure you have the following tools and prerequisites in place:

  • Node

  • Hardhat: JavaScript framework to interact with the Blockchain.

  • Metamask: Install Metamaks and obtain your private key. Configure Metamask to connect to the Sepolia network.

  • Alchemy: Get your alchemy HTTP endpoint for the Sepolia testnet. Here is a guide on how to set it up.

  • Test Sepolia ETH: Request for some Sepolia ETH from the faucet.

Setting up our environment

Now that we've gathered our tools, it's time to set up our development environment. Here's a step-by-step guide:

  • Create a new project directory for your application todolist.
mkdir todolist
cd todolist
npm init -y
npm install --save-dev hardhat
  • Initialize your Hardhat project by running:
npx hardhat init

Choose the option to create a JavaScript project, and accept the default options. Hardhat will generate a sample project and install the necessary dependencies for you.

  • Open your project folder in your preferred code editor. If you use Visual Studio Code, simply run:
code .
  • To keep sensitive information like your Metamask private key and Alchemy RPC URL secure, create a .env file in your project directory and store your keys there in the format below:

  • Install the dotenv package, which will help us work with environment variables:

npm i dotenv
  • Modify your Hardhat configuration file (usually named hardhat.config.js) to recognize the keys from your .env file:
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();

module.exports = {
  solidity: "0.8.19",

  networks: {
    sepolia: {
      url: process.env.ALCHEMY_API_KEY_URL,
      accounts: [process.env.PRIVATE_KEY],
    },
  },
};

Your environment is now ready to perform some magic on the Ethereum blockchain!

Building our Contract

Let's delve into the heart of our todo list application by writing a Solidity smart contract. In the contracts folder, you will find a default 'Lock.sol' file. Begin by locating the 'Lock.sol' file in the 'contracts' folder and rename it to 'TodoList.sol' to align with the name of our contract.

Below is the ‘TodoList’ contract, along with comments to explain what each block of code is doing:

// SPDX-License-Identifier: MIT

// Solidity Version
pragma solidity 0.8.19;

contract TodoList {
    // Struct to represent a task
    struct Task {
        uint id; // Unique task identifier
        uint date; // Timestamp of task creation
        string name; // Task name
        string description; // Task description
        bool isCompleted; // Flag indicating task completion status
        address owner; // Address of the task owner
    }

    // Array to store all tasks
    Task[] public tasks;

    // Mapping to associate user addresses with their task IDs
    mapping(address => uint[]) private userTasks;

    // Constructor function
    constructor() {}

    // Event emitted when a task is created
    event TaskCreated(
        uint id,
        uint date,
        string name,
        string description,
        bool isCompleted,
        address owner
    );

    // Event emitted when a task is marked as completed
    event TaskCompleted(uint id, address owner);

    // Event emitted when a task is deleted
    event TaskDeleted(uint id, address owner);

    // Function to create a new task
    function createTask(string memory name, string memory description) public {
        uint taskId = tasks.length; // Calculate the new task ID
        tasks.push(
            Task(taskId, block.timestamp, name, description, false, msg.sender)
        ); // Create and add the new task to the array
        userTasks[msg.sender].push(taskId); // Update the userTasks mapping
        emit TaskCreated(
            taskId,
            block.timestamp,
            name,
            description,
            false,
            msg.sender
        ); // Emit a TaskCreated event
    }

    // Function to retrieve task details by ID
    function getTask(
        uint id
    )
        public
        view
        returns (uint, uint, string memory, string memory, bool, address)
    {
        // Ensure the task ID is valid
        require(id < tasks.length, "Task ID does not exist"); 
        Task storage task = tasks[id]; // Retrieve the task from storage
        return (
            task.id,
            task.date,
            task.name,
            task.description,
            task.isCompleted,
            task.owner
        ); // Return task details
    }

    // Function to mark a task as completed
    function markTaskCompleted(uint id) public {
        // Ensure the task ID is valid
        require(id < tasks.length, "Task ID does not exist"); 
        Task storage task = tasks[id]; // Retrieve the task from storage
        require(
            task.owner == msg.sender,
            "Only the owner can complete the task"
        );
        // Ensure the task is not already completed
        require(!task.isCompleted, "Task is already completed"); 
        task.isCompleted = true; // Mark the task as completed
        emit TaskCompleted(id, msg.sender); // Emit a TaskCompleted event
    }

    // Function to delete a task
    function deleteTask(uint id) public {
        // Ensure the task ID is valid
        require(id < tasks.length, "Task ID does not exist"); 
        Task storage task = tasks[id]; // Retrieve the task from storage
        // Ensure only the owner can delete the task
        require(task.owner == msg.sender, "Only the owner can delete the task"); 
        emit TaskDeleted(id, msg.sender); // Emit a TaskDeleted event

        // Delete the task by replacing it with the last task in the array 
        // and reducing the array size
        uint lastIndex = tasks.length - 1;
        if (id != lastIndex) {
            Task storage lastTask = tasks[lastIndex];
            tasks[id] = lastTask;
            userTasks[msg.sender][id] = lastIndex;
        }
        tasks.pop();
        userTasks[msg.sender].pop();
    }

    // Function to retrieve all task IDs belonging to the caller
    function getUserTasks() public view returns (uint[] memory) {
        // Return the task IDs associated with the caller's address
        return userTasks[msg.sender]; 
    }
}

Testing our Contract

Testing our contract is essential to guarantee its reliability and functionality to ensure it performs as intended. In an industry prone to hacks and exploits, writing tests is very necessary to make sure we don't leave our contract vulnerable to exploits.

Writing our Test in Mocha

We'll use Mocha for testing, so let's set up our tests. Inside the test folder, rename the Lock.js file to test.js and replace the code with the following:


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

describe("TodoList contract", function () {
  let TodoList;
  let todolist;
  let owner;

  before(async function () {
    [owner] = await ethers.getSigners();

    // Deploy the TodoList contract
    todolist = await ethers.deployContract("TodoList");
    // await TodoList.waitForDeployment();
  });

  it("should create a new task", async function () {
    const taskName = "Sample Task";
    const taskDescription = "This is a sample task description";

    // Create a new task
    await todolist.createTask(taskName, taskDescription);

    // Retrieve the task details
    const [id, date, name, description, isCompleted, taskOwner] =
      await todolist.getTask(0);

    expect(id).to.equal(0);
    expect(name).to.equal(taskName);
    expect(description).to.equal(taskDescription);
    expect(isCompleted).to.equal(false);
    expect(taskOwner).to.equal(owner.address);
  });

  it("should mark a task as completed", async function () {
    // Mark the task at index 0 as completed
    await todolist.markTaskCompleted(0);

    // Retrieve the task details
    const [, , , , isCompleted] = await todolist.getTask(0);

    expect(isCompleted).to.equal(true);
  });

  it("should delete a task", async function () {
    // Create a new task
    await todolist.createTask(
      "Task to be deleted",
      "This task will be deleted"
    );

    // Delete the task at index 1
    await todolist.deleteTask(1);

    // Attempt to retrieve the deleted task (should throw an error)
    let errorOccurred = false;
    try {
      await todolist.getTask(1);
    } catch (error) {
      errorOccurred = true;
    }

    expect(errorOccurred).to.equal(true);
  });

  it("should retrieve the user's tasks", async function () {
    // Create a new task
    await todolist.createTask("User's Task", "This is the user's task");

    // Retrieve the user's tasks
    const userTasks = await todolist.getUserTasks();

    // Expect that there is at least one task
    expect(userTasks.length).to.be.at.least(1);
  });
});

To test our contract, we run the common:

npx hardhat test

The response should look like this:

Deploying our Contract

Now, the thrilling part - deploying our smart contract to the Sepolia network. We'll write a deployment script to make this happen.

Writing our Deployment Script

In the scripts folder, you will find a deploy.js file with some sample code. Replace the JavaScript code with the following:

// Import the ethers library from the Hardhat framework
const { ethers } = require("hardhat");

// Define an asynchronous main function for contract deployment
async function main() {
  // Deploy the contract
  const TodoList = await ethers.deployContract("TodoList");

  // Log message to show deployment in progress
  console.log("Deploying contract.....");

  // Wait for the deployment of the contract to complete
  await TodoList.waitForDeployment();

  // Log the deployment target (contract address) to the console
  console.log(`TodoList deployed to ${TodoList.target}`);
}

// Execute the main function, and handle any errors that may occur
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

To deploy our contract to the Sepolia network, use the command:

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

NB: If you intend to deploy your smart contract to a different network you can easily replace sepolia with the network of your choice.

This should take some seconds as we are deploying to a testnet. You'll receive confirmation of the contract deployment, along with the contract's address.

Now you can experience the excitement of your decentralized todo list coming to life! Go ahead and copy your contract address and verify its presence on the Sepolia Testnet Explorer just like you can do on the Ethereum mainnet. Super Interesting!

Conclusion

You have successfully built and deployed your first dApp to the Ethereum Blockchain. As a next step, I highly recommend the following resources:

Lumos Academy: Lumos Academy by Lumos Labs is a platform dedicated to (aspiring) Web3 developers who are learning Web3 development

Ethereum Development Tutorial: This curated list of community tutorials covers a wide range of Ethereum development topics

Hope you enjoyed the article! If you have any questions or comments, feel free to drop them below or reach out to me on Twitter!