Skip to main content

33 posts tagged with "react"

View All Tags
Go back

Β· 23 min read

Header

Introduction​

In this tutorial, we will be building a dapp for a Dutch auction using Solidity, Hardhat, and React. In a Dutch auction, the price of an item starts high and gradually decreases until a buyer is found. Unlike an English auction, where the price increases with each bid, a Dutch auction can be a more efficient way to sell an item, especially if the seller wants to sell it quickly. We will cover the smart contract and user interface for the dapp and show you how to deploy and interact with the dapp on Celo.

Prerequisites​

We will create a Dutch auction dapp to auction off an NFT. Therefore, we assume that you already have knowledge of how NFTs work. If you are not familiar with NFTs, please complete one of the following tutorials first:

It is recommended that you have a basic understanding of Solidity, JavaScript, and React to follow this tutorial. Familiarity with Hardhat is helpful but not necessary since the tutorial will cover the basics. Having a grasp of these technologies will make it easier to understand and follow along. However, even without prior experience, you can still learn a lot from this tutorial.

Requirements​

To run and test Solidity code for this tutorial, Node.js (version >=16.0) is required. To check if Node.js is installed, run the following command in the terminal:

node -v

If Node.js is not installed or the version is outdated, you can download the latest version from the official website and install it by following the instructions provided. Once Node.js is installed, you can proceed with the tutorial.

We also need to install a wallet in the browser that works with Celo, such as MetaMask.

How Does a Dutch Auction Work?​

In a Dutch auction, the price of the item being auctioned gradually decreases until someone is willing to accept the current price. This differs from a traditional auction, where the price starts low and increases gradually until someone is willing to pay the highest amount.

Here's how a Dutch auction typically works:

  1. The auctioneer sets an initial asking price that is relatively high.
  2. The auctioneer then gradually lowers the price over time until someone is willing to accept the current price.
  3. Anyone who is interested in the item indicates their willingness to purchase it by placing a bid.
  4. Once a bid is placed at the current price, the auction is complete, and the item is purchased at that price.

In a smart contract, instead of manually decreasing the price over a set period of time, we can set a discount rate (discount per second) to achieve the same result. We can calculate the current price of the item using the following formula:

current price = initial price - (discount rate * time elapsed in seconds)

Project Setup​

In this tutorial, we will be creating and testing smart contracts and building a web app using Hardhat and React. Let's start with the setup for both.

Hardhat Setup​

First, create a directory for this tutorial and open it in a code editor like VS Code. To set up the Hardhat project, run the following command in the terminal:

npx hardhat .

After the project setup is complete, remove all files from the contracts and test directories. Next, create three new files, DutchAuction.sol, NFT.sol in the contracts directory, and DutchAuction.js in the test directory. We must also create an .env file to store the private key of the wallet that will be used for contract deployment.

We also need to install some dependencies that will help us in the development process. These dependencies are:

  • @openzeppelin/contracts: provides us with the ERC-721 standard to create NFTs.
  • dotenv: helps us read environment variables like our private key.
  • solidity-coverage: shows us test coverage for our contracts.

To install these dependencies, run the following command in the terminal:

npm install "dotenv" "@openzeppelin/contracts" "solidity-coverage"

Make sure to add the private key for the wallet that you want to use for deployment to the .env file and have some test Celo tokens in the wallet. You can obtain some test tokens from the Alfajores token faucet.

Web App Setup​

To build the web app, we will be using React.js. To create a new React project, open the terminal and run the following command:

npx create-react-app frontend

Now, let's install the packages required for the web app. We need to install the following packages:

  • ethers: This package is used to interact with smart contracts and wallets.
  • react-toastify: This package is used to show notifications on the web app.

To install the packages, run the following command:

# Change directory to `frontend`
cd frontend
# Installing ethers v5.7.2 and react-toastify
npm install "ethers@5.7.2" "react-toastify"
# Change directory back to base directory
cd ..
# PWD - base directory

NFT Contract​

Now that we have set up the project, we can start by writing the NFT smart contract.

First, we will write our NFT contract, with which we are going to mint the NFT that will be auctioned. As the NFT contract is not the focus of this tutorial, simply copy the following code snippet into the NFT.sol file. If you want to learn more about NFTs, you can read the tutorials linked in the Prerequisites section.

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

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract NFT is ERC721URIStorage {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;

constructor() ERC721("GameItem", "ITM") {}

function awardItem(
address player,
string memory tokenURI
) public returns (uint256) {
uint256 newItemId = _tokenIds.current();
_mint(player, newItemId);
_setTokenURI(newItemId, tokenURI);

_tokenIds.increment();
return newItemId;
}
}

When we put the NFT up for auction, we will give permissions to the auction contract to transfer the NFT from our wallet. The next step is to write the auction contract.

Auction Contract​

To create an auction smart contract, we start by opening the DutchAuction.sol file and pasting the following code snippet that defines the smart contract outline and necessary imports:

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

import "@openzeppelin/contracts/token/ERC721/IERC721.sol";

contract DutchAuction {}

Defining Variables​

We need to store some key parameters related to the auction in the contract. For each parameter, we create a variable with the following details:

  • nft: This variable is of type IERC721 and stores the address of the non-fungible token (NFT) contract that will be used to mint and auction the NFT.
  • initialPrice: This variable is of type uint256 sets the initial price of the NFT in ether.
  • discountRate: This variable is of type uint256 sets the discount rate at which the price of the NFT will decrease per unit time.
  • duration: This variable is of type uint256 sets the duration of the auction in days.
  • sold: This variable is of type bool indicates whether the NFT has been sold or not. It is initially set to false.
  • startTime: This variable is of type uint256 stores the timestamp when the auction starts.
  • id: This variable is of type uint256 and stores the ID of the NFT that will be auctioned.
contract DutchAuction {
IERC721 public nft;

uint256 public initialPrice = 50 ether;
uint256 public discountRate = 20 wei;
uint256 public duration = 7 days;
bool public sold = false;

uint256 public startTime;
uint256 public id;

// Constructor
}

Defining Constructor​

In the constructor function, we initialize three variables, namely id, startTime, and nft. We require two arguments to set these variables, _id and _nft. We pass _nft to IERC721 to set up the nft variable, while id is assigned the value of _id. Additionally, we set the value of startTime as the current timestamp at the moment of contract deployment.

    constructor(address _nft, uint256 _id) {
id = _id;
nft = IERC721(_nft);
startTime = block.timestamp;
}
// Calculate current price of the NFT

Get Current Price​

We can calculate the current price of the item being auctioned in the function called getPrice. The formula for the current price is:

current price = initial price - (discount rate * time elapsed in seconds)

Time elapsed can be calculated by subtracting the current timestamp and start time. This will give us the elapsed time in seconds. We can multiply the time elapsed with discountRate and subtract the result from the initial price.

    // Calculate current price of the NFT
function getPrice() public view returns (uint256 price) {
uint256 timePassed = block.timestamp - startTime;
price = initialPrice - (timePassed * discountRate);
}

// buy NFT being auctioned

Buy NFT​

We can write a function called buy that allows anyone to purchase an NFT at the current price. This function first checks whether the auction is still ongoing by verifying whether the current block timestamp is less than the end time of the auction. If the auction is still ongoing, it then calculates the current price of the NFT using the getPrice() function.

Next, the function checks whether the value sent with the transaction is greater than or equal to the calculated price. If this condition is met, the function transfers the NFT from the contract owner to the caller and refunds any excess ETH sent with the transaction. Finally, the function sets the sold variable to true.

    // buy NFT being auctioned
function buy() public payable {
require(block.timestamp < startTime + duration, "Auction ended");

uint256 price = getPrice();
require(msg.value >= price, "ETH < price");

nft.safeTransferFrom(nft.ownerOf(0), msg.sender, id);
uint256 refund = msg.value - price;
if (refund > 0) {
payable(msg.sender).transfer(refund);
}
sold = true;
}

Now that we've completed our smart contract, we can move on to unit testing it.

Unit Tests​

It is considered a good practice to write unit tests for all smart contracts to ensure that they work according to our expectations. In this section, we will write unit tests for our smart contract.

To begin, we import necessary dependencies and create a describe block containing all the tests that we will write. The describe block is a way to group tests together.

const {
days,
} = require("@nomicfoundation/hardhat-network-helpers/dist/src/helpers/time/duration");
const {
loadFixture,
time,
} = require("@nomicfoundation/hardhat-network-helpers");
const { expect } = require("chai");
const hre = require("hardhat");

describe("Dutch Auction", async () => {
const deployFixture = async () => {};
});

We need to define the deployFixture function first. The fixture will deploy both smart contracts and approve the auction contract as an operator for the NFT that is being auctioned. Additionally, we will create two dummy accounts that will be used for testing purposes.

After creating the smart contracts, we will return instances of both the contracts and the two dummy addresses.

const deployFixture = async () => {
const [owner, buyer] = await hre.ethers.getSigners();
let nft;

try {
const NFT = await hre.ethers.getContractFactory("NFT");
nft = await NFT.deploy();

await nft.deployed();
await nft.awardItem(
owner.address,
"https://gist.githubusercontent.com/nikbhintade/97994377f414de00809dad098ca57bf2/raw/117c9db714b13425116c2496ed0b237e7c88d83b/nft.json"
);
} catch (err) {
console.log(err);
}

let dutchauction;

try {
const DutchAuction = await hre.ethers.getContractFactory("DutchAuction");
dutchauction = await DutchAuction.deploy(nft.address, 0);

await dutchauction.deployed();
} catch (err) {
console.log(err);
}

await nft.approve(dutchauction.address, 0);
return { dutchauction, nft, owner, buyer };
};

Our first test will check if the contracts are deployed correctly by verifying that the variables we set from the constructor have the expected values and that the auction contract is approved as an operator for the NFT being auctioned.

it("should deploy contract set using correct parameters", async () => {
const { dutchauction, nft } = await loadFixture(deployFixture);

expect(await dutchauction.id()).to.be.equal(0);
expect(await dutchauction.startTime()).to.be.equal((await time.latest()) - 1);
expect(await dutchauction.nft()).to.be.equal(nft.address);

expect(await nft.getApproved(0)).to.be.equal(dutchauction.address);
});

We have learned about how the contract calculates the current price. Now, we will test if it accurately calculates the current price in the following test:

