Building a Project with SE-2 | Crowd Fund | Part Two | The Contract

WebSculpt
10 min readDec 21, 2023

--

Image from Shubham Dhage on Unsplash

If you are new to Scaffold-ETH-2 (SE-2), check out my Intro into Scaffold-ETH-2 blog.
Want to learn a little bit more before you get started? Here’s a post about writing, reading, and listening to Smart Contracts using SE-2.

This post covers the Smart Contract for the project we are building: Crowd Fund V1. The next blog will go over the frontend components. Looking for Part One of this series? Here it is.

Before we begin, it is helpful to understand the SE-2 workflow a bit…

You’re going to be developing with 3 terminals opened (at least). One runs your local hardhat network, another starts your NextJS app, and the third will be used to deploy/re-deploy your contracts. The first two terminals will be taken-over. Then — after you have deployed your contract— you can work, test, re-deploy from that third terminal.

Ready? Here’s the Readme for the project (might be helpful to keep that opened for reference as you read along).

Prerequisites

You need to install the following tools before working with SE-2:

Cloning and Installing

EITHER: get a fresh copy of SE-2

git clone https://github.com/scaffold-eth/scaffold-eth-2.git
cd scaffold-eth-2
yarn install

OR: get the completed Crowd Fund V1 Project

git clone https://github.com/nathan-websculpt/crowd-fund.git
cd crowd-fund
yarn install

First Terminal (run your local hardhat network)

yarn chain

Second Terminal (start your NextJS app)

yarn start

Third Terminal (deploy your Smart Contract)

yarn deploy

Note that there are multiple branches of SE-2 that offer different packages (like the very useful ‘Subgraph-Package’). I coded this project in TypeScript, but JavaScript versions of SE-2 are also available.

Crowd Fund V1

Live Demo
GitHub
Contract (on Sepolia)
On BuidlGuidl

Recall that V1 will be a foundation (where users can ask for donations), and V2/V3 represent extremes: where multisig transactions are managed on/off contract.

The Contract

CrowdFund.sol

The first struct (Learn more about structs) is defined as FundRun — which has data necessary for managing these “Fund Runs” … as well as some data that will be helpful when you are testing.

Too Long; Didn’t Read. In Solidity, structs are user-defined data types that allow us to group multiple variables of different types under a single name, making it easier to manage and organize data in your smart contracts.

Here’s More about Structs in Solidity, from Kristaps Grinbergs

A lot about a Fund Run’s state/status can be garnered from these four lines:

uint256 target;
uint256 deadline;
uint256 amountCollected;
uint256 amountWithdrawn;
  • The target is the Money Goal/Target a Fund Run is seeking
  • The deadline is defined by the user at time of creation (in minutes) and then added to the block.timestamp
  • The amountCollected will increase as funds are donated to this Fund Run
  • The amountWithdrawn will increase as funds are withdrawn from this Fund Run

Fund Run Struct:

struct FundRun {
uint16 id;
address owner;
string title;
string description;
uint256 target;
uint256 deadline;
uint256 amountCollected;
uint256 amountWithdrawn;
address[] donors;
uint256[] donations;
bool isActive;
}

Note that these will be stored via this mapping (Learn more about mappings):

mapping(uint256 => FundRun) public fundRuns;

The ‘Key of ☝️ this mapping ☝️ will be the ‘Fund Run ID’, and it will map to the Fund Run (the struct). We will keep up with the ID of a Fund Run with a simple counter that is defined like this 👇

 uint16 public numberOfFundRuns = 0;

Later, it is incremented (after a new Fund Run is successfully saved):

FundRun memory fundRun = FundRun({
id: numberOfFundRuns,
...
.....
});

fundRuns[numberOfFundRuns] = fundRun;
numberOfFundRuns++;

With that, we have what it takes to save a Fund Run and ask for it later. The IDs will increment as you save new Fund Runs.

