How to Build an Onchain Accountability App

How to Build an Onchain Accountability App

Building an on-chain accountability app is a fun and creative way to leverage web3 technologies. In this tutorial, we'll walk through the process of creating a goal-setting application where users can deposit funds into a smart contract, set tasks to achieve their goals, and only withdraw their funds once all tasks are completed.

To get started, check out the full tutorial video:

Github repo:

GitHub - thirdweb-example/youtube-accountability-app
Contribute to thirdweb-example/youtube-accountability-app development by creating an account on GitHub.

Prerequisites

Before we begin, make sure you have the following:

  • A thirdweb account
  • A wallet like MetaMask to connect to thirdweb
  • Some test funds on a testnet of choice

Step 1: Create the Smart Contract

First, let's create the smart contract that will handle the on-chain logic for our app. We'll be creating this contract from scratch with Solidity.

  1. Create a new contract by running npx thirdweb create contract in your command line and select Hardhat or Forge as your framework.
  2. Go to the Solidity file created.
💡
If using Hardhat this will be located in the contracts folder. If using Forge this will be located in the src folder.
  1. Add the following code:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;

contract AccountabilityContract {
    struct Task {
        string description;
        bool isCompleted;
    }

    Task[] public tasks;
    address public owner;

    modifier onlyOwner() {
        require(msg.sender == owner, "Only the owner can call this function");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function createTask(string memory _description) public onlyOwner {
        tasks.push(Task(_description, false));
    }

    function depositFunds() public payable onlyOwner {
        require(msg.value > 0, "You need to send some ether");
    }

    function withdrawDepositSafely() public onlyOwner {
        uint256 amount = address(this).balance;
        require(amount > 0, "There are no funds to withdraw");
        payable(owner).transfer(amount);
    }

    function allTasksCompleted() private view returns (bool) {
        for (uint256 i = 0; i < tasks.length; i++) {
            if (!tasks[i].isCompleted) {
                return false;
            }
        }
        return true;
    }

    function clearTasks() private {
        delete tasks;
    }

    function completeTask(uint256 _taskId) public onlyOwner {
        require(_taskId < tasks.length, "Task does not exist");
        require(!tasks[_taskId].isCompleted, "Task is already completed");

        tasks[_taskId].isCompleted = true;

        if(allTasksCompleted()) {
            uint256 amount = address(this).balance;
            payable(owner).transfer(amount);
            clearTasks();
        }
    }

    function getTaskCount() public view returns (uint256) {
        return tasks.length;
    }

    function getDeposit() public view returns (uint256) {
        return address(this).balance;
    }

    function getTasks() public view returns (Task[] memory) {
        return tasks;
    }
}

Now that we have our smart contract created to handle our onchain functionality of our accountability app we can deploy it to any EVM blockchain.

Step 2: Deploy the Smart Contract

Next, we need to deploy the smart contract that will power our accountability app.

  1. Run the command npx thirdweb deploy to deploy your smart contract
  2. Select the chain you want to deploy your contract to and click 'Deploy Now'

Once deployed, save the contract address somewhere as we'll need it later.

Step 3: Set Up the Next.js Project and install Connect SDK

Next, let's set up our Next.js project:

  1. Open a terminal and run: npx thirdweb create app
  2. Select Next.js as the framework
  3. Open project in code editor
  4. Edit the .env file and add your CLIENT_ID
  5. Define your chain. In this example we'll be using Base Sepolia Testnet
import { defineChain } from "thirdweb";
import { baseSepolia } from "thirdweb/chains";

export const chain = defineChain( baseSepolia );

Step 4: Add a way to connect a wallet to app

Next, let's create a way for a user to connect a web3 wallet to our accountability app.

  1. In page.tsx we can add a ConnectEmbed component from Connect SDK
<div>
  <ConnectEmbed 
    client={client}
    chain={chain}
  />
</div>
  1. We can then check to see if a wallet is connected and if there is we can show a connected ConnectButton
const account = useActiveAccount();

if(account) {
  return (
    <div>
    	<ConnectButton
          client={client}
          chain={chain}
         />
    </div>
  )
}

Now we have a way for our user to connect and disconnect a wallet from our accountability app.

Step 5: Build accountability app

Now, lets get some data from our smart contract and create our accountability app to interact with.

  1. Create a new file contract.ts in a utils folder
  2. Get our accountability smart contract using getContract
const contractAddress = "<Contract_Address>";

export const contract = getContract({
    client: client,
    chain: chain,
    address: contractAddress,
    abi: contractABI
});
  1. Create a new file for the accountability smart contract ABI
💡
You can find your smart contract ABI in the contract dashboard in the "source" tab under the ABI dropdown
export const contractABI = ["Contract_ABI"] as const;
  1. Next, let's get the the deposit amount and task count from our smart contract
const { data: depositAmount } = useReadContract({
    contract: contract,
    method: "getDeposit",
});

const { data: taskCount } = useReadContract({
    contract: contract,
    method: "getTaskCount"
});
  1. Check to see if the user should deposit funds, create first task, or view their task list based on the deposit amount and task count
<div>
    {depositAmount?.toString() === "0" && taskCount?.toString() === "0" ? (
        <></>
    ) : depositAmount?.toString() !== "0" && taskCount?.toString() === "0" ? (
        <></>
    ) : (
        <></>
    )} 
</div>
  1. Create the component that will let a user deposit an amount of funds into the smart contract to hold until tasks are completed
export const Deposit = () => {
    const [depositAmount, setDepositAmount] = useState(0);
    
    return (
        <div>
            <h3>Deposit</h3>
            <p>Please deposit the funds to hold.</p>
            <input 
                type="number" 
                value={depositAmount}
                onChange={(e) => setDepositAmount(Number(e.target.value))}
                placeholder="0.0"
                step={0.01}
            />
            <TransactionButton
                transaction={() => (
                    prepareContractCall({
                        contract: contract,
                        method: "despositFunds",
                        value: toWei(depositAmount.toString())
                    })
                )}
                onTransactionConfirmed={() => alert("Deposit successful!")}
            >Deposit Funds</TransactionButton>
        </div>
    )
};
  1. Next, we can create our task list. If there are no task then we should prompt a user to enter their first task.
export const TaskList = () => {
    const [task, setTask] = useState("");

    const {
        data: tasks,
        isLoading: isLoadingTasks,
    } = useReadContract({
        contract: contract,
        method: "getTasks"
    });
    
    return (
        <div style={{ marginTop: "50px"}}>
            {!isLoadingTasks && tasks!.length > 0 ? (
                tasks?.map((task, index) => (
                    <TaskCard
                        key={index}
                        taskId={index}
                        task={task.description}
                        isCompleted={task.isCompleted}
                    />
                ))
            ) : (
                <div>
                    <h3>Create Task</h3>
                    <p>Please create the first task to complete.</p>
                    <input 
                        type="text" 
                        value={task}
                        onChange={(e) => setTask(e.target.value)}
                        placeholder="Enter task..."
                    />
                    <TransactionButton
                        transaction={() => (
                            prepareContractCall({
                                contract: contract,
                                method: "createTask",
                                params: [task]
                            })
                        )}
                        onTransactionConfirmed={() => {
                            setTask("");
                            alert("Task created successfully!");
                        }}
                    >Add Task</TransactionButton>
                </div>
            )}
        </div>
    )
};
  1. Create a TaskCard component that will show the data of a task along with a button to complete the task and mark it completed onchain
type TaskProps = {
    taskId: number;
    task: string;
    isCompleted: boolean;
};

export const TaskCard = ({ taskId, task, isCompleted }: TaskProps) => {
    return(
        <div>
            <p style={{ fontSize: "12px" }}>{task}</p>
            {isCompleted ? (
                <p>Done!</p>
            ) : (
                <TransactionButton
                    transaction={() => (
                        prepareContractCall({
                            contract: contract,
                            method: "completeTask",
                            params: [BigInt(taskId)]
                        })
                    )}
                    onTransactionConfirmed={() => alert("Task completed!")}
                >Complete Task</TransactionButton>
            )}
        </div>
    )
};
  1. Finally, let's create a component to add a new task to our task list
export const AddTask = () => {
    const [isModalOpen, setIsModalOpen] = useState(false);
    const [task, setTask] = useState("");

    return (
        <div>
            <button
                onClick={() => setIsModalOpen(true)}
            >Add Task</button>
            {isModalOpen && (
                <div>
                    <div>
                        <button
                            onClick={() => setIsModalOpen(false)}
                        >Close</button>
                        <p>Enter task description:</p>
                        <input 
                            type="text" 
                            value={task}
                            onChange={(e) => setTask(e.target.value)}
                            placeholder="Enter task..."
                        />
                        <TransactionButton
                            transaction={() => (
                                prepareContractCall({
                                    contract: contract,
                                    method: "createTask",
                                    params: [task]
                                })
                            )}
                            onTransactionConfirmed={() => {
                                setIsModalOpen(false);
                                setTask("");
                                alert("Task created successfully!");
                            }}
                        >Add Task</TransactionButton>
                    </div>
                </div>
            )}
        </div>
    )
};

Conclusion

And there you have it! We've built a web3 accountability app that allows users to:

  1. Connect their wallet
  2. Deposit any amount of funds to hold in the contract
  3. Create tasks to be completed
  4. Receive funds back once all tasks are completed

By using thirdweb's Connect SDK, we were able to easily interact with our smart contract to read data and make transactions.

You can build an app like this on any EVM-compatible blockchain, including popular L2s like Optimism, Base, Arbitrum, Avalanche and more.

The thirdweb Connect SDK provides a simple and powerful way to build web3 apps with a great developer experience.

I hope you enjoyed this tutorial and found it valuable!