it("should return the correct price", async () => {
const { dutchauction } = await loadFixture(deployFixture);

const DISCOUNT_RATE = await dutchauction.discountRate();
const INITIAL_PRICE = await dutchauction.initialPrice();

await time.increase(3600 - 1);

const PRICE = hre.ethers.BigNumber.from(3600).mul(DISCOUNT_RATE);

expect(await dutchauction.getPrice()).to.be.equal(INITIAL_PRICE.sub(PRICE));
});

This test ensures that the getPrice() function accurately returns the current price by comparing the expected price, calculated based on the discount rate and initial price, with the actual price returned by the function.

Next, let's test if the buy functionality works as expected. The expected behavior is that if a buyer buys the NFT at the current price, then the NFT will be transferred to the buyer's wallet. To test this, we will check if the Transfer event is emitted from the NFT contract with the correct arguments. The test code is as follows:

it("should allow the buying of the NFT", async () => {
const { dutchauction, nft, buyer } = await loadFixture(deployFixture);

await time.increase(3600 - 1);
const PRICE = await dutchauction.getPrice();

expect(await dutchauction.connect(buyer).buy({ value: PRICE }))
.to.emit(nft, "Transfer")
.withArgs(dutchauction.address, buyer.address, 0);
});

The buy() function also includes a refund option that refunds the buyer if they paid more than the current price. To test this functionality, we will send more ether than the current price and check if the balance of the smart contract is equal to the current price of the NFT, as the remaining amount will be refunded to the buyer. The test code is as follows:

it("should refund the extra amount", async () => {
const { dutchauction, buyer } = await loadFixture(deployFixture);

await time.increase(3600);

await dutchauction.connect(buyer).buy({ value: "999999999999999999999" });
const PRICE = await dutchauction.getPrice();

expect(
await hre.ethers.provider.getBalance(dutchauction.address)
).to.be.equal(hre.ethers.BigNumber.from(PRICE));
});

The above test checks if the extra amount paid is refunded to the buyer by comparing the balance of the smart contract to the current price of the NFT.

Next, let's test if the smart contract restricts the buying of the NFT after the end of the auction. To test this, we will increase the timestamp by 7 days and then call the buy() function. It should revert with the reason "Auction ended". The test code is as follows:

it("should not allow buying after the auction ends", async () => {
const { dutchauction, buyer } = await loadFixture(deployFixture);

await time.increase(days(7));

const PRICE = await dutchauction.getPrice();
await expect(
dutchauction.connect(buyer).buy({ value: PRICE })
).to.revertedWith("Auction ended");
});

We also need to test if the buyer can only buy the NFT at the current price or higher. To test this, we'll pay less than the current price and check if the function reverts with the reason "ETH < price". Here's the code:

it("should not allow buying if less price is paid", async () => {
const { dutchauction, buyer } = await loadFixture(deployFixture);

await time.increase(3600);

const PRICE = await dutchauction.getPrice();
await expect(
dutchauction.connect(buyer).buy({ value: PRICE.sub(1000) })
).to.be.revertedWith("ETH < price");
});

With these tests, we have covered most of the contract. We can now set up our project to see the code coverage. To set up the solidity-coverage plugin, we just need to import it in hardhat.config.js.

require("solidity-coverage");

To run the tests and see the coverage, we can run the following command in the terminal:

npx hardhat coverage

The output of the coverage should look something like this:

Coverage Output

Now that we have written and tested the auction smart contract, let's proceed to deploy both of our smart contracts.

Contract Deployment​

Firstly, we need to add the network configuration for the Alfajores network and import dotenv to hardhat.config.js. The import statement will be at the top of the file, and the Alfajores configuration will go in the module.exports object:

require("@nomicfoundation/hardhat-toolbox");
require("solidity-coverage");
require("dotenv").config();

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: "0.8.17",
defaultNetwork: "hardhat",
networks: {
hardhat: {},
alfajores: {
url: "https://alfajores-forno.celo-testnet.org",
accounts: [process.env.PRIVATE_KEY],
chainId: 44787,
},
celo: {
url: "https://forno.celo.org",
accounts: [process.env.PRIVATE_KEY],
chainId: 42220,
},
},
};

We have configured our project to deploy to the Alfajores testnet. To deploy both of our smart contracts, we need to write a script. Open deploy.js from the scripts directory. Remove all the content from the main function and add the following snippet:

const hre = require("hardhat");