Knowing when the deadline is: We’ll do this with a modifier (Learn more about modifiers). We’ll get the correct Fund Run using its ID, and then we will check its deadline against the block.timestamp.
***Note that a better name for the boolean fundRunHasCompleted would have been fundRunHasToBeCompleted as you will use it to discern which type of check you do (greater-than vs. less-than).

modifier fundRunCompleted(uint16 id, bool fundRunHasCompleted) {
FundRun storage fundRun = fundRuns[id];
if (fundRunHasCompleted) {
require(
fundRun.deadline < block.timestamp,
"This Fund Run is not complete."
);
_;
} else {
require(
fundRun.deadline > block.timestamp,
"This Fund Run has already completed."
);
_;
}
}

The snippet above is saying: IF the Fund Run needs to be completed (in order to continue), then its deadline must be less-than the block.timestamp; Else, its deadline must be greater-than the block.timestamp.
Call it like this: fundRunCompleted(_id, false)

If this seems confusing, just remember that we are going to use this modifier in two scenarios: When a Fund Run SHOULD be completed vs. when a Fund Run should NOT be completed. Keep going, it will make sense soon.

Knowing when enough Ether has been collected: This modifier will check the Fund Run’s ‘Amount Collected’ against its ‘Target’.
***Note that a better name for the boolean fundRunHasSucceeded would have been something like fundRunMustHaveSucceeded as you will use it to discern which type of check you should be doing (greater-than vs. less-than).

modifier fundRunSucceeded(uint16 id, bool fundRunHasSucceeded) {
FundRun storage fundRun = fundRuns[id];
if (fundRunHasSucceeded) {
require(
fundRun.amountCollected >= fundRun.target,
"This Fund Run has not yet met its monetary goal."
);
_;
} else {
require(
fundRun.amountCollected < fundRun.target,
"This Fund Run has already met its monetary goal."
);
_;
}
}

The snippet above is saying that IF TRUE: the Collected-Amount must be greater-than/equal-to the Target; Else, the Collected-Amount must be less-than the Target.
Call it like this: fundRunSucceeded(_id, false)

Knowing who an owner is: There is a third modifier that is pretty simple to use. There is no need in displaying the entire modifier here. Just send it the Fund Run ID, the Wallet Address, and whether or not the person is supposed to own this Fund Run, like this: ownsThisFundRun(_id, msg.sender, false)

What’s with all these modifiers?

These modifiers in particular are going to spare us from having to write the same code over-and-over.
For one example, we do not want a user donating to their own Fund Run. We also wouldn’t want a user donating to a Fund Run that has already ended! We can handle both of these with two lines of code:

ownsThisFundRun(_id, msg.sender, false)
fundRunCompleted(_id, false)

Beyond this, the logic for a ‘Donor Withdrawal’ and an ‘Owner Withdrawal’ are very different:

  • A donor can get their funds back from a Fund Run IF:
    – The deadline has passed
    – The fund failed to raise its target capital
  • An owner can get their new donations IF:
    – The deadline has passed
    – The fund’s donations are greater-than/equal-to the target money goal

This is such that an ‘Owner Withdrawal’ requires this:

ownsThisFundRun(_id, msg.sender, true)
fundRunCompleted(_id, true)
fundRunSucceeded(_id, true)

And a ‘Donor Withdrawal’ requires this:

ownsThisFundRun(_id, msg.sender, false)
fundRunCompleted(_id, true)
fundRunSucceeded(_id, false)

The Creation of a Fund Run

We will call createFundRun with a Title, Description, Target, and Deadline:

function createFundRun(
string memory _title,
string memory _description,
uint256 _target,
uint16 _deadline
) public {
...
.....
}

Then the Fund Run’s Deadline is calculated:
uint256 fundRunDeadline = block.timestamp + _deadline * 60;

Title and Description are Required Fields

You can’t do string-comparisons with solidity, but you can compare bytes32 objects (hashes)! Keccak256 to the rescue.

That allows us to hash an empty string, hash the Title, and then compare the two like this:

bytes32 baseCompare = keccak256("");
bytes32 titleCompare = keccak256(bytes(_title));
require(titleCompare != baseCompare, "Title was an empty string.");

Arrays for testing

You can research for yourself as to why you don’t want to have a bunch of arrays in a mainnet contract (will be covering this in V2/V3).
But for now, these will be helpful (as you test/debug) to see everything that is going on. A Fund Run has these little guys👇:

address[] donors;
uint256[] donations;

Use them ☝️ like this 👇:

fundRun.donors.push(msg.sender);
fundRun.donations.push(amount);

If you ever have any mix-ups, check those two arrays to be sure the right users are donating to the correct Fund Runs.
There is another helpful array (of all the owners) on line 45:
address[] public fundRunOwners;

Here is the whole createFundRun process…

function createFundRun(
string memory _title,
string memory _description,
uint256 _target,
uint16 _deadline
) public {
uint256 fundRunDeadline = block.timestamp + _deadline * 60;
require(
fundRunDeadline > block.timestamp,
"The deadline would ideally be a date in the future there, Time Traveler."
);
bytes32 baseCompare = keccak256("");
bytes32 titleCompare = keccak256(bytes(_title));
bytes32 descriptionCompare = keccak256(bytes(_description));
require(
titleCompare != baseCompare && descriptionCompare != baseCompare,
"Title and Description are both required fields."
);
require(_target > 0, "Your money target must be greater than 0.");

address[] memory donorArray;
uint256[] memory donationsArray;
FundRun memory fundRun = FundRun({
id: numberOfFundRuns,
owner: msg.sender,
title: _title,
description: _description,
target: _target,
deadline: fundRunDeadline,
amountCollected: 0,
amountWithdrawn: 0,
isActive: true,
donors: donorArray,
donations: donationsArray
});

fundRuns[numberOfFundRuns] = fundRun;
fundRunOwners.push(msg.sender);
numberOfFundRuns++;

emit FundRunCreated(
fundRun.id,
fundRun.owner,
fundRun.title,
fundRun.target
);
}

Donating

We have enough here to keep up with a Fund Run’s funds. We even have enough to keep those funds separated. It’s worth stopping here and trying to figure out why we are far from finished. Beyond the obvious “We need to make a payable function in order to accept donations,” what is it that is missing?

The problem you would run into is that — well, the pesky donors are going to expect their funds back in the event a Fund Run fails. We currently have no way of knowing what funds belong to which user! This would (currently) be fine, if Fund Runs could take any-and-all funds that are donated to them, but … these are behaving more like ‘Vaults’ that have rules.

So, let’s get the Donors stored in a way that is valuable to us.

The below code displays a struct that will contain a mapping that maps Fund Run ID to a Donation Amount.

/**
* @dev an address _> has a DonorsLog
* a Donor Log _> has a mapping of
* FundRunId => donationAmount
*/
struct DonorsLog {
address donor;
mapping(uint256 => uint256) donorMoneyLog; //mapping(fundRunId => donationAmount)
}

The DonorsLog struct will help us keep donations logged separately for Donor Withdrawals

//a single donor will have all of their logs (across all Fund Runs they donated to) here
mapping(address => DonorsLog) public donorLogs;

When a user donates to a Fund Run, we will get/create the log:

DonorsLog storage donorLog = donorLogs[msg.sender];

If this user has donated to this Fund Run before, we will see it here:

uint256 previouslyDonated = donorLog.donorMoneyLog[fundRun.id];

Every ‘Donor’ will have one ‘Donor Log’. Donations to individual Fund Runs can be gathered from the mapping donorMoneyLog and — for example — if a user donates to the same Fund Run twice, there is *no need* for a new log/entry … just update the value on the mapping, like this:

donorLog.donorMoneyLog[fundRun.id] = amount + previouslyDonated;

Withdrawals and a note about Reentrancy

🤢🤢🤢🤢🤢🤢🤢

