Introduction
In this tutorial, we will guide you through the process of building a full-stack decentralized application that enables secure product delivery on the Celo blockchain. We will use Solidity to write a smart contract and React to build the front end of the application.
Our focus will be on creating an escrow smart contract, which acts as a trusted intermediary between buyers and sellers, ensuring that both parties fulfill their obligations before the transaction is complete. This will allow us to create a secure and decentralized system that ensures the safe and timely delivery of products.
Prerequisites
-
Solidity: You should have a basic understanding of Solidity, the programming language used for writing smart contracts on the Celo blockchain.
-
React: You should have a basic understanding of React, a popular JavaScript library for building user interfaces. You should be familiar with components, state, props, and lifecycle methods.
-
Hardhat: You should have a basic understanding of Hardhat, a development environment for building, testing, and deploying smart contracts on the Celo blockchain. You should be familiar with Hardhat’s project structure, configuration, and testing features.
Requirements
- Node.js: You will need Node.js to install on your machine to run the necessary tools and libraries for our application. You can download the latest version of Node.js from the official website here
- Hardhat: We will be using Hardhat as our development environment for building, testing, and deploying smart contracts on the Celo blockchain.
- React: We will be using React to build the front end of our application. You can create a new React project using the following command:
npx create-react-app my-app
- MetaMask Wallet Extension: You will need to install the MetaMask Wallet Extension for your browser to interact with the Celo blockchain. You can find the extension for Chrome, Firefox, and Brave browsers here
Setting up the project
We will set up our project by cloning a starter code template from GitHub and installing the necessary dependencies.
To get started, open your terminal and run the following command to clone the starter code template for our project from GitHub.
git clone https://github.com/JovanMwesigwa/escrow-dapp.git
This will create a new folder called escrow-dapp
in your current directory, which contains the starter code template for our project.
Once you’ve cloned the repository, navigate to the escrow-dapp
folder in your terminal and install the necessary dependencies for our project by running the following command:
npm install
Or
yarn
This command will install all the required dependencies specified in the package.json file.
After the dependencies have been installed successfully, you’re all set to start building the project.
The smart contract code will be written inside the /Contracts/Escrow.sol
that comes with the starter template.
Inside the Contract, we start by defining some custom error messages that will be used in the revert functions of the contract as shown below:
error Escrow__NotAdminRequestDenied();
error Escrow__OnyBuyerDepositAllowed();
error Escrow__OnlyBuyerDepositAllowed();
error Escrow__InsufficientAmountDeposited();
error Escrow__OnlyBuyerConfirmDeposit();
error Escrow__ContractAlreadyInProgress();
error Escrow__BothPartiesMustConfirmFirst();
By doing it this way, we save some gas fees, because this is a little cheaper than the inbuilt require()
method that comes with Solidity.
The Escrow contract is defined using the contract keyword. It has several state variables defined, which are used to keep track of the buyer, seller, balance, and confirmation status. The buyerConfirmation
and sellerConfirmation
variables are used to track whether the buyer and seller have confirmed the transaction.
contract Escrow {
address private i_admin;
address payable private s_buyer;
address payable private s_seller;
uint256 private s_balance;
bool private buyerConfirmation;
bool private sellerConfirmation;
// Events
event Deposit(address indexed buyerAddress, uint256 indexed amount);
event DeliveryConfirmed(address indexed buyerAddress);
event ReceiptDone(address indexed sellerAddress);
event PaymentReleased(
address indexed buyerAddress,
address indexed sellerAddress,
uint256 amount
);
To keep track of the changes happening in the contract, we’ll emit some events in the contract by emitting deposit, delivery confirmation, receipt, and payment events
The Deposit
event is emitted when the buyer makes a deposit, the DeliveryConfirmed
event is emitted when the buyer confirms the delivery, the ReceiptDone
event is emitted when the seller confirms receipt, and the PaymentReleased
event is emitted when the payment is released.
constructor() {
i_admin = payable(msg.sender);
s_balance = 0;
}
The constructor
function is used to initialize the state variables when the contract is deployed. It sets the i_admin
variable to the address of the deployer and sets the s_balance
variable to 0.
A confirmSellerDelivery
function is called by the buyer to confirm the delivery. It verifies that only the buyer can call this function, sets the buyerConfirmation
variable to true, emits a DeliveryConfirmed
event, and if both the buyer and seller have confirmed the transaction, it releases the payment to the seller, as shown below:
function confirmSellerDelivery() public payable {
// Verify that only the buyer confirms the delivery
if (msg.sender != s_buyer) {
revert Escrow__OnlyBuyerConfirmDeposit();
}
buyerConfirmation = true;
// Emit a delivery confirmation event
emit DeliveryConfirmed(msg.sender);
// Call the release payment here...
if (buyerConfirmation && sellerConfirmation) {
s_seller.transfer(address(this).balance);
buyerConfirmation = false;
sellerConfirmation = false;
s_balance = 0;
}
}
A confirmBuyerReceipt
function is used by the seller to confirm that the buyer has received the product and issued a receipt. It first checks that only the seller can call this function by verifying that msg.sender
is equal to the s_seller address
. If not, it reverts the transaction and throws an error.
function confirmBuyerReceipt() public payable {
// Verify that only the seller confirms the issuing of the receipt
if (msg.sender != s_seller) {
revert Escrow__OnlyBuyerConfirmDeposit();
}
sellerConfirmation = true;
// Emit a receipt done event
emit ReceiptDone(msg.sender);
// Call the release payment here...
if (buyerConfirmation && sellerConfirmation) {
s_seller.transfer(address(this).balance);
sellerConfirmation = true;
s_balance = 0;
}
}
If the seller is the one who called the function, sellerConfirmation
is set to true, indicating that the seller has confirmed the receipt. An event, ReceiptDone
, is then emitted to notify any external applications of the event.
Finally, if both the buyer and the seller have confirmed their parts, the s_seller
address is sent the entire balance of the contract, and the sellerConfirmation
and s_balance
variables are reset to their initial values.
Finally, we define the rest of the pure
/ view
to retrieve private variables in the contract.
Here’s the full smart contract code below:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;
error Escrow__NotAdminRequestDenied();
error Escrow__OnyBuyerDepositAllowed();
error Escrow__OnlyBuyerDepositAllowed();
error Escrow__InsufficientAmountDeposited();
error Escrow__OnlyBuyerConfirmDeposit();
error Escrow__ContractAlreadyInProgress();
error Escrow__BothPartiesMustConfirmFirst();
contract Escrow {
address private i_admin;
address payable private s_buyer;
address payable private s_seller;
uint256 private s_balance;
bool private buyerConfirmation;
bool private sellerConfirmation;
// Events
event Deposit(address indexed buyerAddress, uint256 indexed amount);
event DeliveryConfirmed(address indexed buyerAddress);
event ReceiptDone(address indexed sellerAddress);
event PaymentReleased(
address indexed buyerAddress,
address indexed sellerAddress,
uint256 amount
);
constructor() {
i_admin = payable(msg.sender);
s_balance = 0;
}
function confirmSellerDelivery() public payable {
// Verify that only the buyer confirms the delivery
if (msg.sender != s_buyer) {
revert Escrow__OnlyBuyerConfirmDeposit();
}
buyerConfirmation = true;
// Emit a delivery confirmation event
emit DeliveryConfirmed(msg.sender);
// Call the release payment here...
if (buyerConfirmation && sellerConfirmation) {
s_seller.transfer(address(this).balance);
buyerConfirmation = false;
sellerConfirmation = false;
s_balance = 0;
}
}
function confirmBuyerReceipt() public payable {
// Verify that only the seller confirms the issuing of the receipt
if (msg.sender != s_seller) {
revert Escrow__OnlyBuyerConfirmDeposit();
}
sellerConfirmation = true;
// Emit a receipt done event
emit ReceiptDone(msg.sender);
// Call the release payment here...
if (buyerConfirmation && sellerConfirmation) {
s_seller.transfer(address(this).balance);
sellerConfirmation = true;
s_balance = 0;
}
}
function deposit() public payable {
// Verify that the buyer is only depositing
if (msg.sender != s_buyer) {
revert Escrow__OnlyBuyerDepositAllowed();
}
// Verify that the amount of money deposited is not 0
if (msg.value <= 0) {
revert Escrow__InsufficientAmountDeposited();
}
// Update the contract's balance
s_balance += msg.value;
// Emit a deposit event
emit Deposit(msg.sender, msg.value);
}
function addParties(address payable buyer, address payable seller) public {
if (msg.sender != i_admin) {
revert Escrow__NotAdminRequestDenied();
}
s_buyer = buyer;
s_seller = seller;
}
function getBalance() public view returns (uint256) {
return s_balance;
}
function getAdmin() public view returns (address) {
return i_admin;
}
function getBuyer() public view returns (address) {
return s_buyer;
}
function getSeller() public view returns (address) {
return s_seller;
}
function getBuyerConfirmation() public view returns (bool) {
return buyerConfirmation;
}
function getSellerConfirmation() public view returns (bool) {
return sellerConfirmation;
}
}
Configuring the hardhat script file
The hardhat script file is used to deploy and interact with the smart contract.
In this script, we define three networks: hardhat
, localhost
, and alfajores
.
Both the hardhat and local networks are the default used for local testing.
Finally, the alfajores network is used to deploy the smart contract on the Alfajores testnet. This is where we’ll deploy our contract. Each network configuration includes a chainId, which is a unique identifier for each blockchain network.
Next, we define the Solidity compiler versions that we want to use. In this script, we specify four different versions of the Solidity compiler, ranging from version 0.6.6
to version 0.8.18
. By including multiple compiler versions, we can compile our smart contracts with different versions of Solidity and ensure that our contracts will work on different networks.
The paths
section specifies the directory where Hardhat stores the compiled contracts, artifacts, and other data. These will be very important to use when interacting with the front end later. By setting this path, hardhat will automatically look for the artifacts in this route.
We also define a named account in this script, deployer
, which is used to deploy the smart contract. By defining a named account, we can easily refer to it in our deployment scripts.
Finally, the etherscan
section is used to configure Etherscan API keys for different networks. We provide an API key for the alfajores
network, which allows us to verify our deployed contract on the Etherscan block explorer.
Obtaining env keys
To configure and get the environment variables for the hardhat.config.js
file, you need to create a .env
file in the root directory of the project. The .env
file should contain the required environment variables.
As shown below:
ALFAJORES_API_KEY=<ALAFAJORES API KEY>
ALFAJORES_URL=https://celo-alfajores.infura.io/v3/<INFURA RPC KEY>
PRIVATE_KEY=<WALLET PRIVATE KEY FROM METAMASK>
ALFAJORES_API_KEY
: This is the API key for the Alfajores network provided by Celscan. You can get an API key by creating an account on Celoscan here and going to the API section of your account.ALFAJORES_URL
: This is the URL for the Aljajores network. To get this URL, create an account with Infura herePRIVATE_KEY
: This is the private key for the account that will be used to deploy the contract. This can be found in your MetaMask wallet.
Overall, this script file is used to configure the various settings of the Hardhat framework and is crucial for developing, testing, and deploying smart contracts using Hardhat.
Deploy script
Now that our hardhat configuration is complete, we can now write a deploy script for the contract.
Inside /deploy
create a file called 01-deploy-escrow.js
. This will define the main deploy script for the contract with the code below:
const { network } = require("hardhat");
const { verifyContract } = require("../utils/verifyContract");
module.exports = async ({ getNamedAccounts, deployments }) => {
const { deployer } = await getNamedAccounts();
const chainId = network.config.chainId;
const { deploy, log } = deployments;
const args = [];
const waitConfirmations = 1;
// Only verify the contract when we are deploying on the celo test net
const tx = await deploy("Escrow", {
from: deployer,
args: args,
waitConfirmations: waitConfirmations,
log: true,
});
log("Product Escrow contract deployed --------------");
if (chainId != 31337) {
log("Verifying the contract on celoscan...");
await verifyContract(tx.address);
}
};
module.exports.tags = ["all", "deploy"];
The asynchronous function takes in two parameters: getNamedAccounts
and deployments
. getNamedAccounts
is a function that retrieves the addresses of pre-configured accounts in the Hardhat config file.
In this script, we are only interested in the deployer
account, which is the account that will deploy the contract. We retrieve it by calling await getNamedAccounts()
.
We then retrieve the chainId
from the network.config
object, which tells us which network we are deploying to.
We use the deploy
function provided by the deployments
plugin to deploy the contract. We pass in the name of the contract, Escrow
, and an empty array for the constructor arguments, as our Escrow
contract does not require any constructor arguments. We also specify the from
parameter as the deployer
account retrieved earlier. We set waitConfirmations
to 1 so that we only proceed with the deployment once the transaction has been confirmed by at least one node on the network. We also set log
to true
so that deployment progress is displayed in the console.
Finally, we check if the chainId
is not equal to 31337
(i.e. we are deploying on a Celo network, not the local Hardhat network), and if so, we call the verifyContract
function to verify the contract on the Celo blockchain.
Lastly, we define a line exports an array of tags for this script, which can be used to run this script and any others with a particular tag by using the --tags
flag in the command line. In this case, we have tagged this script with all
and deploy
, so running npx hardhat deploy --tags deploy
will execute this script.
Deploying the contract
Using the defined deploy script, we can now deploy the contract from the terminal.
In your terminal type:
npx hardhat deploy --network alfajores
This will automatically push the contract to Celo Alfajore’s network using all the predefined environment variables in the hardhat.config.js
file.
Output:
The contract was successfully deployed and verified on the alfajores network.
Setting up the front end
Now that the contract is successfully deployed, we can set up the front end to interact with the deployed contract.
Navigate to the /client
folder in your project and install all the necessary dependencies for the frontend using by typing:
npm install
OR
yarn
We need to notify the front where to find the contract’s address and the admin’s address.
This can be found in the /constants
folder.
import abi from "../backend/contracts/Escrow.sol/Escrow.json";
export const CONTRACT_ADDRESS = "";
export const ADMIN_ADDRESS = "";
export const ABI = abi.abi;
Replace CONTRACT_ADDRESS
with your deployed contract address and ADMIN_ADDRESS
with your deployer’s address.
Now that the basic configuration is done, we can now run the front end.
In your terminal type:
yarn run dev
Output:
The UI consists of the following:
-
A web3 connect button, deposit payment button, escrow balance, and the admin, buyer, and seller addresses.
-
A confirm delivery and confirm Recept button that will specifically be shown to a seller or buyer.
-
Finally and progress bar to keep track of the necessary steps taken to complete the escrow lifetime.
Building the frontend
The main logic of the front end happens in the Home.jsx
file. Inside, we declared various state variables to keep track of what’s happening in the UI. As shown below:
const { isWeb3Enabled, account } = useMoralis();
// Buyer state
const [buyer, setBuyer] = useState("");
// Contract balance
const [balance, setBalance] = useState(null);
// Seller State
const [seller, setSeller] = useState("");
// buyer confirmation status
const [buyerConfirmed, setBuyerConfirmed] = useState(false);
//Seller confirmed the status
const [sellerConfirmed, setSellerConfirmed] = useState(false);
const [adminAddress, setAdminAddress] = useState(
"0x5cbdf5f9e468df3888e04155668ccafc6f6c4dcf"
);
Using the useMoralis()
hook to retrieve two variables, isWeb3Enabled
and account
, from the Moralis Web3 API. isWeb3Enabled
is a boolean variable that indicates whether the user’s browser has Web3 enabled or not. account
is a string variable that contains the user’s Celo account address.
Next, we have several state variables declared using the useState
hook. These variables are used to keep track of the buyer’s address, contract balance, seller’s address, and whether the buyer and seller have confirmed the transaction or not.
We have another state variable called adminAddress
, which is initialized to a default Celo address. This variable is used to store the Celo address of the contract administrator. This happens to be the deployer of the contract too.
To interact easily with the smart contract, we use the contract function calls in this function and throughout the code with the useWeb3Contract
hook provided by Moralis, which provides a way to interact with the contract’s functions by specifying its ABI, contract address, function name, and any necessary parameters.
For example, to fetch the contract’s balance, we define it as below:
// Get balance
const { runContractFunction: getBalance } = useWeb3Contract({
abi: ABI,
contractAddress: CONTRACT_ADDRESS,
functionName: "getBalance",
params: {},
});
This directly calls the contract function name getBalance
by passing in the contract address and AB directly.
If the function needs any type of parameters, we pass them inside the params: {}
object.
To call the deposit
function, we use
// CONTRACT MUTATION FUNCTIONS
// Deposit money
const {
isLoading,
runContractFunction: deposit,
isFetching,
error,
} = useWeb3Contract({
abi: ABI,
contractAddress: CONTRACT_ADDRESS,
functionName: "deposit",
params: {},
msgValue: 1,
});
The deposit()
function calls the contract’s deposit
function which allows the buyer to deposit funds into the smart contract. It also uses the useWeb3Contract
hook to specify the ABI, contract address, function name, and a msgValue
of 1, which specifies the amount of Celo being sent with the transaction.
Using the same logic above, we’ll use for interacting with all the contract’s functions like confirmSellerDelivery()
and confirmBuyerReceipt()
All the contract interaction functions are defined as shown below:
const { runContractFunction: getBuyer } = useWeb3Contract({
abi: ABI,
contractAddress: CONTRACT_ADDRESS,
functionName: "getBuyer",
params: {},
});
// Get balance
const { runContractFunction: getBalance } = useWeb3Contract({
abi: ABI,
contractAddress: CONTRACT_ADDRESS,
functionName: "getBalance",
params: {},
});
const { runContractFunction: getSeller } = useWeb3Contract({
abi: ABI,
contractAddress: CONTRACT_ADDRESS,
functionName: "getSeller",
params: {},
});
// Get buyer Confirmation status
const { runContractFunction: getBuyerConfirmation } = useWeb3Contract({
abi: ABI,
contractAddress: CONTRACT_ADDRESS,
functionName: "getBuyerConfirmation",
params: {},
});
// Get seller Confirmation status
const { runContractFunction: getSellerConfirmation } = useWeb3Contract({
abi: ABI,
contractAddress: CONTRACT_ADDRESS,
functionName: "getSellerConfirmation",
params: {},
});
// CONTRACT MUTATION FUNCTIONS
// Deposit money
const {
isLoading,
runContractFunction: deposit,
isFetching,
error,
} = useWeb3Contract({
abi: ABI,
contractAddress: CONTRACT_ADDRESS,
functionName: "deposit",
params: {},
msgValue: 1,
});
// Buyer Confirms delivery
const {
// isLoading,
runContractFunction: confirmSellerDelivery,
// isFetching,
// error,
} = useWeb3Contract({
abi: ABI,
contractAddress: CONTRACT_ADDRESS,
functionName: "confirmSellerDelivery",
params: {},
});
// Seller Confirms receipt
const {
// isLoading,
runContractFunction: confirmBuyerReceipt,
// isFetching,
// error,
} = useWeb3Contract({
abi: ABI,
contractAddress: CONTRACT_ADDRESS,
functionName: "confirmBuyerReceipt",
params: {},
});
We may want to render all the fetched data on the initial page load of the UI.
The populateData()
function is an asynchronous function that calls several other functions to populate the state variables defined earlier. It first calls getBuyer()
to get the buyer’s address and sets the buyer state variable to this value. Similarly, it calls getSeller()
to get the seller’s address and sets the seller state variable to this value. It also calls getBuyerConfirmation()
and getSellerConfirmation()
to get the confirmation status of the buyer and the seller respectively, and sets the corresponding buyerConfirmed
and sellerConfirmed
state variables to these values. Finally, it calls getBalance()
to get the contract’s balance and sets the balance state variable to this value.
As shown below:
const populateData = async () => {
try {
const returnedBuyer = await getBuyer();
setBuyer(returnedBuyer);
const returnedSeller = await getSeller();
setSeller(returnedSeller);
const buyerStatus = await getBuyerConfirmation();
setBuyerConfirmed(buyerStatus);
const sellerStatus = await getSellerConfirmation();
setSellerConfirmed(sellerStatus);
const balanceResult = await getBalance();
setBalance(balanceResult.toString());
} catch (error) {
console.log(error.response);
}
};