async function main() {
const deployer = await hre.ethers.getSigner();
console.log(deployer.address);

const NFT = await hre.ethers.getContractFactory("NFT");
const nft = await NFT.deploy();
await nft.deployed();

console.log(`NFT contract address is ${nft.address}`);

const mintTxn = await nft.awardItem(
deployer.address,
"https://gist.githubusercontent.com/nikbhintade/97994377f414de00809dad098ca57bf2/raw/137da789cb4ba9710db60439c27e9c36deeee555/nft.json"
);
await mintTxn.wait();

const DutchAuction = await hre.ethers.getContractFactory("DutchAuction");
const dutchAuction = await DutchAuction.deploy(nft.address, 0);
await dutchAuction.deployed();

console.log(`Dutch Auction contract is ${dutchAuction.address}`);

const approvalTxn = await nft.approve(dutchAuction.address, 0);
await approvalTxn.wait();
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

In this script, we are performing the same steps as we did when creating a fixture for our unit tests. Firstly, we deploy the NFT contract and mint an NFT with the contract deployer's wallet as the owner. Next, we deploy the auction contract with the correct parameters, and finally, we approve the auction contract as an operator for the minted NFT.

With the setup for contract deployment completed, you can run the following command in the terminal to deploy the contracts:

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

You will see an output similar to the following:

Deployment output

Creating The Web App​

As we have tested and deployed our smart contract, we can proceed with building the web app. To create the web app, we need the contract ABI. We have generated the ABI while deployed contracts. Copy the DutchAuction.json and NFT.json from the artifacts/contracts/DutchAuction.sol and artifacts/contracts/NFT.sol directory to frontend/src.

To start building the web app, open the App.js file in the frontend/src directory and paste the following code snippet. It creates the basic structure of the web app:

import "./App.css";
import DutchAuction from "./DutchAuction.json";
import NFT from "./NFT.json";

import { useEffect, useState } from "react";
import { ethers } from "ethers";
import { ToastContainer, toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";

const CONTRACTADDRESS = "0x60b9752bF3e616f4Aa40e12cdB6B615fe5e14807";

function App() {
const [state, setState] = useState({
contract: undefined,
address: "",
metadata: {},
sold: false,
});
const [price, setPrice] = useState();

// connect to wallet
const connect = async () => {};

const buy = async () => {};

return (
<div className="App">
<nav>
<p>Dutch Auction</p>
<button onClick={connect}>
{state.address ? `${state.address}` : "Connect"}
</button>
</nav>

{state.address ? (
<div className="container">
<div>
<img
id="nft"
src={state.metadata.image}
alt={state.metadata.description}
/>
</div>

<div className="buy">
<h2>{state.metadata.name}</h2>
<h4>{price}</h4>
<button onClick={buy} disabled={state.sold}>
{state.sold ? "NFT Sold" : "Buy Now!"}
</button>
</div>
</div>
) : (
<h2>Not connected</h2>
)}

<ToastContainer
position="top-right"
autoClose={3000}
hideProgressBar={false}
newestOnTop={false}
closeButton={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="dark"
/>
</div>
);
}

export default App;

In the web app, our primary focus is on the logic rather than the structure and styling. The App component handles most of the application's functionality. Let's take a closer look at what it does. At the top, we import various libraries, each of which serves a specific purpose:

  • ethers: a library used to interact with contracts and wallets
  • useState: a hook that allows us to track state in a function component
  • useEffect: a hook that allows us to perform side effects in our components
  • ToastContainer: a notification component
  • toast: a function used to trigger notifications

We also declare a variable called CONTRACTADDRESS, which stores the auction contract address obtained from deploying the contracts.

Within the App component, we use the useState hook to store a variety of data, including an object that stores an instance of the contract, the user's address, the metadata of the NFT being auctioned, and the NFT's status to determine whether it has been sold. We also use useState to store the current price of the NFT. Additionally, the component contains functions that handle wallet and contract interactions, including:

  • connect: handles wallet connection and sets the initial state of the web app
  • buy: allows users to buy the NFT being auctioned if they wish to do so
  • getPrice: retrieves the current price of the NFT

The rest of the web app consists of a simple HTML structure that uses ternary operators to display different components to the user based on the application's state.

Connecting Wallet​

The connect function is responsible for connecting a wallet to a web application and retrieving information related to a Dutch auction smart contract and an NFT being auctioned. The function begins by checking if a wallet is installed, and if one is not found, it returns an error message. However, if a wallet is detected, the function uses the Web3Provider from the ethers.js library to connect to the wallet and retrieve the associated account. It then uses this account to create a signer object and instantiate an auction contract object with the signer, address of the contract, and the ABI of the contract.

The function retrieves information from the sold, nft, and id variables of the auction smart contract. After retrieving this information, it uses the nft and id variables to get metadata information from the NFT contract. Finally, it stores all the necessary data in the state using the setState function. If an error occurs during any of these steps, the function displays an error message using the toast library.

const connect = async () => {
try {
const { ethereum } = window;
if (!ethereum) {
console.error("Install any Celo wallet");
return toast.error("No wallet installed!");
}

const provider = new ethers.providers.Web3Provider(ethereum);
const accounts = await provider.send("eth_requestAccounts", []);
const account = accounts[0];

const signer = provider.getSigner();
const auction = new ethers.Contract(
CONTRACTADDRESS,
DutchAuction.abi,
signer
);

const [sold, nftAddress, id] = await Promise.all([
auction.sold(),
auction.nft(),
auction.id(),
]);

const nft = new ethers.Contract(nftAddress, NFT.abi, signer);
const metadata = await fetch(await nft.tokenURI(id)).then((res) =>
res.json()
);

setState({
contract: auction,
address: `${account.slice(0, 5)}...${account.slice(-5)}`,
metadata,
sold,
});
} catch (error) {
toast.error(error.reason);
}
};

We can write a buy function that enables users to purchase an NFT from an auction at its current price. The function begins by retrieving the current price of the NFT and invoking the smart contract's buy function with the appropriate amount. If the transaction is successful, a toast notification will appear. We then call the function again to check the NFT's sold status and save it in the application's state. In case of any errors during any of these steps, the function employs the toast library to display an error message.

const buy = async () => {
try {
const _price = await state.contract.getPrice();
const buyTxn = await state.contract.buy({
value: ethers.utils.parseEther(_price),
});
await toast.promise(buyTxn.wait(), {
pending: "transaction executing",
success: "NFT bought",
});
const isSold = await state.contract.sold();
setState({ ...state, sold: isSold });
} catch (error) {
console.error(error);
toast.error(error.data?.message || error.message);
}
};

To ensure that the price is consistently updated, we will use the useEffect hook and create a getPrice function. We will make state.contract a dependency of the useEffect hook to ensure that it is triggered whenever the state changes.

In the useEffect hook, we will first define the getPrice function. This function will call the getPrice function of the smart contract and update the price in the state. To continually update the price, we will use the setInterval method to call the getPrice function every 5 seconds.

Finally, we will include a clearInterval method in the return statement to clear the interval.

useEffect(() => {
const getPrice = async () => {
console.log("hit");
if (state.contract !== undefined) {
try {
const price = await state.contract.getPrice();
setPrice(ethers.utils.formatEther(price));
} catch (e) {
console.log(e);
}
}
};
let interval;
if (state.contract !== undefined) {
interval = setInterval(() => {
getPrice();
}, 5000);
}

return () => {
if (interval) {
clearInterval(interval);
}
};
}, [state.contract]);

We have finished implementing the functionality of our application. To add styling to our application, please copy and paste the following CSS code into the app.css file:

* {
font-size: 14px;
}

body {
margin: 0;
font-family: "Martel", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #c5d0dd;
}

.App {
text-align: center;
}

nav {
font-family: "Karla";
font-weight: 700;
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 30px;
}
nav > p {
font-size: 25px;
font-weight: 700;
}

button {
background-color: #fda5a4;
font-family: "Karla";
padding: 10px 30px;
border-style: none;
font-weight: 700;
cursor: pointer;
}

.container {
display: flex;
flex-direction: row;
gap: 5%;
padding: 5% 10%;
}
.buy {
padding-top: 5%;
text-align: left;
}

#nft {
width: 75%;
}

h2 {
font-family: "Karla";
font-weight: 700;
font-size: 25px;
}

Running a Web App​

To run a web app on your machine, enter the following commands in the terminal:

# change directory to frontend
cd frontend
# run development server
npm start
# or run `yarn start`

This should open a tab in the browser, allowing you to interact with your smart contract. Make sure to switch network in your wallet to the Alfajores testnet.

Demo App​

You can check out the demo app at this link. Make sure to switch network in your wallet to the Alfajores testnet.

Conclusion​

In conclusion, we have successfully developed a Dutch auction dapp that enables us to auction our NFT. Throughout this tutorial, we have learned how to implement Dutch auction logic in our smart contract and how to interact with the NFT using contract interface.

Furthermore, we have built a web app to interact with our auction contract, with a focus on the app's logic and functions rather than its structure and styling. The web app features an easy-to-use interface that allows users to connect their wallets and purchase the NFT.

Overall, we have gained a better understanding of how Dutch auctions work and how to implement them in a smart contract. We also learned how to test and deploy a smart contract, as well as build a web app to interact with it. We hope that this tutorial has been helpful and provided a good introduction to Dutch auctions.

Next Steps​

Congratulations on completing this tutorial! Making it to the end is no small feat. Share your outstanding work on Celo Discord so that everyone can admire it.

Now that you have a solid understanding of how Dutch auctions work, it's important to note that there is a limitation in our dapp: it only allows one NFT to be auctioned at a time. As a next step, you can create a smart contract that enables different users to auction NFTs from different NFT contracts in one smart contract. You can then write a web app to interact with the new contract.

Alternatively, you can start small by allowing different users to auction NFTs from the same contract and implement a function that enables users to add NFTs for auction.

About the Author​

Nikhil Bhintade is a tech-savvy product manager and technical writer, always eager to discover the latest and greatest in the tech world. With a sharp mind for all things technical, he is constantly exploring new ideas and looking for ways to push the boundaries of what's possible with technical content and products.

When he's not crafting compelling product stories and technical documents, you can catch him tinkering on his latest projects on GitHub. And if you're looking for a tech industry insider to connect with, he's always up for a chat on LinkedIn, where he stays on top of the latest trends and developments.

References​

Go back

Β· 14 min read
Israel Okunaya

header

Introduction​

In this tutorial, I will take you through setting up a wallet connection in your Dapp using Rainbowkit-celo. RainbowKit is a React library that makes it easy to add a wallet connection to your dapp. It's intuitive, responsive, and customizable. Rainbowkit-celo is a plugin to help rainbowkit developers support the CELO protocol faster. It includes the chain information and the main CELO wallets (Valora, Celo Wallet, Celo Terminal, etc.).

Prerequisites​

For this tutorial, you will need to have some level of knowledge of Javascript, React, tailwindcss, and Solidity.

Requirements​

For this article, you will need:

  • A code editor. VScode is preferable.
  • A chrome browser.
  • A crypto wallet: Valora, Celo wallet, MetaMask
  • Nodejs installed on your computer. If not, download from here

The Smart Contract​

For this tutorial, we will be building a Will/Inheritance smart contract. To build the smart contract and deploy it to the Celo blockchain, we will be using hardhat blockchain. Hardhat is a development environment for Ethereum software. With hardhat, you can write your smart contract, deploy them, run tests, and debug your code.

Building the Will/Inheritance Smart Contract​

First, create a folder where the hardhat project and your Dapp will go. In your terminal, execute these commands.

mkdir inheritance-smartContract
cd inheritance-smartContract

Then in the inheritance-smartContract folder, we will set up a Hardhat project.

mkdir will-contract
cd will-contract
yarn init --yes
yarn add --dev hardhat

If you are using Windows, you must add one more dependency with the code below:

yarn add --dev @nomiclabs/hardhat-waffle hardhat-deploy

In the same directory where you installed the Hardhat project, run:

npx hardhat

Select Create a Javascript Project and follow the steps in the terminal to complete your Hardhat setup.

Once your project is set up, create a new file in the contract folder and name it will.sol.

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.2 <0.9.0;
contract Will {
address private owner;
address private _heir;
bool private isAlive;
event OwnerChanged(address newOwner);
event ChangeHeir(address newHeir);
event IsAliveChanged(bool isAlive);
event FundsTransferred(address recipient, uint256 amount);
constructor() {
owner = msg.sender;
isAlive = true;
}
function changeOwner(address newOwner) public {
require(msg.sender == owner, "Only owners can change owner");
owner = newOwner;
emit OwnerChanged(newOwner);
}
function setHeir(address heir) public {
require(msg.sender == owner, "Only owners can set heir");
_heir = heir;
}
function changeHeir(address newHeir) public {
require(msg.sender == owner, "Only owners can remove a heir");
_heir = newHeir;
emit ChangeHeir(newHeir);
}
function changeIsAlive(bool _isAlive) public {
require(msg.sender == owner, "Only the owner can change the status");
isAlive = _isAlive;
emit IsAliveChanged(_isAlive);
}
function transferFunds(address payable recipient, uint256 amount) public {
require(msg.sender == owner || (msg.sender == _heir && !isAlive), "Only the owner or heir can transfer funds");
require(amount <= address(this).balance, "Insufficient funds");
recipient.transfer(amount);
emit FundsTransferred(recipient, amount);
}

function getOwner() public view returns (address) {
return owner;
}

function getHeir() public view returns (address) {
return _heir;
}

function getIsAlive() public view returns (bool) {
return isAlive;
}

receive() external payable {}
}

In the smart contract code above, the Will contract has an owner, a designated heir, and a status that determines whether the owner is alive. The contract also has four functions:

  • changeOwner() allows the current owner to transfer ownership to a new address.
  • setHeir() allows the current owner to set a designated heir.
  • changeHeir() allows the current owner to change the designated heir.
  • changeIsAlive() allows the current owner to change their status to alive or deceased.
  • transferFunds() allows the owner or the heir (if the owner is deceased) to transfer the funds to a designated recipient.

The contract also has three functions to allow anyone to view the current owner, heir, and status.

The contract also includes several events that get emitted whenever the owner, heir, status, or funds are changed, to allow for easy monitoring of changes to the contract.

Note: This is just a simple smart contract example, and a real-world will would likely have more complex logic to handle edge cases and to ensure that the contract is secure and reliable.

Configure Deployment​

We will deploy the smart contract to the Alfajores network on the Celo blockchain. To do this, we will need to connect to the Alfajores testnet through forno by writing a deployment script. Replace the content of the deploy.js file with the code below to deploy the smart contract.

const { ethers } = require("hardhat");
async function main() {
const willContract = await ethers.getContractFactory("MyWill");
const deployedWillContract = await willContract.deploy();
await deployedWillContract.deployed();
console.log("Will Contract Address:", deployedWillContract.address);
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main()
.then(() => process.exit(0))
.catch((error) => {
console.log(error);
process.exit(1);
});

Now create a .env in the will-contract folder and add your MNEMONIC key in the .env file like this

MNEMONIC=//add your wallet seed phrase here

Now we will install dotenv package to be able to import the env file and use it in our config.

In your terminal pointing to the will-contract folder, enter this command

yarn add dotenv

Next, open the hardhat.config.js file and replace the content of the file provided to us by Hardhat with the configuration code for deployment made available by Celo.

require("@nomiclabs/hardhat-waffle");
require("dotenv").config({ path: ".env" });
require("hardhat-deploy");
// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more
// Prints the Celo accounts associated with the mnemonic in .env
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(account.address);
}
});
/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
defaultNetwork: "alfajores",
networks: {
localhost: {
url: "http://127.0.0.1:7545",
},
alfajores: {
url: "https://alfajores-forno.celo-testnet.org",
accounts: {
mnemonic: process.env.MNEMONIC,
path: "m/44'/52752'/0'/0",
},
//chainId: 44787
},
celo: {
url: "https://forno.celo.org",
accounts: {
mnemonic: process.env.MNEMONIC,
path: "m/44'/52752'/0'/0",
},
chainId: 42220,
},
},
solidity: "0.8.4",
};

Add the gas price and gas to the Alfajores object.