If you need more information about Reentrancy Attacks (and how to prevent them), some of my earliest blog posts go over that. Here are some other resources:
solidity by example
Hackernoon
GeeksForGeeks
Alchemy

When it comes to preventing Reentrancy Attacks…
The rule-of-thumb is this: No modifications to state (or changes resulting from interaction) after transactions occur.

Why?

Because someone can basically cause this (sort-of) infinite loop in your contract (beginning at the point of transaction [from within your contract]), so if a developer has placed the line-of-code that updates a user’s Money-Balance up under the line-of-code that transacts (then the balance will never get updated [meaning: your contract won’t know to STOP giving this person/contract money]).

Simple Prevention

In our withdrawals, we will finish updating variables BEFORE we send the user their funds:

fundRun.amountWithdrawn = fundRun.amountWithdrawn + amountToWithdraw;
fundRun.isActive = false;

(bool success, ) = payable(msg.sender).call{ value: amountToWithdraw }("");

require(success, "Withdrawal reverted.");
if (success) emit OwnerWithdrawal(fundRun.owner, amountToWithdraw);

The Owner Withdrawal

Most of this function is a series of checks to make sure it is actually OK to send these funds to this user:

function fundRunOwnerWithdraw(
uint16 _id
)
public
ownsThisFundRun(_id, msg.sender, true)
fundRunCompleted(_id, true)
fundRunSucceeded(_id, true)
{
FundRun storage fundRun = fundRuns[_id];
require(fundRun.amountCollected > 0, "There is nothing to withdraw");
require(
fundRun.amountCollected > fundRun.amountWithdrawn,
"This Fund Run is empty -- a withdrawal may have already occurred."
);
uint256 amountToWithdraw = fundRun.amountCollected -
fundRun.amountWithdrawn;
require(
amountToWithdraw > 0,
"There is nothing to withdraw -- a withdrawal may have already occurred."
);

/**
* @dev ADD the would-be withdrawal amount to the actual withdrawn amount
* and ensure they are going to be less-than/equal-to the Fund Run's total balance ("amountCollected")
*/
require(
(amountToWithdraw + fundRun.amountWithdrawn) <=
fundRun.amountCollected,
"This Fund Run is hereby prevented from being over-drawn."
);

fundRun.amountWithdrawn = fundRun.amountWithdrawn + amountToWithdraw;
if (fundRun.isActive) fundRun.isActive = false;

(bool success, ) = payable(msg.sender).call{ value: amountToWithdraw }("");

require(success, "Withdrawal reverted.");
if (success) emit OwnerWithdrawal(fundRun.owner, amountToWithdraw);
}

Looking at the WHOLE PROCESS (through the lens of This Test File)…

(Tests handled via ethers.js library)
A Fund Run is created 👇

const tx = 
await crowdFund.connect(walletSigning)
.createFundRun(title, description, targetAmount, deadline);

Then Donations can be made to it 👇

const tx = 
await crowdFund.connect(walletSigning)
.donateToFundRun(fundRunId, { value: donationAmount });

Here’s an example where our test will wait for the Fund Run to end naturally, before performing an Owner Withdrawal 👇

it("Should allow for Alice to do an 'Owner Withdrawal' because her Fund was successful", async function () {
do {
await setTimeout(5000); //wait 5 more seconds
} while (alicesDeadline.toBigInt() > BigInt((await getBlock()).toString()));

const [, , alice] = await ethers.getSigners();
const tx =
await crowdFund.connect(alice)
.fundRunOwnerWithdraw(alicesId);
});

A Donor Withdrawal is similar 👇

const tx = 
await crowdFund.connect(bob)
.fundRunDonorWithdraw(johnsId);

If you want to look over the contract as a whole, it is right here.

If you are ready to move on to the next part to see how we can hit this contract from our frontend, click this link to continue.

--

--

WebSculpt

Blockchain Development, coding on Ethereum. Condensed notes for learning to code in Solidity faster.