Creating a Decentralised Crowd Funding Contract with Solidity
This will be a three-part series with code and instructions for creating and deploying a decentralized crowdfunding contract with Solidity. You can use a crowdfunding contract to raise money for a specific purpose. A contract can be written in which a person requests donations for a particular purpose. Supporters of their ideas can contribute to the campaign in Ethers. A campaign creator creates milestones that donors must approve before they can withdraw the Ethers donated to them.
Introduction
In this series, we will use Solidity to create a smart contract. We'll make a factory contract that creates child contracts from an implementation contract. This pattern is cost-effective since we are not deploying new child contracts to the network but instead creating clones to which the factory contract will delegate calls. Each child contract or instance of the implementation contract is autonomous, with its own state variables.
We will build a frontend DApp that will connect to the smart contract and allow people to create campaigns to request funds. We will store some information off-chain on the InterPlanetary File System (IPFS) network. We will store the resulting IPFS content identifier on-chain. We will save money on gas by doing it this way instead of storing large amounts of data on-chain.
Let's get right into it and create our project. You can find the code for this tutorial on GitHub
Creating a New Hardhat Project
Steps to create a new hardhat project:
Open our terminal, navigate to the project directory, and then type the command below to initiate a new Node.js project.
npm init --y
Install hardhat
in the project by typing:
npm install --save-dev hardhat
Run the following command to create a hardhat
project and select create a Javascript project
npm hardhat
The above command will install all dependencies and bootstrap a hardhat
project in the directory.
Configuring the project
Install the dotenv
package from NPM to enable us put our secret configuration, such as private keys, inside an environment file.
npm install dotenv --save
Create a file called .env
on the project's root directory. This file will contain the following keys:
INFURA_URL =
PRIVATE_KEY=
POLY_SCAN=
The INFURA_URL
will be our Infura key to connect via Infura to Polygon Mumbai testnet network. You can register for an Infura account here and create a project that points to the Polygon Mumbai network here
The PRIVATE_KEY
will be the private key to our wallet that we will use to deploy the smart contract to the blockchain network.
The POLY_SCAN
key is our API key that Etherscan will use to verify the contract after deployment. You can signup here to obtain your key.
Note: You should never push the
.env
file to version control. It would be best if you remembered to add it to your.gitignore
file.
Open the hardhat.config.js
file in the project's root directory. This is a configuration file used by hardhat
. Replace the content with the code below:
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
module.exports = {
solidity: {
compilers: [
{
version: "0.8.0",
},
{
version: "0.8.2",
},
{
version: "0.8.1",
},
],
},
networks: {
hardhat: {
chainId: 1337,
},
mumbai: {
url: process.env.INFURA_URL,
accounts: [`0x${process.env.PRIVATE_KEY}`],
chainId: 80001,
gas: 2100000,
gasPrice: 8000000000,
},
},
etherscan: {
apiKey: process.env.POLY_SCAN,
},
};
At the top of the file, we required the @nomicfoundation/hardhat-toolbox
, provided by hardhat
. Next, we require("dotenv").config()
, this will load our environment variables into the application, and we can access the environment variables by writing:
processs.env.[Environment_Key]
Following that, we set up the Solidity compiler version that we will use to compile the project. The networks
key is configured and inside the networks
key, we have a configuration for our local hardhat environment and the mumbai
Polygon network.
We use the etherscan
apiKey
to connect to Etherscan for contract verification.
Let's open the package.json
file and write some scripts to run the project. Replace the scripts
object with the following:
"scripts": {
"deploy-local": "npx hardhat run scripts/deploy.js --network hardhat",
"deploy": "npx hardhat run scripts/deploy.js --network mumbai",
"run-local": "npx hardhat run scripts/run.js --network hardhat",
"test": "npx hardhat test --network hardhat ./test/test.js"
}
These commands will be executed and used to deploy and test our application.
Installing Openzeppelin Contracts
Openzeppelin provides audited and tested smart contracts that you can use in your project. We will add Openzeppelin contracts into our project by typing:
npm install @openzeppelin/contracts
We will use Openzeppelin's Clones
, Ownable
, and Initializable
contracts in our crowdfunding contract project. The Clones
contract is used to replicate an implementation contract that has been deployed to the blockchain. The Clones
contract duplicates the crowdfunding contract, giving each funding project its own smart contract.
We use the Ownable
contract to grant an address ownership of a smart contract, whereas the Initializable
contract acts like a constructor by ensuring that a function commonly known as initialize
is executed once.
Implementing the Factory Contract Using Openzeppelin Clonable
We will create two files; the contract factory and the crowdfunding contract. Open the contracts
folder of the project and create two files, namely CrowdFundingContract.sol
and CrowdSourcingFactory.sol
. Delete the default contract in the contracts
folder. Open the CrowdSourcingFactory.sol
file, and start creating the methods and variables in the factory contract.
//SPDX-License-Identifier:MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/proxy/Clones.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "./CrowdFundingContract.sol";
contract CrowdSourcingFactory is Ownable {
//state variables;
address immutable crowdFundingImplementation;
address[] public _deployedContracts;
uint256 public fundingFee = 0.001 ether;
//events
event newCrowdFundingCreated(
address indexed owner,
uint256 amount,
address cloneAddress,
string fundingCID
);
constructor(address _implementation) Ownable() {
crowdFundingImplementation = _implementation;
}
function createCrowdFundingContract(
string memory _fundingCId,
uint256 _amount,
uint256 _duration
) external payable returns (address) {
require(msg.value >= fundingFee, "deposit too small");
address clone = Clones.clone(crowdFundingImplementation);
(bool success, ) = clone.call(
abi.encodeWithSignature(
"initialize(string,uint256,uint256)",
_fundingCId,
_amount,
_duration
)
);
require(success, "creation failed");
_deployedContracts.push(clone);
emit newCrowdFundingCreated(msg.sender, fundingFee, clone, _fundingCId);
return clone;
}
function withdrawFunds() public onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "nothing to withdraw");
(bool success, ) = payable(msg.sender).call{value: balance}("");
require(success, "withdrawal failed");
}
function deployedCo`ntracts() public view returns (address[] memory) {
return _deployedContracts;
}
receive() external payable {}
}
We imported the Clones
and Ownable
contracts from Openzeppelin. The implementation contract CrowdFundingContract.sol
is also imported. Our contract factory inherits from the Ownable
contract; this means the Ownable
contract will manage the ownership of this contract.
contract CrowdSourcingFactory is Ownable
We define three state variables of crowdFundingImplementation
, _deployedContracts
, and fundingFee
. The crowdFundingImplementaion
variable is of type address
immutable
, and we use it to save the address of the deployed CrowdFundingcontract
. The _deployedContracts
is an array of type addresses used to save the address of each deployed cloned contract. At the same time, the fundingFee
is the fee paid before creating and deploying a new CrowdFundingContract`.
The factory contract constructor
is called with the deployed CrowdFundingContract
address. We save this address in the crowdFundingImplementation
variable. The deployer of the factory contract is the owner of the contract. You will notice that we also call the Ownable
contract constructor to assign ownership to the deployer of the contract.
Cloning a Contract
Anyone who wants to create a crowdfunding project calls the function createCrowdFundingContract.
It accepts a string
variable called _fundingCId
as a parameter. This is the IPFS hash containing the details of the crowd-sourcing project, which will be too expensive to store on-chain. The other parameters are the _amount
you want to raise and the _duration
of the campaign. These parameters are both of the 'uint256' type.
The createCrowdFundingContract
function is a payable
function, meaning the caller of the function must send Ether
that is greater or equal to the _funndingFee
.
This line clones the CrowdFundingContract
:
address clone = Clones.clone(crowdFundingImplementation);
We call the Clones
contract from the Openzeppelin clone
function, which creates a new clone of the CrowdSourcingContract
. The function returns the address of the cloned contract. Each cloned contract has a different independent state. After a successful clone of the implementation contract (CrowdSourcingContract
), we call the initialize
function using the low-level call
method.
The 'initialize' function is similar to a constructor in that we can only call it once.
(bool success, ) = clone.call(
abi.encodeWithSignature(
"initialize(string,uint256,uint256)",
_fundingCId,
_amount,
_duration
)
);
abi.encodeWithSignature
creates a function selector that is used to execute the initialize
function. The initialize
function accepts as arguments variables of string
, uint256
and uint256
. Remember that we pass these three variables to the createCrowdFundingContract
function.
We perform a check to find out if the function call was successful before we push the address of the clone contract into the _deployedContract
array and emit the
newCrowdFundingCreated
event.
Withdrawing Funds From the Factory Contract
The contract owner can withdraw the Ether in the factory contract. Only the contract owner can call the withdrawFunds
function. The onlyOwner
modifier is from the Ownable
contract. It checks if the caller msg.sender
is the contract owner. The owner can only withdraw if there is Ether inside the contract. We use the low-level call method to withdraw the Ether.
function withdrawFunds() public onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "nothing to withdraw");
(bool success, ) = payable(msg.sender).call{value: balance}("");
require(success, "withdrawal failed");
}
The receive
function is necessary so the contract can receive Ether.
receive() external payable {}
Implementing the Crowd Funding Contract
Open the CrowdFundingContract.sol
file and create the basic skeleton for the contract.
//SPDX-License-Identifier:MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/proxy/utils/Initializable.sol";
contract CrowdFundingContract is Initializable {
//state variable
address payable private _campaignOwner;
string public fundingId;
uint256 public targetAmount;
uint256 public campaignDuration;
function initialize(
string calldata _fundingId,
uint256 _amount,
uint256 _duration
) external initializer {
_campaignOwner = payable(tx.origin);
fundingId = _fundingId;
targetAmount = _amount;
campaignDuration = _duration;
}
}
At the top of the file, we import the Initializable
contract from Openzeppelin. The CrowdFundingContract
contract derives from the Initializable
contract, which allows us to use the initializer
modifier. The initialize
function acts like a constructor; we can only call it once. Remember that when we create a new instance of the CrowdFundingContract
, we call the initialize
function.
Inside the initialize
function, we set the values of some state variables. The _fundingCId
is the IPFS hash that contains details about the crowdfunding project; the targetAmount
and campaignDuration
are also set. The _campaignOwner
value equals tx.origin
.
The address that initiates the transaction is
tx.origin
. A contract may have some internal transactions, and in our case, we are calling theinitialize
contract from within thecreateCrowdFundingContract
function of theCrowdSourcingFactory
contract,msg.sender
, which generally equates to the caller of the function, will be theCrowdSourcingFactory
contract rather than the wallet owner that called the function. So,tx.origin
gives us the wallet address that started the transaction.
Functions in the Crowd Funding Contract
A crowdfunding contract needs a function to allow people to donate to the cause.
Creating the makeDonation
Function
//other part of the contract
bool public campaignEnded;
uint256 private _numberOfWithdrawal;
uint256 private _numberOfDonors;
uint256 private _amountDonated;
mapping(address => uint256) public donors;
//event
event fundsDonated(address indexed donor, uint256 amount, uint256 date);
function makeDonation() public payable {
uint256 funds = msg.value;
require(!campaignEnded, "campaign ended");
require(funds > 0, "You did not donate");
require(_numberOfWithdrawal != 3, "no longer taking donation");
if (donors[msg.sender] == 0) {
_numberOfDonors += 1;
}
donors[msg.sender] += funds;
_amountDonated += funds;
emit fundsDonated(msg.sender, funds, block.timestamp);
}
The makeDonation
function is payable
, so the function's caller needs to send Ether to the contract before calling it. We perform a check to see if the campaign has ended. The campaignEnded
variable is true when the contract owner successfully withdraws funds from the contract three times.
We have a mapping of address
to uint256
called donors
. We use the donors
mapping to save and track the donation made by people to the contract. We increment the _numberOfDonors
by one only if the address hasn't donated before. We also increment the _amountDonated
by the value of msg.value
(Ether amount sent by the caller). At the end of the function, we emit the event fundsDonated
.
Creating the Campaign Milestone Function
A campaign owner creates a milestone that donors to the campaign vote on. We save the milestone information in IPFS and the resulting CID on-chain. Below is the code used to create a milestone.
emit milestoneCreated(msg.sender, block.timestamp, votingPeriod);
enum MilestoneStatus {
Approved,
Declined,
Pending
}
contract CrowdFundingContract is Initializable {
//other parts of the code
uint32 private _milestoneCounter;
uint256 private _numberOfWithdrawal;
struct Milestone {
string milestoneCID;
bool approved;
uint256 votingPeriod;
MilestoneStatus status;
MilestoneVote[] votes;
}
struct MilestoneVote {
address donorAddress;
bool vote;
}
mapping(uint256 => Milestone) public milestones;
//event
event milestoneCreated(address indexed owner, uint256 datecreated, uint256 period);
function creatNewMilestone(string memory milestoneCID, uint256 votingPeriod)
public
{
require(msg.sender == _campaignOwner, "you not the owner");
//check if we have a pending milestone
//check if we have a pending milestone or no milestone at all
require(
milestones[_milestoneCounter].status != MilestoneStatus.Pending,
"you have a pending milestone"
);
//check if all three milestone has been withdrawn
require(_numberOfWithdrawal != 3, "no more milestone to create");
//create a new milestone increment the milestonecounter
_milestoneCounter++;
//voting period for a minimum of 2 weeks before the proposal fails or passes
Milestone storage new milestone = milestones[_milestoneCounter];
new milestone.milestoneCID = milestoneCID;
new milestone.approved = false;
new milestone.votingPeriod = votingPeriod;
new milestone.status = MilestoneStatus.Pending;
emit milestoneCreated(msg.sender, block.timestamp, votingPeriod);
}
}
Outside the contract, we created an enum
called MilestoneStatus
. We determine the state of a milestone by the enum
. Enums are user-defined types that limit the MilestoneStatus
value to Approved
, Declined
, and Pending
. We also made a struct called Milestone
. We will use this struct data type to save all milestones created. It consists of a milestoneCID,
the CID of the milestone details data saved on IPFS.
struct Milestone {
string milestoneCID;
bool approved;
uint256 votingPeriod;
MilestoneStatus status;
MilestoneVote[] votes;
}
It also has an array of MilestoneVote
. The MilestoneVote
is a struct that holds the vote of each donor on a created milestone.
struct MilestoneVote {
address donorAddress;
bool vote;
}
The createNewMilestone
function takes a string
data milestoneCID
and a uint256
votingPeriod
value as parameters. The votingPeriod
is the time frame during which donors can vote on a milestone. If the period has passed and a donor has not voted, we consider that donor to have voted for the milestone. The milestone creator can set the voting period for convenience.
We performed checks inside the function first to see if the function's caller is the _campaignOwner
and to ensure that the contract owner has not made more than three withdrawals from the contract. We want the maximum successful withdrawal to be 3.
require(_numberOfWithdrawal != 3, "no more milestone to create");
If both checks pass, we increment the _milestoneCounter
state variable by 1. Each created milestone is stored in a mapping of uint256 => Milestone.
mapping(uint256 => Milestone) public milestones;
We create a storage variable newmilestone
and save it in the location of the milestones[_milestoneCounter]
.
Milestone storage newmilestone = milestones[_milestoneCounter];
newmilestone.milestoneCID = milestoneCID;
newmilestone.approved = false;
newmilestone.votingPeriod = votingPeriod;
newmilestone.status = MilestoneStatus.Pending;
emit milestoneCreated(msg.sender, block.timestamp, votingPeriod);
A created milestone starts in the MilestoneStatus.Pending
state. We emit an event at the end of the function.
Creating the Vote on a Milestone Function
A donor to a campaign can vote on a milestone within the voting period allowed. The voteOnMilestone
function receives a bool
as a parameter. We update the user vote according to the milestone struct.
function voteOnMilestone(bool vote) public {
//check if the milestone is pending, which means we can vote
require(
milestones[_milestoneCounter].status == MilestoneStatus.Pending,
"can not vote on milestone"
);
//check if the person has voted already
//milestone.votes
//check if this person is a donor to the cause
require(donors[msg.sender] != 0, "you are not a donor");
uint256 counter = 0;
uint256 milestoneVoteArrayLength = milestones[_milestoneCounter]
.votes
.length;
bool voted = false;
for (counter; counter < milestoneVoteArrayLength; ++counter) {
MilestoneVote memory userVote = milestones[_milestoneCounter].votes[
counter
];
if (userVote.donorAddress == msg.sender) {
//already voted
voted = true;
break;
}
}
if (!voted) {
//the user has not voted yet
MilestoneVote memory userVote;
//construct the user vote
userVote.donorAddress = msg.sender;
userVote.vote = vote;
milestones[_milestoneCounter].votes.push(userVote);
}
}
We first determine whether the milestone on which the user is voting is in the Pending
state. We accomplish this by retrieving the current milestone's status. _milestoneCounter
is a state variable that keeps track of the current milestone. Because only one milestone can be active at any given time, the _milestoneCounter
will always point to the most recent milestone.
require(milestones[_milestoneCounter].status == MilestoneStatus.Pending,
"can not vote on milestone"
);
We also check if the user address is a donor by checking the donors
mapping to get the amount donated. If the amount is zero, they have not donated. We looped through the votes
array of the milestone
struct to check if the user has voted before.
bool voted = false;
for (counter; counter < milestoneVoteArrayLength; ++counter) {
MilestoneVote memory userVote = milestones[_milestoneCounter].votes[
counter
];
if (userVote.donorAddress == msg.sender) {
//already voted
voted = true;
break;
}
}
If the user has voted, we break and exist from the for
loop
, but if the user is yet to vote:
if (!voted) {
//the user has not voted yet
MilestoneVote memory userVote;
//construct the user vote
userVote.donorAddress = msg.sender;
userVote.vote = vote;
milestones[_milestoneCounter].votes.push(userVote);
}
We initialize a MilestoneVote'struct in memory and fill it with the user address and vote choice. We push the created
Milestoneinto the current
milestones`votes
array field, which is of type MilestoneVote[]
.
Creating the Withdraw Milestone Funds Function
According to the business rules, a campaign owner can only withdraw funds from the contract after three successful milestones. If the voting period has expired, you may withdraw the funds designated for the milestone. We tally the total number of donors' votes, and if the yes vote equals or exceeds two-thirds of the total number of donors, the milestone is met, and the Ether for that milestone is transferred to the campaign owner.
function withdrawMilestone() public {
require(payable(msg.sender) == _campaignOwner, "you not the owner");
//check if the voting period is still on
require(
block.timestamp > milestones[_milestoneCounter].votingPeriod,
"voting still on"
);
//check if milestone has ended
require(
milestones[_milestoneCounter].status == MilestoneStatus.Pending,
"milestone ended"
);
//calculate the percentage
(uint yesvote, uint256 novote) = _calculateTheVote(
milestones[_milestoneCounter].votes
);
//calculate the vote percentage and make room for those that did not vote
uint256 totalYesVote = _numberOfDonors - novote;
//check if the yesVote is equal to 2/3 of the total votes
uint256 twoThirdofTotal = (2 * _numberOfDonors * _baseNumber) / 3;
uint256 yesVoteCalculation = totalYesVote * _baseNumber;
//check if the milestone passed 2/3
if (yesVoteCalculation >= twoThirdofTotal ) {
//the milestone succeeds payout the money
milestones[_milestoneCounter].approved = true;
_numberOfWithdrawal++;
milestones[_milestoneCounter].status = MilestoneStatus.Approved;
//transfer 1/3 of the total balance of the contract
uint256 contractBalance = address(this).balance;
require(contractBalance > 0, "nothing to withdraw");
uint256 amountToWithdraw;
if (_numberOfWithdrawal == 1) {
//divide by 3 1/3
amountToWithdraw = contractBalance / 3;
} else if (_numberOfWithdrawal == 2) {
//second withdrawal 1/2
amountToWithdraw = contractBalance / 2;
} else {
//final withdrawal
amountToWithdraw = contractBalance;
campaignEnded = true;
}
(bool success, ) = _campaignOwner.call{value: amountToWithdraw}("");
emit fundsWithdrawn(
_campaignOwner,
amountToWithdraw,
block.timestamp
);
require(success, "withdrawal failed");
} else {
//the milestone failed
milestones[_milestoneCounter].status = MilestoneStatus.Declined;
emit milestoneRejected(yesvote, novote);
}
}
function _calculateTheVote(MilestoneVote[] memory votesArray)
private
pure
returns (uint256, uint256)
{
uint256 yesNumber = 0;
uint256 noNumber = 0;
uint256 arrayLength = votesArray.length;
uint256 counter = 0;
for (counter; counter < arrayLength; ++counter) {
if (votesArray[counter].vote == true) {
++yesNumber;
} else {
++noNumber;
}
}
return (yesNumber, noNumber);
}
The function determines whether the caller is the _campaignOwner
and whether the milestone's voting period is still active. If both checks pass, the function checks the milestone's status to ensure it is pending. We define a private function _calculateTheVote
that takes an array of MilestoneVote
as a parameter.
The function _calculateTheVote
compiles the votes and returns a tuple of yes and no votes. To calculate the total number of those who voted yes for the milestone, we subtract the no votes from the _numberOfDonors
state variable (those that do not vote are assumed to have voted for the milestone).
We need a two-thirds yes vote to pass the milestone. We multiply the total number of donors by a _baseNumber
that equals '10 ** 18`. We do this because our sample donor size may be small and to avoid rounding errors. We followed the same procedure for the yes vote.
uint256 twoThirdofTotal = (2 * _numberOfDonors * _baseNumber) / 3;
uint256 yesVoteCalculation = totalYesVote * _baseNumber;
If the yesVoteCalculation
is greater or equal to `twoThirdofTotal, that means the milestone passes. We calculate the total balance of the contract and disburse it based on the milestone withdrawal.
if (_numberOfWithdrawal == 1) {
//divide by 3 1/3
amountToWithdraw = contractBalance / 3;
} else if (_numberOfWithdrawal == 2) {
//second withdrawal 1/2
amountToWithdraw = contractBalance / 2;
} else {
//final withdrawal
amountToWithdraw = contractBalance;
campaignEnded = true;
}
If this is the first withdrawal, the available contract balance is divided by three; if this is the second withdrawal, it is divided by two; in the final withdrawal, all Ether is sent to the caller.
The calculated milestone amount is transferred from the contract using the low-level call method. A fundsWithdraw
event is emitted on a successful transfer.
(bool success, ) = _campaignOwner.call{value: amountToWithdraw}("");
require(success, "withdrawal failed");
Milestone rejected scenario; If the yesVoteCalculation
is less than twoThirdofTotal
, the milestone fails, and we set the status of the milestone to Declined
and emit an event.
//milestone failed
milestones[_milestoneCounter].status = MilestoneStatus.Declined;
emit milestoneRejected(yesvote, novote);
Creating Utility Functions
We can use other functions in the crowdfunding contract to retrieve the values of private variables.
function getDonation() public view returns (uint256) {
return _amountDonated;
}
function campaignOwner() public view returns (address payable) {
return _campaignOwner;
}
function numberOfDonors() public view returns (uint256) {
return _numberOfDonors;
}
function showCurrentMillestone() public view returns (Milestone memory) {
return milestones[_milestoneCounter];
}
Creating Unit Tests for the Smart Contract
Hardhat provides some wonderful helpers we can use when unit-testing our smart contract code. The unit test is inside the test
folder of the project. At the top of the file, we required some helpers from hardhat.
const { time, loadFixture } = require("@nomicfoundation/hardhat-network-helpers");
The time
helper is used to manipulate blockchain time, whereas the loadFixture
executes a setup function only once when called. On subsequent calls, It returns the snapshot or result of the setup function instead of re-executing it.
describe("CrowdFunding", function () {
async function setUpContractUtils() {
//1.
const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
const DEPOSIT = ethers.utils.parseEther("0.001");
const FUTURE TIME = (await time.latest()) + ONE_YEAR_IN_SECS;
let fundingCId =
"bafybeibhwfzx6oo5rymsxmkdxpmkfwyvbjrrwcl7cekmbzlupmp5ypkyfi";
let milestoneCID =
"bafybeibhwfzx6oo5rymsxmkdxpmkfwyvbjrrwcl7cekmbzlupmp5ypkyfi";
// Contracts are deployed using the first signer/account by default
const [
owner,
otherAccount,
someOtherAccount,
accountOne,
accountTwo,
accountThree,
accountFour,
] = await ethers.getSigners();
//2.deploy the contracts here
const CrowdFundingImplementation = await hre.ethers.getContractFactory(
"CrowdFundingContract"
);
const crowdFundingImplementation =
await CrowdFundingImplementation.deploy();
await crowdFundingImplementation.deployed();
//3. deploy the factory contract
const CrowdFundingFactory = await hre.ethers.getContractFactory(
"CrowdSourcingFactory"
);
const crowdFundingFactory = await CrowdFundingFactory.deploy(
crowdFundingImplementation.address
);
await crowdFundingFactory.deployed();
//4 deploy a new crowdfunding contract
let txn = await crowdFundingFactory
.connect(otherAccount)
.createCrowdFundingContract(fundingCId, DEPOSIT, FUTURE TIME, {
value: DEPOSIT,
});
let wait = await txn.wait();
const cloneAddress = wait.events[1].args.cloneAddress;
//5. use the clone
let instanceOne = await hre.ethers.getContractAt(
"CrowdFundingContract",
cloneAddress,
otherAccount
);
let txnTwo = await crowdFundingFactory
.connect(otherAccount)
.createCrowdFundingContract(fundingId, DEPOSIT, FUTURETIME, {
value: DEPOSIT,
});
let waitTwo = await txnTwo.wait();
const cloneAddressTwo = waitTwo.events[1].args.cloneAddress;
let instanceTwo = await hre.ethers.getContractAt(
"CrowdFundingContract",
cloneAddressTwo,
someOtherAccount
);
return {
FUTURE TIME,
DEPOSIT,
owner,
otherAccount,
someOtherAccount,
contractFactory: crowdFundingFactory,
fundingId,
amountToDeposit,
instanceOne,
instanceTwo,
milestoneCID,
accountOne,
accountTwo,
accountThree,
accountFour,
};
}
}
We defined an async function setUpContractUtils
which will be passed to the loadFixtures
. The setUpContractUtils
functions deploys the crowdfunding contract and the contract factory. We returned an object of contract instances used to run our test. You can find the complete test in the test
folder inside the project directory.
The code above sets up and deploys the implementation contract (crowdfunding contract) and the factory contract. Let us break it down using the number in the comment to explain each code section.
- At the top of the function, we define some constant variables. Using the
time
method provided by hardhat, we defined aFUTURE TIME
time variable which is the sum of the current blockchain time and a year in seconds. We also defined 'fundingCId' and'milestoneCId' variables, representing the IPFS content hash of the data stored off-chain for our purposes. - The crowdfunding contract (implementation contract) is deployed.
- We deployed the factory contract passing in the address of the previously deployed smart contract. (the address of the deployed crowdfunding contract). Remember, we will use the factory to create clones of the implementation contract.
We deploy a new instance of the crowdfunding contract.
let txn = await crowdFundingFactory.connect(otherAccount) .createCrowdFundingContract(fundingCId, DEPOSIT, FUTURETIME, { value: DEPOSIT, });
We connect to the
crowdFundingFactory
contract instance with the signerotherAccount
and call thecreateCrowdFundingContract
passing in thefundingCID
,DEPOSIT
andFUTURE TIME
, represent the IPFS hash, the amount we are raising, and the duration of the crowdfunding campaign. We also send Ether to the factory method that will create the clone.We await the transaction and retrieve the address of the clone from the factory contract event.
let wait = await txn.wait(); const cloneAddress = wait.events[1].args.cloneAddress;
We use the new clone address to retrieve an instance of newly created crowdfunding contract.
let instanceOne = await hre.ethers.getContractAt("CrowdFundingContract", cloneAddress, otherAccount );
To make use of any variable exported from the
setUpContractUtils
function we awaitloadFixture(setUpContractUtils)
const { instanceOne, otherAccount, someOtherAccount } =
await loadFixture(setUpContractUtils);
To run our test :
npm run test
Writing a Deployment Script
We are going to write a deploy script. Open the scripts
folder and create a new file called deploy.js
. We should deploy our implementation contract first before the factory contract. The factory contract creates a clone of the implementation contract.
const hre = require("hardhat");
async function main() {
//deploy the crowdfunding contract implementation
const CrowdFundingImplementation = await
hre.ethers.getContractFactory("CrowdFundingContract");
console.log("deploying the implementation contract")
const crowdFundingImplementation = await CrowdFundingImplementation.deploy();
await crowdFundingImplementation.deployed();
console.log("deployed the implementation contract with address : ",
crowdFundingImplementation.address);
//create the factory contract
const CrowdFundingFactory = await
hre.ethers.getContractFactory("CrowdSourcingFactory");
const crowdFundingFactory = await
CrowdFundingFactory.deploy(crowdFundingImplementation.address);
console.log("deployed the factory contract");
await crowdFundingFactory.deployed();
console.log("deployed the factory contract with to : ", crowdFundingFactory.address);
}
main().catch((error) => {
console.error("There was an error",error);
process.exitCode = 1;
});
To run the deployment on the local hardhat network, we run this command on the terminal:
npm run deploy-local
Deploying to the Mumbai network, we run this command on the terminal:
npm run deploy
The commands are set up in the
package.json
file.
In the Github project, there is a run.js
script you can execute by running :
npm run run-local
Conclusion
In this tutorial, we learned how to create a crowdfunding contract. We implemented the Openzeppelin Clones using a factory contract to create clones from an implementation contract. We also deployed and tested the smart contract. In the next section of the series, we will create and deploy the crowdfunding contract off-chain data to IPFS.
Thanks for reading.