alfajores: {
gasPrice: 200000000000,
gas: 41000000000,
url: "https://alfajores-forno.celo-testnet.org",
accounts: {
mnemonic: process.env.MNEMONIC,
path: "m/44'/52752'/0'/0
}

Check out the Celo doc for more details on what each part of the configuration code does.

The content of the hardhat.config.js file should now look like this.

require("@nomiclabs/hardhat-waffle");
require("dotenv").config({ path: ".env" });
require("hardhat-deploy");
// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more
// Prints the Celo accounts associated with the mnemonic in .env
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(account.address);
}
});
/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
defaultNetwork: "alfajores",
networks: {
localhost: {
url: "http://127.0.0.1:7545",
},
alfajores: {
gasPrice: 200000000000,
gas: 41000000000,
url: "https://alfajores-forno.celo-testnet.org",
accounts: {
mnemonic: process.env.MNEMONIC,
path: "m/44'/52752'/0'/0",
},
//chainId: 44787
},
celo: {
url: "https://forno.celo.org",
accounts: {
mnemonic: process.env.MNEMONIC,
path: "m/44'/52752'/0'/0",
},
chainId: 42220,
},
},
solidity: "0.8.4",
};

Compiling the Contract​

To compile the contract, in your terminal, point to the will-contract folder and run this command.

npx hardhat compile

After the compilation is successful, run the following command in your terminal.

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

Save the Whitelist Contract Address that was printed on your terminal somewhere, because you would need it further down in the tutorial.

The Dapp​

To develop the Dapp, we will be using Reactjs. React is a free and open-source front-end JavaScript library for building user interfaces based on components.

To create a new react-app, in your terminal, point to the inheritance-smartContract folder and type.

mkdir dapp
cd dapp
yarn create react-app

Press enter and allow the react-app to initialize.

You should have a folder structure that should look like this:

image

Now to run the app, execute this command in the terminal

yarn start

Go to http://localhost:3000 to view your running app.

Now we will install tailwindcss for the styling of the Dapp. To install tailwindcss, in your terminal, still pointing to the dapp folder, run.

yarn add --dev tailwindcss

After installation has been completed, run

npx tailwindcss init

npx tailwindcss init creates a tailwind.config.js file in your dapp folder.

in the tailwind.config.js file, copy and paste this code.

/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
};

Then replace the content of the index.css file with the @tailwind directives for each of Tailwind’s layers.

@tailwind base;
@tailwind components;
@tailwind utilities;

Now we will install Rainbowkit-celo. To install Rainbowkit-celo, open up your terminal pointing to the dapp folder and run

yarn add @celo-rainbowkit-celo

The @celo-rainbowkit-celo package has @rainbow-me/rainbowkit as a peer dependency and expects it to be installed too. To install it, run the command in your terminal

yarn add @rainbow-me/rainbowkit wagmi

Next we will install webjs and @celo/contractkit. We will be using the contract kit to interact with the Celo blockchain.

yarn add web3 @celo/contractkit

We will have to make further configurations to our project folder to enable us use web and @celo/contractkit without errors or bugs. These configurations involve installing webpack and other dependencies. To install webpack, type these commands in your terminal. Make sure your terminal still points to the dapp folder you created earlier.

yarn add --dev webpack

After successfully installing webpack, create a webpack.config.js file in the dapp folder and paste the code.

const path = require("path");
module.exports = {
entry: "./src/index.js",
output: {
filename: "main.js",
path: path.resolve(__dirname, "build"),
},
node: {
net: "empty",
},
};

For the other dependencies, paste this in your terminal.

yarn add --dev react-app-rewired crypto-browserify stream-browserify assert stream-http https-browserify os-browserify url buffer process

Create config-overrides.js in the dapp folder with the content:

const webpack = require("webpack");
module.exports = function override(config) {
const fallback = config.resolve.fallback || {};
Object.assign(fallback, {
crypto: require.resolve("crypto-browserify"),
stream: require.resolve("stream-browserify"),
assert: require.resolve("assert"),
http: require.resolve("stream-http"),
https: require.resolve("https-browserify"),
os: require.resolve("os-browserify"),
url: require.resolve("url"),
});
config.resolve.fallback = fallback;
config.plugins = (config.plugins || []).concat([
new webpack.ProvidePlugin({
process: "process/browser",
Buffer: ["buffer", "Buffer"],
}),
]);
return config;
};

In the package.json file, change the scripts field for start, build, and test. Replace react-scripts with react-app-rewired:

"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-scripts eject"
},

Now go to your index.js file in the dapp folder and copy and import the follow dependencies.

import {
RainbowKitProvider,
connectorsForWallets,
} from "@rainbow-me/rainbowkit";
import {
metaMaskWallet,
omniWallet,
walletConnectWallet,
} from "@rainbow-me/rainbowkit/wallets";
import { Valora, CeloWallet, CeloDance } from "@celo/rainbowkit-celo/wallets";
import { configureChains, createClient, WagmiConfig } from "wagmi";
import { jsonRpcProvider } from "wagmi/providers/jsonRpc";
import { Alfajores, Celo } from "@celo/rainbowkit-celo/chains";
import "@rainbow-me/rainbowkit/styles.css";

Below the imports, add the following code.

const { chains, provider } = configureChains(
[Alfajores, Celo],
[
jsonRpcProvider({
rpc: (chain) => ({ http: chain.rpcUrls.default.http[0] }),
}),
]
);
const connectors = connectorsForWallets([
{
groupName: "Recommended with CELO",
wallets: [
Valora({ chains }),
CeloWallet({ chains }),
CeloDance({ chains }),
metaMaskWallet({ chains }),
omniWallet({ chains }),
walletConnectWallet({ chains }),
],
},
]);
const wagmiClient = createClient({
autoConnect: true,
connectors,
provider,
});

Now wrap the <App/> component with the RainbowkitProvider and WagmiConfig

<WagmiConfig client={wagmiClient}>
<RainbowKitProvider chains={chains}>
<App />
</RainbowKitProvider>
</WagmiConfig>

Your index.js file should look like this.

import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import {
RainbowKitProvider,
connectorsForWallets,
} from "@rainbow-me/rainbowkit";
import {
metaMaskWallet,
omniWallet,
walletConnectWallet,
} from "@rainbow-me/rainbowkit/wallets";
import { Valora, CeloWallet, CeloDance } from "@celo/rainbowkit-celo/wallets";
import { configureChains, createClient, WagmiConfig } from "wagmi";
import { jsonRpcProvider } from "wagmi/providers/jsonRpc";
import celoGroups from "@celo/rainbowkit-celo/lists";
import { Alfajores, Celo } from "@celo/rainbowkit-celo/chains";
import "@rainbow-me/rainbowkit/styles.css";
const { chains, provider } = configureChains(
[Alfajores, Celo],
[
jsonRpcProvider({
rpc: (chain) => ({ http: chain.rpcUrls.default.http[0] }),
}),
]
);
const connectors = connectorsForWallets([
{
groupName: "Recommended with CELO",
wallets: [
Valora({ chains }),
CeloWallet({ chains }),
CeloDance({ chains }),
metaMaskWallet({ chains }),
omniWallet({ chains }),
walletConnectWallet({ chains }),
],
},
]);
const wagmiClient = createClient({
autoConnect: true,
connectors,
provider,
});
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<WagmiConfig client={wagmiClient}>
<RainbowKitProvider chains={chains}>
<App />
</RainbowKitProvider>
</WagmiConfig>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

In your App.js file, copy and paste this code

import "./App.css";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import { useSigner } from "wagmi";
import { useState, useEffect } from "react";
import Web3 from "web3";
import { newKitFromWeb3 } from "@celo/contractkit";
import { abi } from "./will.abi";
function App() {
const { data: signer } = useSigner();
const [address, setAddress] = useState("");
const [displayValue, setDisplayValue] = useState();
const willContractAddress = "0x24B1525F0061CA0c91bE9f966c41C652A9a8D383"; //The address of your own smart contract;
useEffect(() => {
const getAddress = async () => {
if (signer) {
const address = await signer.getAddress();
setAddress(address);
}
};
getAddress();
}, [signer]);
if (address) {
console.log(address);
}
console.log(abi);
const getContractKit = () => {
if (signer) {
const web3 = new Web3(window.celo);
const kit = newKitFromWeb3(web3);
return kit;
}
};
const kit = getContractKit();
console.log(kit);
const getOwner = async () => {
const kit = getContractKit();
const contract = new kit.web3.eth.Contract(abi, willContractAddress);
try {
const owner = await contract.methods.getOwner().call();
setDisplayValue(owner);
} catch (error) {
console.log(error);
}
};
const getHeir = async () => {
const kit = getContractKit();
const contract = new kit.web3.eth.Contract(abi, willContractAddress);
try {
const heir = await contract.methods.getHeir().call();
setDisplayValue(heir);
} catch (error) {
console.log(error);
}
};
const getStatus = async () => {
const kit = getContractKit();
const contract = new kit.web3.eth.Contract(abi, willContractAddress);
try {
const status = await contract.methods.getIsAlive().call();
console.log(status);
setDisplayValue(status);
} catch (error) {
console.log(error);
}
};
return (
<div className="flex flex-col items-center justify-center h-screen">
<h1 className="mb-4 font-semibold text-2xl">
Simple Will Smart Contract
</h1>
<ConnectButton />
<div className="mt-8">
<div className="bg-white border border-green-500 border-solid p-4 rounded-lg text-center">
{displayValue ? displayValue : ""}
</div>
<div className="mt-4 grid grid-cols-3 gap-8">
<button className="bg-red-400 py-2 px-4 text-white rounded-lg">
Change Owner
</button>
<button className="bg-red-400 py-2 px-4 text-white rounded-lg">
Set Heir
</button>
<button className="bg-red-400 py-2 px-4 text-white rounded-lg">
Change Heir
</button>
<button className="bg-red-400 py-2 px-4 text-white rounded-lg">
Change Deceased Status
</button>
<button
onClick={getOwner}
className="bg-green-400 py-2 px-4 text-white rounded-lg"
>
Owner
</button>
<button
onClick={getHeir}
className="bg-green-400 py-2 px-4 text-white rounded-lg"
>
Heir
</button>
<button
onClick={getStatus}
className="bg-green-400 py-2 px-4 text-white rounded-lg"
>
Status
</button>
<button className="bg-red-600 py-2 px-4 text-white rounded-lg">
Transfer Inheritance
</button>
</div>
</div>
</div>
);
}
export default App;

In the code above we have a UI that has the ConnectButton provided by @rainbow-me/rainbowkit rendered. This custom button allows us to connect to default crypto wallets provided by Celo and other crypto wallets .that support Celo.

The getOwner(), getHeir() and getStatus() are functions that are called when a user clicks a particular button. These functions connect with the smart contract we created and deployed to call the various methods we created in the smart contract.

Start up the React app by running this command in your terminal to view the Dapp

yarn start

Make sure the terminal points to the dapp folder before running the command.

This is how the Dapp should look when the application opens

image

Conclusion​

So far, we have been able to create a smart contract Dapp using React. We created the UI enabling a user to connect their wallet to the Dapp using Rainbowkit-celo. We also connected with the smart contract we deployed from the Dapp and called some of the methods we created in the smart contract.

Next Step​

If you wish to read more on rainbowkit and Rainbowkit-celo, check out these docs links:

About the Author​

Israel Okunaya is an ace writer with a flair for simplifying complexities and a knack for storytelling. He leverages over four years of experience to meet the most demanding writing needs in different niches, especially food and travel, blockchain, and marketing. He sees blockchain as a fascinating yet tricky affair. So, he's given to simplifying its complexities with text and video tutorials.

Go back

Β· 15 min read

header

Introduction​​

Gas is the unit of currency used to pay transaction fees on the Celo blockchain. This makes optimizing gas consumption an important consideration when developing smart contracts professionally. In this tutorial, we'll explore some techniques for minimizing gas usage in Solidity smart contracts and demonstrate how to use the Hardhat gas reporter to test the efficiency of our code.

Prerequisites​​

To follow this tutorial, you will need:

  • Basic knowledge of Solidity, Ethereum, and smart contracts.

  • A local development environment set up with Node.js and npm, as well as the Hardhat development environment installed. You can follow the Hardhat installation instructions to get set up.

  • An understanding of gas fees and how they are calculated on the Celo network. This knowledge will help us understand the optimizations we will make in this tutorial.

Requirements​

To follow along with this tutorial, you will need the following:

  • Basic knowledge of Solidity programming language and Celo blockchain.

  • Node.js installed on your machine.

  • Hardhat development environment installed. You can install it by running the following command in your terminal:

yarn add hardhat
  • An Integrated Development Environment (IDE) such as Visual Studio Code (VS Code) or any other text editor of your choice.

  • Basic knowledge of the command line interface (CLI) and the ability to use it to run commands.

  • Basic knowledge of JavaScript and familiarity with testing frameworks like Mocha and Chai.

Setting up your environment​

Before we get started, we need to set up our development environment. As we said before, we'll be using hardhat for the major development of the project.

You can clone the starter project here:

git clone https://github.com/JovanMwesigwa/gas-optimisation-in-celo-smart-contracts/tree/starter

Or create a new hardhat project from scratch using the following steps below:

  1. Install hardhat by running:
yarn add hardhat
  1. Scaffold a new hardhat project by running npx hardhat:
$ npx hardhat
888 888 888 888 888
888 888 888 888 888
888 888 888 888 888
8888888888 8888b. 888d888 .d88888 88888b. 8888b. 888888
888 888 "88b 888P" d88" 888 888 "88b "88b 888
888 888 .d888888 888 888 888 888 888 .d888888 888
888 888 888 888 888 Y88b 888 888 888 888 888 Y88b.
888 888 "Y888888 888 "Y88888 888 888 "Y888888 "Y888

πŸ‘· Welcome to Hardhat v2.9.9 πŸ‘·β€

? What do you want to do? ...
❯ Create a JavaScript project
Create a TypeScript project
Create an empty hardhat.config.js
Quit

This will install the necessary packages that we'll use for the whole project.

By default, the gas reporter plugin is disabled in the hardhat.config.js file. So, we need to activate it to be able to monitor the contract's gas consumption.

Open the hardhat.config.js file and add:

require("@nomicfoundation/hardhat-toolbox");

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: "0.8.17",
gasReporter: {
enabled: true,
currency: "USD",
},
};

We set the gasReporter to true to allow hardhat to activate it and set currency to USD.

Writing the smart contract​

Let's write a very basic smart contract that is using inefficient gas-consuming practices. Then we'll work our way back to optimize it and improve it in terms of gas consumption.

Under the contracts folder, create a new contract file name MyContract.sol.

Inside the file add:

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

contract MyContract {
address public owner;
uint256 public joinFee;

uint256 public balances = 0;

address[] public members;

constructor(address adminAddress, uint256 joinFee_) {
owner = adminAddress;
joinFee = joinFee_;
}

function join() public payable {
require(msg.value == joinFee, 'Please pay the join fee to join.');

members.push(msg.sender);

balances += msg.value;
}
}

First, there's an owner address, which is set when the contract is created by passing in an adminAddress as an argument. The joinFee is also set when the contract is created, with the joinFee_ argument.

The join() function is where users can join the contract. When they call this function and send in some Celo tokens (represented by msg.value), the function checks to make sure they've sent in exactly the right amount of tokens by comparing msg.value to joinFee. If the user has sent in the right amount, their address is added to the members array, and their tokens are added to the balances variable.

The contract is pretty simple, but it demonstrates some key concepts of smart contracts: storing and modifying data on the blockchain, and enforcing rules through code.

Logging the gas estimation​

Now that the contract is done, we can compile it. In the terminal run, npx hardhat compile.

To be able to see the contract's gas logs, we need to interact with the contract itself.

To keep the tutorial simple we won't build a front end to interact with the contract. But instead, we'll write tests that will call the contract's functions directly.

Inside the test folder, we have a MyContract.js file that has a prewritten test that will help interact with the contract's functions.

Here's the code:

const {
time,
loadFixture,
} = require("@nomicfoundation/hardhat-network-helpers");
const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs");
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("GasOptimizer", function () {
async function deployContractFixture() {
const [owner, otherAccount] = await ethers.getSigners();
const JOIN_FEE = ethers.utils.parseEther("0.02");

const MyContract = await ethers.getContractFactory("MyContract");
const myContract = await MyContract.deploy(owner.address, JOIN_FEE);

return { myContract, owner, otherAccount, JOIN_FEE };
}

describe("Deployment", function () {
it("Should set the join fee amount", async function () {
const { myContract, JOIN_FEE, owner } = await loadFixture(
deployContractFixture
);
expect(await myContract.joinFee()).to.equal(JOIN_FEE);
});

it("Should set the admin address", async function () {
const { myContract, JOIN_FEE, owner } = await loadFixture(
deployContractFixture
);
const setOwner = await myContract.owner();
expect(setOwner.toString()).to.equal(owner.address);
});
});

describe("Join", function () {
it("Should add the a new member", async function () {
const { JOIN_FEE, otherAccount, myContract, owner } = await loadFixture(
deployContractFixture
);

await myContract.join({ value: JOIN_FEE });

const balance = await myContract.balances();

console.log("Balance: ", balance.toString());
console.log("JOIN FEE: ", JOIN_FEE);

expect(balance.toString()).to.be.equal(JOIN_FEE);
});
});
});

The test function is testing the join() function of the MyContract smart contract.

Here's what it's doing:

  • First, it defines a fixture function deployContractFixture() which deploys the MyContract contract with an owner address and a JOIN_FEE value on the constructor.

  • Then, it defines two tests in a Deployment section to check if the joinFee and owner are correctly set after the contract is deployed.

  • Finally, it defines a test in a Join section to test if a new member can join the contract and if the balances variable is correctly updated. It does this by calling the join() function on the myContract instance with the JOIN_FEE value and then checking the value of balances.

Overall, this test suite ensures that the MyContract contract is correctly deployed and that the join() function can add new members to the contract and update the balances variable.

To run the tests go to the terminal and type:

npx hardhat test

This will run the tests and because we activated the gasReporter plugin in the hardhat.config.js file, hardhat will print out a gas report analysis of the contract.

gas log

The test output shows that all the tests in the script passed successfully. The first two tests in the Deployment section checked that the join fee amount and admin address were set correctly. The output shows that both tests passed.

The last test in the Join section checks that a new member is added when they join the contract by paying the joining fee. The output shows that a new member was added and the expected join fee was received by the contract.

Explaining the gas log​

The MyContract smart contract gas report shows that the join() method was called once during the execution of the test suite. The gas report shows that the join() method consumed 90042 gas units.

When deploying the contract, the report showed that the MyContract smart contract deployment consumed a total of 342538 gas units, which is 1.1% of the gas limit of 30000000set for the test run.

The gas usage report is useful for identifying methods that consume a lot of gas and optimizing them to reduce the gas cost of executing transactions on the smart contract.

Our goal is to improve and reduce the amount of gas consumed by calling join() and the overall gas consumed by the Contract.

Optimizing the contract​

  1. Use private rather than public for contract state constants: Declaring a variable as public in Solidity generates an additional getter function to read its value from outside the contract. This additional getter function can make contract execution more expensive in terms of gas costs. On the other hand, declaring a variable as private eliminates the need for a getter function, and can save gas costs during contract execution.

  2. Use immutable for one-time set variables: Declaring a variable as immutable means that its value is set only once during contract deployment and cannot be changed afterward. This eliminates the need for the storage slot to be writable and saves gas that would otherwise be spent on writing to storage. Additionally, making a variable private and not defining a getter function saves gas that would be spent on creating and executing a getter function.

In MyContract.sol, let's change the state variables to private as shown below:

    address private immutable i_owner;
uint256 private immutable i_joinFee;

uint256 private s_balances = 0;

Because the owner and joinFee are not going to change, it only makes sense to make them immutable while balance will always change when new users join the contract. So it's not immutable by default.

Notice how we prefixed the variable names with i_ at the beginning. By using the i_ prefix, it's easy to identify that the variable is both private and immutable, making the code easier to understand and maintain. Whereas we prefixed balances with s_ because it's a storage that will change often.

Go ahead and update all the new variable names in your functions accordingly.

Making the i_owner, i_joinFee, and s_balances private means we can't access them outside the contract, and yet we may want to read them at some point.

To solve this, we need to create view functions that return those values when called outside the contract.

In your contract add:

    // View functions
function getOwner() public view returns (address) {
return i_owner;
}

function getJoinFee() public view returns (uint256) {
return i_joinFee;
}

function getBalances() public view returns (uint256) {
return s_balances;
}

A view function does not modify the state of the contract and returns some value. It is used to read the state of the contract or to perform calculations based on that state. Since it only reads data and does not modify it, it is a cost-effective way of retrieving data without incurring gas costs.

Let's try and run the gas report to see the effects of our changes:

In the terminal run npx hardhat test

gas report after optimising storage state

Look at that! Our report is better than the previous which spent 90042 gas estimate on the join() method now it's down to 87964. The overall MyContract gas is now 312370 which is a major downgraded from 342538 we got in the first report.

Just by doing the small changes in how we're writing the smart contract, we can see a slight change in the gas report.

We can look at more ways we can improve it further.

  1. Use Custom Errors instead of Revert Strings Custom errors are more efficient than revert strings. When a contract reverts, all state changes made in the current transaction are undone, and gas is refunded. However, when a custom error is used, the state changes made before the error are not undone, and gas is not refunded. This means that using custom errors can save gas costs compared to using revert strings.

In the join() function, we're using require() to revert when a user doesn't enter the required joinFee. This is not very gas efficient as explained above.

To define a custom error to handle verifying the joinFee amount add this line at the top of your contract:

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

error Not__EnoughFeesEntered();

Let's replace the require() use a more gas-optimal way.

Inside join() change:

function join() public payable {

if (msg.value < i_joinFee) {
revert Not__EnoughFeesEntered();
}

The if block checks whether the value sent by the transaction (msg.value) is greater than or equal to the required i_joinFee. If the condition evaluates to false (i.e., the sent value is less than the required fee), then the function execution is reverted with an error message using the custom error Not__EnoughFeesEntered().

Replacing require() with revert which defines a custom error proves to be more gas efficient.

We've optimized the error handling in join() using custom revert errors.

Let's continue to optimize this function further.

We're using an array to push all the members' addresses in the contract.

This is not very gas efficient because when you use an array to store data, the Solidity compiler creates a getter function that returns the entire array, which can become expensive in terms of gas costs as the size of the array grows. This is because the EVM must load the entire array into memory to read a single element, which can be a significant amount of gas. Additionally, when an element is added or removed from the middle of an array, all subsequent elements must be shifted, which also incurs additional gas costs.

The best option here is to use a mapping.

When you use a mapping to store data, the compiler does not create a getter function, and accessing a single element in the mapping is a constant-time operation that always costs the same amount of gas. Additionally, adding or removing an element from a mapping does not require any shifting of data, as each key-value pair in the mapping is stored separately.

To update the contract with a mapping, we need to add a state variable that keeps track of all the members in the contract.

    uint256 private s_count = 0;

mapping(uint256 => address) private members;

Every new user that is added to the contract will be given the current number according to s_count which will use as the key-value pair to access the address directly without looping through the array.

In join() add:

        members[s_count] = msg.sender;

s_balances += msg.value;
s_count += 1;
}

The members mapping is updated by assigning the sender's address msg.sender to the key s_count.

s_balances is then updated by adding the value of funds sent with the transaction to the current balance.

s_count is incremented by 1, to reflect the addition of a new member.

Finally, we can now inspect our optimized gas contract in the reporter.

In the terminal run:

npx hardhat test

Output:

Final Optimized report

Compared to the completely unoptimized contract, the report shows that the join function in the MyContract consumes 88,248 gas for one call.

There is a significant reduction in the gas consumption of the join function. The previous report showed that the function consumed 325,301 of gas per call, while this report shows consumption of only 88,248 gas per call.

Here's the unoptimized report:

unoptimized report

Conclusion​

In conclusion, optimizing gas usage in Solidity smart contracts is essential for reducing transaction costs and improving contract efficiency. By using techniques such as using private instead of public for constants, using view and pure functions, declaring variables with specific data types, and avoiding expensive operations such as loops, developers can significantly reduce gas usage and optimize the contract's performance. With the ever-increasing demand for blockchain-based applications, optimizing gas usage is becoming more critical than ever before, and developers should take it seriously when designing and deploying smart contracts.

Next Steps​

After optimizing gas consumption in your Celo smart contracts with the techniques outlined in this step-by-step guide, there are several next steps you can take to continue improving the efficiency and performance of your contracts. Firstly, you can explore using the Solidity optimizer to further optimize the bytecode of your contracts. Additionally, you can consider implementing more advanced gas optimization techniques, such as using storage arrays instead of memory arrays, using structs to group related data, and leveraging the benefits of function modifiers. You can also explore using gas reporting tools and conducting regular gas audits to monitor the gas usage of your contracts over time. Finally, it's always a good idea to stay up-to-date with the latest developments in the Celo ecosystem and Solidity language to ensure you're taking advantage of the most efficient and effective techniques for optimizing gas consumption in your contracts.

About the Author​​

Created by Jovan Mwesigwa B, Blockchain, Solidity developer

Reach out: Twitter: @unreal_joova Github: JovanMwesigwa Linkedin: JovanMwesigwa

References​​

Hardhat docs | For hardhat set-up Project code | Github

Go back

Β· 11 min read
Oluwafemi Alofe

Introduction​

In this tutorial, we will show you how to create a subscription platform using the Celo composer react-app and the hardhat package. The platform will offer three subscription plans that users can choose from, and payment will be charged monthly in cUSD. We will also use the OpenZeppelin Defender autotask to handle the monthly subscription charges and an email service to notify users of the charge status. By the end of this tutorial, you will have a working subscription platform and the knowledge to customize and build upon it for your own use case.

header

Background Knowledge​

Merkle trees are a fundamental data structure to build blockchain systems. In a merkle tree, there are leaf nodes and non-leaf nodes. Each leaf nodes represent a data element while each non-leaf nodes represent the hash of its child nodes. There is also the Merkle root which is the hash of the entire tree. It also serves as a summary of all the data in the tree.

Requirements​

Before we begin, make sure to have a package manager installed on your machine. yarn and npm are perfect managers.

Github Code​

For your reference, you can use the completed tutorial github code

Create a starter app with Celo Composer​

In your terminal, run the following command

npx @celo/celo-composer create

You will be prompted to select the framework you will like to work with which in our case is React.

007

You will also be prompted to pick a web3 library for the react app. For this tutorial, we will pick RainbowKit

006

Next up, you will be prompted to choose the smart contract framework you want to work with, Choose Hardhat.

005

For next steps, we will be prompted to create a Subgraph. We would not be creating a subgraph, so go ahead to select No

004

Then, proceed to give your project a name

You did it! You just created a starter project dApp in few minutes using Celo-Composer

Write out your smart contract​

What is next now is to cd into your project directory and open in your IDE

cd merkle-drop
code .

Go to the packages folder of your project and navigate to hardhat.

003

Go to contracts folder and create a new file called MerkleAirdrop.sol . We will create a constant to hold the merkle root and token contract that will be airdropped to the recipients. We will also keep track of people who claim eventually by creating a mapping.

// The Merkle root
bytes32 public merkleRoot;

// The token contract
IERC20 public tokenContract;

// Mapping to keep track of who claimed their tokens
mapping(address => bool) public claimed;

We will also create a function that allow users claim the tokens.

// Function to claim tokens by providing a merkle proof
function claimTokens(bytes32[] calldata _proof, uint256 _amount) external {
require(!claimed[msg.sender], "Tokens already claimed");

// Mark the address as claimed
claimed[msg.sender] = true;

// Transfer tokens to the address
tokenContract.transfer(msg.sender, _amount);
}

In this function, there is a simple check to see that the user has already claimed the token. There is also the need to verify the merkle proof so we are going to write a function verifyProof to do that

// Function to verify the merkle proof
function verifyProof(bytes32[] calldata _proof, address _address) public view returns (bool) {
// Compute the leaf hash
bytes32 leaf = keccak256(abi.encodePacked(_address, msg.sender));

// Compute the root hash
bytes32 computedHash = leaf;
for (uint256 i = 0; i < _proof.length; i++) {
bytes32 proofElement = _proof[i];

if (computedHash < proofElement) {
computedHash = keccak256(abi.encodePacked(computedHash, proofElement));
} else {
computedHash = keccak256(abi.encodePacked(proofElement, computedHash));
}
}

// Check if the computed hash matches the Merkle root
return computedHash == merkleRoot;
}

We will then go back to our claimTokens function to call our verifyProof function after checking that tokens had already been claimed. So update the claimTokens function with this line of code

require(verifyProof(_proof, msg.sender), "Invalid proof");

All these would require that you import the openzepelin's ERC20 file

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

If you don't have a token in mind to give out, you can create one. Just create another solidity file, name it Token.sol for example and copy this piece of code

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

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Token is ERC20 {
constructor(uint256 initialSupply) ERC20("My Token", "MTK") {
_mint(msg.sender, initialSupply);
}
}

Create an .env file to store your environment variables and copy your private key from your Celo/Metamask wallet as applicable

CELO_NETWORK=https://alfajores-forno.celo-testnet.org
PRIVATE_KEY=YOUR_PRIVATE_KEY

Compile your contracts using this command

npx hardhat compile

Testing your contract​

Testing a smart contract is quite essential because it helps for the smart contract to be secure and function well. We will test our contract to ensure that the functions as intended. In packages >> hardhat >> test , create a test javascript file and write all the possible tests that you think are applicable.

// Import the required dependencies and the smart contract
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { MerkleTree } = require("merkletreejs");

const { StandardMerkleTree } = require("@openzeppelin/merkle-tree");

describe("MerkleAirdrop", function () {
// Define some variables to use in the tests
let merkleRoot;
let airdrop;
let token;
let owner;
let amount = ethers.utils.parseEther("100");

// Create the Merkle Airdrop contract and token contract before each test
beforeEach(async function () {
[owner, recipient1, recipient2] = await ethers.getSigners();
const Token = await ethers.getContractFactory("Token");
token = await Token.deploy(amount);
const MerkleAirdrop = await ethers.getContractFactory("MerkleAirdrop");
merkleRoot = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("test"));
airdrop = await MerkleAirdrop.deploy(merkleRoot, token.address);
await token.transfer(airdrop.address, ethers.utils.parseEther("10"));
});

// Test that the contract deploys successfully
describe("Deployment", function () {
it("Should deploy MerkleAirdrop and Token contracts successfully", async function () {
expect(airdrop.address).to.not.equal(ethers.constants.AddressZero);
expect(token.address).to.not.equal(ethers.constants.AddressZero);
});
});

// Test that only eligible recipients can claim tokens
describe("Claiming tokens", function () {
it("Should allow eligible recipients to claim their tokens and check for invalid proof", async function () {
// Generate a proof

const whitelist = [
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"0xe304cC7Cfed9120ADa3Cd04cC13e210F7c5F37ED",
]; // Replace with real whitelist
const leaves = whitelist.map((address) =>
ethers.utils.keccak256(address)
);
const values = [
["0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "5"],
["0xe304cC7Cfed9120ADa3Cd04cC13e210F7c5F37ED", "2"],
];
const tree = new MerkleTree(leaves);
const leaf = ethers.utils.keccak256(
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
);
const proof = tree.getProof(leaf);

// Call the claimTokens function with a valid proof and an amount
const buffer = proof[0].data;
const bytes32Array = [];
for (let i = 0; i < buffer.length; i += 32) {
const slice = buffer.slice(i, i + 32);
const bytes32 = `0x${slice.toString("hex").padEnd(64, "0")}`;
bytes32Array.push(bytes32);
}
const result = await airdrop.claimTokens(bytes32Array, 3, {
gasLimit: 500000,
});
expect(result)
.to.emit(airdrop, "Tokens already claimed")
.withArgs(bytes32Array);

const invalidProof = tree.getProof(
ethers.utils.keccak256("0x70997970C51812dc3A010C7d01b50e0e17dc79C9")
);
expect(invalidProof)
.to.emit(airdrop, "Invalid Proof")
.withArgs(invalidProof);
});
it("Should be able to verify proof", async function () {
const whitelist = [
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"0xe304cC7Cfed9120ADa3Cd04cC13e210F7c5F37ED",
]; // Replace with real whitelist
const leaves = whitelist.map((address) =>
ethers.utils.keccak256(address)
);
const values = [
["0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "5"],
["0xe304cC7Cfed9120ADa3Cd04cC13e210F7c5F37ED", "2"],
];
const tree = new MerkleTree(leaves);
const leaf = ethers.utils.keccak256(
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
);
const proof = tree.getProof(leaf);
// Call the claimTokens function with a valid proof and an amount
const buffer = proof[0].data;
const bytes32Array = [];
for (let i = 0; i < buffer.length; i += 32) {
const slice = buffer.slice(i, i + 32);
const bytes32 = `0x${slice.toString("hex").padEnd(64, "0")}`;
bytes32Array.push(bytes32);
}
const res = await airdrop.verifyProof(
bytes32Array,
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
{ gasLimit: 500000 }
);
expect(res)
.to.emit(airdrop, true)
.withArgs("0x70997970C51812dc3A010C7d01b50e0d17dc79C8");
});
});
});

You can proceed to test this by running the following command

npx hardhat test test/your_javascript_file.js

If successful, you should see an output similar to this

002

Deploy your Smart Contract​

Create a deployment script file in scripts folder. You can run this command

cd packages/hardhat/scripts && touch deploy.js

Then write your script

const hre = require("hardhat");

async function main() {
const merkleRoot = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("test"));
const tokenContractAddress = "0xB3C20f3011ac4f713b3E6252E9B6A2060EB912a1"; // Replace with your token contract address
const MerkleAirdrop = await hre.ethers.getContractFactory("MerkleAirdrop");
const merkleAirdrop = await MerkleAirdrop.deploy(
merkleRoot,
tokenContractAddress
);
await merkleAirdrop.deployed();
}

main();

Run this command after

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

After a succesful deployment, you would see the message

MerkleAirdrop address deployed to: 0x4004aD23277E51E1086beba0C0E8644Cb0DAe1d5

Starting out the Frontend​

In the root of your project folder, create a file called AirdropWrapper.js. This will serve as a gateway between the contract deployed and our component class. We will call our contract in this file.

import { abi } from "./AirdropContract.json";
import { providers, Contract, ethers } from "ethers";
const { MerkleTree } = require("merkletreejs");

require("dotenv").config();

export async function getContract() {
const contractAddress = "0x4004aD23277E51E1086beba0C0E8644Cb0DAe1d5";
const contractABI = abi;
let supportTokenContract;
try {
const { ethereum } = window;
if (ethereum.chainId === "0xaef3") {
const provider = new providers.Web3Provider(ethereum);
const signer = provider.getSigner();
supportTokenContract = new Contract(contractAddress, contractABI, signer);
} else {
throw new Error("Please connect to the Alfajores network");
}
} catch (error) {
console.log("ERROR:", error);
}
return supportTokenContract;
}

export async function claimTokens(proof, amount) {
const contract = await getContract();
const tx = await contract.claimTokens(proof, amount, {
gasLimit: 300000,
});
await tx.wait();
}

export async function checkEligibility(whitelist) {
const leaves = whitelist.map((address) => ethers.utils.keccak256(address));
const tree = new MerkleTree(leaves, ethers.utils.keccak256);

const leaf = ethers.utils.keccak256(whitelist[0]);
const proof = tree.getProof(leaf);
const root = tree.getRoot().toString("hex");
return tree.verify(proof, leaf, root);
}

export async function getTheProof(whitelist) {
const leaves = whitelist.map((address) => ethers.utils.keccak256(address));
const tree = new MerkleTree(leaves, ethers.utils.keccak256);

const leaf = ethers.utils.keccak256(whitelist[0]);
const proof = tree.getProof(leaf);
const root = tree.getRoot().toString("hex");
const bytes32Array = [];
const buffer = proof[0].data;
for (let i = 0; i < buffer.length; i += 32) {
const slice = buffer.slice(i, i + 32);
const bytes32 = `0x${slice.toString("hex").padEnd(64, "0")}`;
bytes32Array.push(bytes32);
}

return bytes32Array;
}

In this file, we will also write functions to get our merkle proof, check if an address is eligible for airdrop and claim tokens.

Navigate to react-app folder and go to your components folder, create a new file there named Airdrop.tsx. Here we will import the functions from the wrapper class and call it.

import React, { useState } from "react";
import {
checkEligibility,
claimTokens,
getTheProof,
} from "../../../AirdropWrapper";
import { useAccount } from "wagmi";
const [isAddress, setAddress] = useState("");
const [isEligible, setIsEligible] = useState(false);
const [isClaimed, setIsClaimed] = useState(false);
const { address, isConnecting, isDisconnected } = useAccount();
let whitelist: any = [];
const lowercaseAddress = address.toLowerCase();
whitelist.push(lowercaseAddress);
whitelist.push("0xe304cC7Cfed9120ADa3Cd04cC13e210F7c5F37ED");

const proof = getTheProof(whitelist);
const checkEligibile = async () => {
const isEligible = await checkEligibility(whitelist);
setIsEligible(isEligible);
};
const claimAirdrop = async () => {
const claim = await claimTokens(await proof, "1");
setIsClaimed(true);
};

Complete your frontend to show that the wallet connected is eligible for the airdrop and that the person can claim

<div className="bg-gray-100 p-4">
<h1 className="text-2xl font-bold mb-4">Airdrop</h1>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder={address}
className="bg-white rounded-md py-2 px-4 mb-4 w-full"
/>
<button
onClick={checkEligibile}
className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded"
>
Check eligibility
</button>
{isEligible && !isClaimed && (
<div className="bg-green-100 p-4 rounded mt-4">
<p className="text-green-700 font-bold mb-2">
You are eligible for the airdrop!
</p>
<button
onClick={claimAirdrop}
className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded"
>
Claim airdrop
</button>
</div>
)}
{isEligible && isClaimed && (
<p className="text-gray-700 font-bold mt-4">
You have already claimed the airdrop.
</p>
)}
{!isEligible && (
<p className="text-red-700 font-bold mt-4">
You are not eligible for the airdrop.
</p>
)}
</div>

Proceed to your terminal to run this command

npm run dev

It should compile and deploy to your localhost so you should see an interface similar to this

001

The wallet connected is eligible for the airdrop hence we see that here. Proceed to claim the airdrop by clicking on the button.

There you have it. You have successfully implemented a dApp that gives your users access to claim airdrops using merkle trees.

Conclusion​

In this tutorial , we learnt and saw the versatility of Merkle trees in building decentralized applications and showcased how they can be used to provide access to airdrops. This demonstrates the real-world applicability of Merkle trees and their role in building secure and efficient blockchain systems.

Reference​

About Author​

Oluwafemi Alofe is a Blockchain developer with 3 years of applicable knowledge and 8 years of total experience as a Software engineer. He has written and deployed vulnerability-free smart contracts as a blockchain developer on a variety of blockchains, including but not limited to Ethereum, BCS, and Celo. Oluwafemi has a strong interest in developing distributed systems, and continues to learn new things every day to stay current with technological advancements.

He is has two Udemy courses on solidity development with over 6,300 student enrolled and also a book on Amazon KDP for PHP and Laravel Developers.

He's currently a platform owner at myco.io, first world, watch to earn platform with over 1 Million users.

Connect with Oluwafemi on Twitter or Linkedin.

Go back

Β· 14 min read

header

Introduction​

In this tutorial, we will be exploring a Solidity smart contract that allows users to create fundraising campaigns and receive donations in cUSD token. The contract will have two main functions: createCampaign and fundCampaign. With the createCampaign function, a user can create a new fundraising campaign by providing the campaign details, including the beneficiary's address and the campaign's goal. The fundCampaign function allows users to fund an existing campaign by sending cUSD tokens to the campaign's beneficiary address.

We will explain the code in detail, including the Campaign struct, the mapping used to store the campaigns, and the events emitted during the contract's execution. By the end of this tutorial, you will have a good understanding of how to create a fundraising smart contract in Solidity. So let's get started!

Here’s a demo link of what you’ll be creating.

And a screenshot. image

Prerequisites​

To fully follow up with these tutorials, you should have a basic understanding of the following technologies.

Solidity, smart-contract and blockchain concepts. React. Basic web Development.

Requirements​

  • Solidity.
  • React.
  • Bootstrap.
  • NodeJS 12.0.1 upwards installed.
  • Celo Extension Wallet.
  • Remix IDE

SmartContract​

Let's begin writing our smart contract in Remix IDE

The completed code Should look like this.

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

interface IERC20Token {
function transfer(address, uint256) external returns (bool);
function approve(address, uint256) external returns (bool);
function transferFrom(address, address, uint256) external returns (bool);
function totalSupply() external view returns (uint256);
function balanceOf(address) external view returns (uint256);
function allowance(address, address) external view returns (uint256);

event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}

contract Fundraising {

address internal cUsdTokenAddress = 0x874069Fa1Eb16D44d622F2e0Ca25eeA172369bC1;
uint internal totalCampaigns = 0;

struct Campaign {
string image;
string description;
address beneficiary;
uint totalRaised;
uint goal;
}

mapping(uint => Campaign) public campaigns;

event CampaignCreated(address indexed beneficiary, uint goal);
event CampaignFunded(address indexed beneficiary, uint amount);
event CampaignGoalReached(address indexed beneficiary);

function createCampaign(
string memory _image,
string memory _description,
address _beneficiary,
uint _goal
) public {
require(msg.sender == _beneficiary);

Campaign storage campaign = campaigns[totalCampaigns];
campaign.image = _image;
campaign.description = _description;
campaign.beneficiary = _beneficiary;
campaign.totalRaised = 0;
campaign.goal = _goal;
totalCampaigns ++;

emit CampaignCreated(_beneficiary, _goal);
}

function fundCampaign(uint _campaignId, uint _amount) public payable {
Campaign storage campaign = campaigns[_campaignId];

require(msg.sender != campaign.beneficiary);
require(campaign.totalRaised < campaign.goal, "Campaign goal reached");

campaign.totalRaised += _amount;

require(
IERC20Token(cUsdTokenAddress).transferFrom(
msg.sender,
campaign.beneficiary,
_amount
),
"Transfer failed."
);


emit CampaignFunded(campaign.beneficiary, _amount);

if (campaign.totalRaised >= campaign.goal) {
emit CampaignGoalReached(campaign.beneficiary);
}
}

function getCampaign(uint256 _campaignId)
public
view
returns (
string memory,
string memory,
address,
uint,
uint
)
{
Campaign storage campaign = campaigns[_campaignId];
return (
campaign.image,
campaign.description,
campaign.beneficiary,
campaign.totalRaised,
campaign.goal
);
}

function getCampaignLength() public view returns (uint256){
return (totalCampaigns);
}
}

Break down​

First, we declared our license and the solidity version.

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

Next, let's declare our cUSD ERC20 token interface.

interface IERC20Token {
function transfer(address, uint256) external returns (bool);
function approve(address, uint256) external returns (bool);
function transferFrom(address, address, uint256) external returns (bool);
function totalSupply() external view returns (uint256);
function balanceOf(address) external view returns (uint256);
function allowance(address, address) external view returns (uint256);

event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}

The IERC20Token interface provides functions to transfer tokens, approve tokens, and view the total supply and balance of tokens. To create the interface, we need to define each of the required functions:

transfer – This function is used to transfer tokens from one address to another. It takes two parameters: the address to send the tokens to and the amount of tokens to send.

approve – This function is used to approve another address to transfer tokens from the sender's account. It takes two parameters: the address to approve and the amount of tokens to approve.

transferFrom – This function is used to transfer tokens from one address to another. It takes three parameters: the address to transfer tokens from, the address to transfer tokens to, and the amount of tokens to transfer.

totalSupply – This function is used to view the total number of tokens in circulation. It takes no parameters and returns a uint256 value.

balanceOf – This function is used to view the balance of tokens for a given address. It takes one parameter: the address to check the balance for. It returns a uint256 value.

allowance – This function is used to view the approved allowance for a given address. It takes two parameters: the owner address and the spender address. It returns a uint256 value.

Transfer and Approval events – These events are used to emit Transfer and Approval events when tokens are transferred or approved.

Now let's start building our fundraising smart contract

contract Fundraising {

address internal cUsdTokenAddress = 0x874069Fa1Eb16D44d622F2e0Ca25eeA172369bC1;
uint internal totalCampaigns = 0;

struct Campaign {
string image;
string description;
address beneficiary;
uint totalRaised;
uint goal;
}

mapping(uint => Campaign) public campaigns;

event CampaignCreated(address indexed beneficiary, uint goal);
event CampaignFunded(address indexed beneficiary, uint amount);
event CampaignGoalReached(address indexed beneficiary);
}

The cUsdTokenAddress is the address of the ERC-20 token contract that is used to accept donations. The totalCampaigns variable is used to keep track of the total number of campaigns that have been created.

Next we declare a struct called Campaign which is used to store information about each campaign. It contains the image and description of the campaign, the address of the beneficiary, the total amount raised so far, and the goal amount that needs to be raised.

Then we added the campaigns mapping which is used to store the Campaign struct for each campaign.

The CampaignCreated, CampaignFunded, and CampaignGoalReached events are used to notify external applications when a campaign is created, funded, or when its goal is reached.

Let's take a look at the createCampaign function.

function createCampaign(
string memory _image,
string memory _description,
address _beneficiary, uint _goal
) public {
require(msg.sender == _beneficiary);

Campaign storage campaign = campaigns[totalCampaigns];
campaign.image = _image;
campaign.description = _description;
campaign.beneficiary = _beneficiary;
campaign.totalRaised = 0;
campaign.goal = _goal;
totalCampaigns ++;

emit CampaignCreated(_beneficiary, _goal);
}

The createCampaign function takes four parameters: image, description, beneficiary, and goal. These parameters define the fundraising campaign's details. The function first verifies that the caller is the beneficiary and then creates a new Campaign struct to store the campaign details. The function also emits a CampaignCreated event with the beneficiary address and the campaign's goal.

Next is the fundCampaign function.

function fundCampaign(uint _campaignId, uint _amount) public payable {
Campaign storage campaign = campaigns[_campaignId];

require(msg.sender != campaign.beneficiary);
require(campaign.totalRaised < campaign.goal, "Campaign goal reached");

campaign.totalRaised += _amount;

require(
IERC20Token(cUsdTokenAddress).transferFrom(
msg.sender,
campaign.beneficiary,
_amount
),
"Transfer failed."
);

emit CampaignFunded(campaign.beneficiary, _amount);

if (campaign.totalRaised >= campaign.goal) {
emit CampaignGoalReached(campaign.beneficiary);
}
}

The fundCampaign function is used to fund an existing campaign. It takes two parameters: campaignId and amount. The function first gets the campaign details using the campaignId parameter. It then verifies that the caller is not the beneficiary and that the campaign has not reached its goal. If these conditions are met, the function adds the amount to the campaign's totalRaised variable and transfers the amount of cUSD tokens from the caller to the campaign beneficiary. The function then emits a CampaignFunded event with the beneficiary address and the funded amount.

If the campaign's totalRaised variable is greater than or equal to the campaign's goal after the funding, the function emits a CampaignGoalReached event with the beneficiary address.

Next up is the getCampain function

 function getCampaign(uint256 _campaignId)
public
view
returns (
string memory,
string memory,
address,
uint,
uint
)
{
Campaign storage campaign = campaigns[_campaignId];
return (
campaign.image,
campaign.description,
campaign.beneficiary,
campaign.totalRaised,
campaign.goal
);
}

The getCampaign function is used to get the details of a campaign using the campaignId parameter. It returns a tuple with the campaign's image, description, beneficiary address, totalRaised, and goal.

And finally the getCampaignLength function

 function getCampaignLength() public view returns (uint256){
return (totalCampaigns);
}

The getCampaignLength function returns the total number of campaigns created so far by returning the current value of the totalCampaigns variable.

Deployment​

To deploy our smart contract successfully, we need the celo extention wallet which can be downloaded from here

Next, we need to fund our newly created wallet which can done using the celo alfojares faucet Here

You can now fund your wallet and deploy your contract using the celo plugin in remix.

Frontend​

Click on this repo from your github.

  • Clone the repo to your computer.
  • open the project from from vscode.
  • Run npm install command to install all the dependencies required to run the app locally.

App.js​

The completed code should look like this.

import "./App.css";
import Home from "./components/home";
import { Campaigns } from "./components/Campaigns";
import { useState, useEffect, useCallback } from "react";
import Web3 from "web3";
import { newKitFromWeb3 } from "@celo/contractkit";
import fundraising from "./contracts/fundraising.abi.json";
import IERC from "./contracts/IERC.abi.json";

const ERC20_DECIMALS = 18;
const contractAddress = "0xadB1C74ce3b79344D3587BB2BC8530d95cDEEAa2";
const cUSDContractAddress = "0x874069Fa1Eb16D44d622F2e0Ca25eeA172369bC1";

function App() {
const [contract, setcontract] = useState(null);
const [address, setAddress] = useState(null);
const [kit, setKit] = useState(null);
const [cUSDBalance, setcUSDBalance] = useState(0);
const [campaigns, setCampaigns] = useState([]);

const connectToWallet = async () => {
if (window.celo) {
try {
await window.celo.enable();
const web3 = new Web3(window.celo);
let kit = newKitFromWeb3(web3);

const accounts = await kit.web3.eth.getAccounts();
const user_address = accounts[0];
kit.defaultAccount = user_address;

await setAddress(user_address);
await setKit(kit);
} catch (error) {
console.log(error);
}
} else {
alert("Error Occurred");
}
};

const getBalance = useCallback(async () => {
try {
const balance = await kit.getTotalBalance(address);
const USDBalance = balance.cUSD.shiftedBy(-ERC20_DECIMALS).toFixed(2);

const contract = new kit.web3.eth.Contract(fundraising, contractAddress);
setcontract(contract);
setcUSDBalance(USDBalance);
} catch (error) {
console.log(error);
}
}, [address, kit]);

const getCampaigns = useCallback(async () => {
const campaignsLength = await contract.methods.getCampaignLength().call();
const campaigns = [];
for (let index = 0; index < campaignsLength; index++) {
let _campaigns = new Promise(async (resolve, reject) => {
let campaign = await contract.methods.getCampaign(index).call();

resolve({
index: index,
image: campaign[0],
description: campaign[1],
beneficiary: campaign[2],
totalRaised: campaign[3],
goal: campaign[4],
});
});
campaigns.push(_campaigns);
}

const _campaigns = await Promise.all(campaigns);
setCampaigns(_campaigns);
}, [contract]);

const addCampaign = async (_image, _description, _beneficiary, _goal) => {
try {
await contract.methods
.createCampaign(_image, _description, _beneficiary, _goal)
.send({ from: address });
getCampaigns();
} catch (error) {
alert(error);
}
};

const fundCampaign = async (_index, _ammount) => {
try {
const cUSDContract = new kit.web3.eth.Contract(IERC, cUSDContractAddress);

await cUSDContract.methods
.approve(contractAddress, _ammount)
.send({ from: address });
await contract