DEV Community

Mosunmola Balogun
Mosunmola Balogun

Posted on

How I Built a Decentralized Crowdfunding App with Foundry(incl. unit tests)

Recently, I completed the Foundry Solidity Smart Contract course by Patrick Collins. Throughout the course, I gained hands-on experience with generating randomness on blockchains using Chainlink VRF, working with ERC20s and NFTs, exploring DeFi, building upgradeable smart contracts, and diving into DAOs—all using the powerful tools provided by Foundry.

To cement some of the concepts I had learned, I decided to get my hands dirty by building a Crowdfunding dApp which will be discussed in this article.

This article will cover a range of beginner-friendly concepts, from using struct to define complex data types, utilizing mappings to store and access campaign data efficiently, and implementing require statements to enforce critical conditions and ensure contract integrity. You'll also learn how to send ETH to a function securely and declare custom error types in Solidity to handle exceptions. Additionally, I'll walk you through writing comprehensive tests in Solidity to validate your contract’s functionality.

Finally, we'll generate an RPC URL with Alchemy to interact with the blockchain seamlessly and explore how to deploy your contracts on the Sepolia testnet.

Better yet, we'll put all of this into practice using a real code editor—preferably VSCode.

Prerequisites

  1. A solid understanding of fundamental blockchain concepts, particularly smart contracts, and their functionality.
  2. Basic familiarity with Solidity for writing smart contracts, including key concepts such as functions, modifiers, events, and error handling (though I will briefly explain them throughout the article).
  3. A working knowledge of Makefiles and their use in automating project setups.

When you're ready, let's dive in! 🚀

Foundry installation & project setup

Foundry is a comprehensive toolchain for smart contract development. It handles your dependencies, compiles your project, runs tests, deploys contracts, and enables interaction with the blockchain via the command line and Solidity scripts.

Note: If you're new to Foundry and using Windows, you'll need to use the WSL as your terminal, since Foundry doesn't currently support Powershell or the command prompt.

For first-time Foundry users, open your terminal and run the following command:

curl -L https://foundry.paradigm.xyz | bash
Enter fullscreen mode Exit fullscreen mode

This will install Foundryup. Simply follow the on-screen instructions to make the Foundryup command available in your CLI.

Next, run the Foundryup command in your terminal. This will install the latest (nightly) precompiled binaries: forge, cast, anvil, and chisel.

Once installation is complete, open your code editor, navigate to your projects folder, and create a directory for the crowdfunding dApp project we're about to build. For this tutorial, I'll name mine crowdfunding-dApp.

Finally, to initialize a Foundry application, run the following command in your terminal:

forge init
Enter fullscreen mode Exit fullscreen mode

If successful, your project structure should appear like this:

Foundry app project structure

Inside the src folder, you'll find boilerplate code for a counter contract, with corresponding scripts and tests in the script and test folders. Delete these files so we can start our project from scratch.

This takes us to the next section.

Developing the Crowdfunding Smart Contract

Before diving into the implementation, let’s outline the key features of this project:

  • Create a function that allows users to launch campaigns with a specific funding goal and deadline. This function should revert with an error if the specified deadline is in the past.
  • Develop a function that enables donors to contribute to a campaign by sending funds to the campaign creator’s address. This function should revert with an error in the following scenarios:
    • If the donor sends zero ETH.
    • If the campaign’s deadline has already passed.
    • If the campaign’s funding target has already been reached.

For each of these conditions, we’ll define custom errors.

To get started, create a new file in your src folder named crowdfunding.sol. This file will contain the smart contract code for our crowdfunding dApp.

Begin by pasting the following code, which specifies the Solidity version and defines the contract. Notice that we have also defined the custom errors we'll use throughout the contract:

//SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

contract Crowdfunding {
    error Crowdfunding__DeadlineMustBeInTheFuture();
    error Crowdfunding__CantSendZeroEth();
    error Crowdfunding__CampaignDeadlineElapsed();
    error Crowdfunding__TargetMetForCampaign();
}
Enter fullscreen mode Exit fullscreen mode

Next, we’ll outline the campaign structure using the struct keyword and declare the necessary state variables. Inside the contract, add the following code:

struct Campaign {
    address owner;
    string title;
    string description;
    uint256 target;
    uint256 deadline;
    uint256 amountCollected;
    string image;
    address[] donators;
    uint256[] donations;
}

mapping(uint256 => Campaign) public campaigns;
uint256 public campaignId = 0;
Enter fullscreen mode Exit fullscreen mode

In this snippet, we define a struct to represent each campaign, and we declare a mapping variable that links a uint256 type to the Campaign struct. If you’re familiar with JavaScript, you can think of a mapping as an object that stores data in key-value pairs.

Lastly, we introduced a campaignId variable, which will serve as the key for our mappings as we add new campaigns.

With this foundation in place, we’re ready to start creating the contract functions.

To begin, we'll create a function that allows users to launch a campaign. Add the following code snippet to your file. Comments have been included to clarify each part:

function createCampaign(
    address _owner,
    string memory _title,
    string memory _description,
    uint256 _target,
    uint256 _deadline,
) public returns (uint256) {
    Campaign storage campaign = campaigns[campaignId]; // Assign a key to the mapping

    if (_deadline < block.timestamp) {
        revert Crowdfunding__DeadlineMustBeInTheFuture();
    }
    // Populate the campaign details using the provided input
    campaign.owner = _owner;
    campaign.title = _title;
    campaign.description = _description;
    campaign.target = _target;
    campaign.deadline = _deadline;
    campaign.amountCollected = 0;

    campaignId++; // Increment the state value after a new campaign is added
    return campaignId - 1; // Return the index of the newly created campaign
}
Enter fullscreen mode Exit fullscreen mode

The createCampaign function is designed to allow users to create new crowdfunding campaigns on the blockchain. It takes several parameters, including the campaign owner's address, the title and description of the campaign, the funding target, and the deadline for the campaign. The function first checks if the provided deadline is in the future; if it's not, the function reverts with an error, Crowdfunding__DeadlineMustBeInTheFuture(). If the deadline is valid, the function then stores the campaign details in a mapping using the current campaignId as the key.

After storing the campaign information, the function increments the campaignId to ensure each new campaign gets a unique identifier. It finally returns the id of the newly created campaign. This id can be used to reference the campaign for future actions, like making donations. The function essentially sets up the campaign's foundation, ensuring it has all the necessary information and a unique identifier for tracking.

Next, we'll implement the function for donating to the campaign. Add the below function to your contract.

function donateToCampaign(uint256 _campaignId) public payable {
        // revert if donor isnt sending anything
        if (msg.value <= 0) {
            revert Crowdfunding__CantSendZeroEth();
        }
        //  revert if deadline is in the past
        if (campaigns[_campaignId].deadline < block.timestamp) {
            revert Crowdfunding__CampaignDeadlineElapsed();
        }
        // revert if target is met
        if (campaigns[_campaignId].amountCollected == campaigns[_campaignId].target) {
            revert Crowdfunding__TargetMetForCampaign();
        }

        Campaign storage campaign = campaigns[_campaignId];

        uint256 remainingFundsNeeded = campaign.target - campaign.amountCollected;
        // Handle contributions based on the remaining funds needed
        // next code block optimized for CEI
        if (msg.value <= remainingFundsNeeded) {
            campaign.amountCollected += msg.value;
            campaign.donators.push(msg.sender);
            campaign.donations.push(msg.value);
            (bool callSuccess,) = payable(getCampaign(_campaignId).owner).call{value: msg.value}("");
            // reupdate state variables
            if (!callSuccess) {
                campaign.amountCollected -= msg.value;
                campaign.donators.pop();
                campaign.donations.pop();
            }
        } else {
            // Handle excess contributions and refunds
            uint256 excessAmount = msg.value - remainingFundsNeeded;
            uint256 amountToDonate = msg.value - excessAmount;

            // Refund the excess amount to the contributor
            payable(msg.sender).transfer(excessAmount);

            // Update the total contributions with the amount that was supposed to be donated
            campaign.amountCollected += amountToDonate;
            campaign.donators.push(msg.sender);
            campaign.donations.push(amountToDonate);
            (bool callSuccess,) = payable(getCampaign(_campaignId).owner).call{value: amountToDonate}("");
            if (!callSuccess) {
                campaign.amountCollected -= amountToDonate;
                campaign.donators.pop();
                campaign.donations.pop();
            }
        }
    }

function getCampaign(uint256 _campaignId) public view returns (Campaign memory) {
     return campaigns[_campaignId];
}
Enter fullscreen mode Exit fullscreen mode

In the above code block, the donateToCampaign function is designed to facilitate secure contributions to a specific crowdfunding campaign on the blockchain. It first performs several checks to ensure the donation is valid: it verifies that the donor is sending a non-zero amount of ETH, ensures that the campaign’s deadline hasn’t passed, and confirms that the campaign hasn’t already met its funding target. If any of these conditions are not met, the function reverts the transaction, preventing the donation from going through.

Once the initial checks are passed, the function handles the donation by updating the campaign's total funds collected and recording the donor’s contribution. If the donation exceeds the amount needed to meet the campaign’s target, the excess is refunded to the donor, and only the necessary amount is added to the campaign’s funds. The function also attempts to transfer the donation directly to the campaign owner, and if this transfer fails, it reverts all state changes to maintain the integrity of the campaign’s data.

We also implemented a getter function, getCampaign, which allows users to view the details of any campaign using its unique id. It provides transparency by giving access to all relevant campaign information without altering any data on the blockchain. This ensures that potential donors or interested parties can easily check the status and details of a campaign before deciding to contribute.

To complete our contract, we’ll add two important getter functions: getDonators and getCampaigns. These functions are essential for retrieving key information about the campaigns and will be particularly useful when writing tests.

In your contract, include the following code:

function getDonators(uint256 _campaignId) public view returns (address[] memory) {
   return campaigns[_campaignId].donators;
}

function getCampaigns() public view returns (Campaign[] memory) {
    Campaign[] memory allCampaigns = new Campaign[](campaignId); // create a new array of length campaignId
    require(allCampaigns.length > 0, "No campaign has been created yet");
      for (uint256 i = 0; i < campaignId; i++) {
            allCampaigns[i] = campaigns[i];
       }
       return allCampaigns;
}
Enter fullscreen mode Exit fullscreen mode

The getDonators function returns a list of all addresses that have donated to a specific campaign, allowing us to easily track contributors. The getCampaigns function, on the other hand, compiles all the campaigns into an array, providing a comprehensive view of every campaign created so far.

In the next section, we'll move on to writing tests for the contract.

Testing the Crowdfunding Smart Contract

In Foundry, each time a test is run, the contract is deployed on the local Anvil chain, typically through a setup configuration. This ensures that a new contract is deployed for each test, providing a clean state for every test case.

However, I like to separate the deployment logic from the tests themselves by implementing a deployment script independently of the tests—though this isn't strictly necessary. If this seems unclear now, don't worry—you’ll get more insights in the next sub-section.

Implementing the deploy script

First, create a new file in the script folder of your project and name it DeployCrowdfunding.s.sol. This follows the standard naming convention for script files and is where we'll write our deployment logic.

Paste the following code into it:

//  SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

import {Script} from "forge-std/Script.sol";
import {Crowdfunding} from "../src/Crowdfunding.sol";

contract DeployCrowdfunding is Script {
    function run() external returns (Crowdfunding) {
        vm.startBroadcast();
        Crowdfunding crowdfunding = new Crowdfunding();
        vm.stopBroadcast();
        return crowdfunding;
    }
}
Enter fullscreen mode Exit fullscreen mode

The script above begins by importing necessary modules, including the Script from forge-std, a standard utility for writing scripts in Foundry. The DeployCrowdfunding contract, which inherits the Script contract, defines a run function that is automatically executed when the script is run. Within this function, the deployment process is bracketed by vm.startBroadcast() and vm.stopBroadcast(), which signal the beginning and end of the broadcasted deployment on the blockchain. The Crowdfunding contract is then instantiated and deployed, and the resulting contract instance is returned.

With this, we have handled the deployment of the Crowdfunding contract, we can now go ahead to write tests.

Writing Tests

Here's a quick breakdown of the tests we will be writing; these tests will cover a range of scenarios to guarantee the robustness of our contract.

  1. testCampaignCreationRevertsIfDeadlineIsNotInTheFuture: When a user attempts to create a campaign with a date that's in the past—this test ensures that such attempts are blocked, safeguarding against illogical campaign setups.

  2. testCreateCampaign: This is the core test that verifies if a campaign can be successfully created. We'll check that all the campaign details, like the title, description, target, and deadline, are stored correctly and accessible through the contract.

  3. testDonationRevertsWhenZeroEthIsDonated: This test ensures our contract correctly rejects any attempts to donate without sending ETH, preventing empty contributions from slipping through.

  4. testDonationRevertsWhenDeadlinePasses: Donations should only be accepted within the campaign's active period. This test ensures that the contract stops accepting donations once the deadline has passed.

  5. testDonationRevertsWhenTargetIsMet: When a campaign hits its target, there's no need for further contributions. This test verifies that the contract prevents overfunding and respects the campaign's goals.

  6. testDonateToCampaign: Here, we'll ensure that donations are processed correctly—funds are received, campaign balances are updated, and the donor’s details are recorded accurately.

  7. testReverseExcessAmountToSender: Sometimes, a donor might send more ETH than needed to reach the target. This test ensures that any excess funds are returned to the sender, maintaining fairness and transparency.

Each of these tests ensures that our crowdfunding platform works securely, and ultimately, as intended. Now, let's start implementing the test contract.

Navigate to the test folder and create a new file named CrowdfundingTests.t.sol, this follows the standard naming convention for test files. Paste the following code into the newly created file:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {Crowdfunding} from "../src/Crowdfunding.sol";
import {DeployCrowdfunding} from "../script/DeployCrowdfunding.s.sol";

contract CrowdfundingTests is Test {
}
Enter fullscreen mode Exit fullscreen mode

Here, we've laid the foundation for our test suite. The Test contract from the Foundry standard library provides us with a range of tools to simulate blockchain transactions and verify that our contract behaves as expected.

We've also imported our Crowdfunding contract from the src folder, which will serve as the focus of our tests. Additionally, we imported the DeployCrowdfunding contract from the script folder to handle the deployment of our Crowdfunding contract when we run tests. Finally, we declared the CrowdfundingTests contract, which inherits the Test contract.

To effectively simulate the creation of a campaign and the process of making donations, we need to set up some initial parameters that will be used across our test functions. These parameters are crucial for mimicking real-world scenarios where multiple users interact with our contract.

Paste the code below inside the CrowdfundingTests contract.

Crowdfunding crowdFunding;
address owner = makeAddr("user");
address donor1 = makeAddr("donor1");
address donor2 = makeAddr("donor2");

uint256 campaignId;
string constant TITLE = "Help Fund My Project";
string constant DESCRIPTION = "A project that will change the world!";
uint256 constant TARGET = 5 ether;
uint256 deadline = block.timestamp + 7 days;
Enter fullscreen mode Exit fullscreen mode

In the first line, we declared an instance of the Crowdfunding contract. This instance will allow us to directly interact with the contract’s external functions during our tests.

Next, we define addresses for the owner and two donors. The makeAddr function is a utility from the Foundry framework that generates Ethereum addresses for testing purposes. These addresses represent the different actors in our crowdfunding scenario, with the owner being the campaign creator and the donors being contributors to the campaign.

We then introduce a campaignId, which will store the unique identifier of the campaign created during the tests. This id is essential for referencing specific campaigns, especially when testing multiple campaign creations.

Lastly, we implement parameters needed to simulate a realistic campaign which are passed as arguments when creating a campaign.

With the initial setup complete, we can start writing our actual tests. However, before diving into the individual test cases, it's essential to configure a setUp function. This function, which we mentioned earlier, ensures that each test begins with a fresh and consistent state, making our testing process more reliable.

Paste the code snippet below into your CrowdfundingTest contract to configure the setup function.

function setUp() public {
   DeployCrowdfunding deployCrowdFunding = new DeployCrowdfunding();
   crowdFunding = deployCrowdFunding.run();
   vm.deal(owner, 10 ether);
   vm.deal(donor1, 5 ether);
   vm.deal(donor2, 5 ether);
}
Enter fullscreen mode Exit fullscreen mode

The first line within the setUp function creates a new instance of the DeployCrowdfunding contract.

Next, we call the run function within the DeployCrowdfunding contract, which handles the actual deployment of the Crowdfunding contract as we implemented in the script.

The last three lines use a Foundry cheatcode to simulate funding the owner and donor addresses with specific amounts of ETH. The vm.deal function is used to assign these addresses a starting balance, ensuring that they have sufficient funds to participate in the campaign creation and donation processes during the tests.

With the foundational setup in place, it's time to start writing our tests. We'll begin with the first test on our list: testCampaignCreationRevertsIfDeadlineIsNotInTheFuture. This test is designed to ensure that the contract correctly handles scenarios where a campaign is created with a deadline that has already passed. The goal is to confirm that the contract will revert the transaction, preventing the creation of invalid campaigns.

To implement this test, paste the following code:

function testCampaignCreationRevertsIfDeadlineIsNotInTheFuture() public {
    vm.warp(block.timestamp + deadline + 1);
        vm.expectRevert(Crowdfunding.Crowdfunding__DeadlineMustBeInTheFuture.selector);
    vm.prank(owner);
    crowdFunding.createCampaign(owner, TITLE, DESCRIPTION, TARGET, deadline);
}
Enter fullscreen mode Exit fullscreen mode

The test starts by using vm.warp(block.timestamp + deadline + 1);, which advances the blockchain's internal clock to a point just beyond the intended deadline. This simulates a scenario where the current time is already past the campaign's deadline. Learn more about vm.warp here.

Next, the vm.expectRevert(Crowdfunding.Crowdfunding__DeadlineMustBeInTheFuture.selector); line expects a revert when the next function is called. Specifically, it expects the revert reason to match the custom error Crowdfunding__DeadlineMustBeInTheFuture.

Finally, vm.prank(owner); simulates a transaction sent from the owner's address, followed by the call to crowdfunding.createCampaigns(owner, TITLE, DESCRIPTION, TARGET, deadline);. This line attempts to create a campaign with an expired deadline. Since the deadline is in the past, the contract should revert, confirming that our validation logic is working as intended.

Note: To check if every test runs successfully, run the following command in your terminal:

forge test
Enter fullscreen mode Exit fullscreen mode

Since we have only written one test so far, a successful run will look like this:

Forge test result

The next test, testCreateCampaign, will simulate the behavior of the createCampaign function. Since creating a campaign and retrieving the campaignId will be necessary for several upcoming tests, let's streamline the process by creating a modifier that we can reuse throughout the test suite.

Add this modifier to your test contract:

modifier createCampaign() {
   vm.prank(owner);
   campaignId = crowdFunding.createCampaign(owner, TITLE, DESCRIPTION, TARGET, deadline);
   _;
 }
Enter fullscreen mode Exit fullscreen mode

Above, we created a modifier named createCampaign that helps us automate the creation of a campaign by calling the createCampaign function with the predefined values owner, TITLE, DESCRIPTION, TARGET, and deadline. It uses vm.prank(owner) to simulate transactions as the owner account. The campaignId generated from this creation is then stored and can be reused in subsequent tests. Lastly, the _; in the modifier allows the test function to execute after the campaign is created.

Now that the modifier is in place, let's proceed with the actual testCreateCampaign function, which ensures that campaigns are created as expected. Add the following code:

function testCreateCampaign() public createCampaign {
    string memory campaignTitle = crowdFunding.getCampaign(campaignId).title;
    string memory campaignDescription = crowdFunding.getCampaign(campaignId).description;
    uint256 campaignTarget = crowdFunding.getCampaign(campaignId).target;
    uint256 campaignDeadline = crowdFunding.getCampaign(campaignId).deadline;
    uint256 campaignAmountCollected = crowdFunding.getCampaign(campaignId).amountCollected;

    assertEq(owner, owner);
    assertEq(campaignTitle, TITLE);
    assertEq(campaignDescription, DESCRIPTION);
    assertEq(campaignTarget, TARGET);
    assertEq(campaignDeadline, deadline);
    assertEq(campaignAmountCollected, 0);
}
Enter fullscreen mode Exit fullscreen mode

As you'll notice, the function above uses the createCampaign modifier to create a campaign, ensuring that we don't manually repeat the process in every test.

It then retrieves the created campaign's details title, description, target, deadline, and amount collected using the getCampaign function defined in the Crowdfunding contract. Each of these values is stored in memory to be compared with the expected values (predefined in the test).

Finally, the assertEq statements verify that the campaign’s actual values match the expected inputs. The assertions ensure the owner is correct, confirm the title, description, target, and deadline match the provided values during creation, and lastly, verify that the initial amount collected is 0, as no donations have been made yet.

The next test we'll implement is the testDonationRevertsWhenZeroEthIsDonated; which ensures that zero eth isn't donated to a campaign. Here's the code you need to add to your test contract:

 function testDonationRevertsWhenZeroEthIsDonated() public createCampaign {
  vm.expectRevert(Crowdfunding.Crowdfunding__CantSendZeroEth.selector);
  // Donate to the campaign
  vm.prank(donor1);
  crowdFunding.donateToCampaign{value: 0}(campaignId);
}
Enter fullscreen mode Exit fullscreen mode

You should already be acquainted with the Foundry cheatcodes used in this test from the first one. The main distinction here is that we're invoking the donateToCampaign function while sending zero ETH.

Now, let's proceed to the next test, testDonationRevertsWhenDeadlinePasses, which ensures that donations are rejected once the campaign deadline has expired.

Paste the following code in your contract to implement this:

function testDonationRevertsWhenDeadlinePasses() public createCampaign {
     vm.warp(block.timestamp + deadline + 1);
     vm.expectRevert();
     // Donate to the campaign
     vm.prank(donor1);
     crowdFunding.donateToCampaign{value: 2 ether}(campaignId);
}
Enter fullscreen mode Exit fullscreen mode

In this case, donor1 is unable to send the 2 ether to the campaign owner because the campaign deadline has already passed.

Next up is the testDonationRevertsWhenTargetIsMet test, which checks that a donation is rejected once the campaign's funding target has been reached, as its name suggests.

Add the following code to implement this function:

function testDonationRevertsWhenTargetIsMet() public createCampaign {
   vm.prank(donor1);
   crowdFunding.donateToCampaign{value: 5 ether}(campaignId); 
   vm.expectRevert(Crowdfunding.Crowdfunding__TargetMetForCampaign.selector);
   vm.prank(donor2);
   crowdFunding.donateToCampaign{value: 1 ether}(campaignId);
}
Enter fullscreen mode Exit fullscreen mode

In this test, we simulate two donors contributing to the same campaign. The first donor meets the campaign's funding target by sending the required amount of ETH. The second donor's attempt to donate should therefore trigger a revert since the target has already been reached.

That concludes the tests for the revert scenarios. Next, we'll move on to testing another core functionality of the contract with the testDonateToCampaign test. This test verifies that donations to a campaign are handled correctly. Below is the code that implements this:

function testDonateToCampaign() public createCampaign {
    // Donate to the campaign
    vm.prank(donor1);
    crowdFunding.donateToCampaign{value: 2 ether}(campaignId);

    // Validate campaign donation data
    uint256 campaignAmountCollected = crowdFunding.getCampaign(campaignId).amountCollected;
    uint256[] memory donations = crowdFunding.getCampaign(campaignId).donations;
    address[] memory donators = crowdFunding.getCampaign(campaignId).donators;

    assert(campaignAmountCollected == 2 ether);
    assertEq(donators.length, 1);
    assertEq(donations[0], 2 ether);
}
Enter fullscreen mode Exit fullscreen mode

In this test, donor1 donates 2 ether to the campaign. We then validate that the campaign correctly tracks the amount collected, the donations made, and the donor's address. The assert statements ensure that 2 ether is properly reflected in the campaign's data, and that donor1 is listed as the sole contributor with the correct donation amount.

Next, we’ll test the contract's ability to return excess funds to the donor if they send more ether than is needed to meet the campaign’s target. The code below demonstrates how this is implemented:

function testReverseExcessAmountToSender() public createCampaign {
    vm.prank(donor1);
    crowdFunding.donateToCampaign{value: 2 ether}(campaignId);
    vm.prank(donor2);
    crowdFunding.donateToCampaign{value: 4 ether}(campaignId);
    assertEq(donor2.balance, 2 ether);
}
Enter fullscreen mode Exit fullscreen mode

In this test, donor1 donates 2 ether to the campaign, and donor2 attempts to donate 4 ether. However, since the campaign only needs 2 more ether to meet its target, the contract should accept the necessary amount and return the extra 2 ether to donor2. The final assertEq statement verifies that donor2's balance reflects the excess ether refund. This ensures that the contract correctly handles over-contributions.

So far, we’ve tested the key functionalities of our contract. Now, to check how much of our code is covered by these tests, you can run the following command:

forge coverage
Enter fullscreen mode Exit fullscreen mode

The output should resemble something like this:

Forge coverage

As you can see, we've already covered a significant portion. However, if you'd like to dive deeper and challenge yourself, you can write additional tests, particularly for the getter functions in the contract.

The final step is deploying the smart contract, which will be discussed in the next section.

Deploying Your Contract Locally and on the Sepolia Testnet

With Foundry, you can deploy your contract either locally using anvil or directly on a testnet. In this section, I'll guide you through both deployment methods. We will manage both scenarios using a Makefile, so ensure that make is installed on your system.

Deploying on a Local Chain

For both local and testnet deployments, you'll need separate private keys and RPC URLs. In this subsection, I'll walk you through deploying the contract locally on a simulated blockchain.

To begin, open your terminal and run the anvil command. This will generate a list of private keys and a localhost URL (typically https://localhost:8545), which will serve as your RPC URL for the local deployment.

Once you have your private key and RPC URL, create a Makefile in the root folder of your project. At this stage, your project folder structure should look something like this:

Folder structure

Now, paste the following into your Makefile:

-include .env

DEFAULT_ANVIL_KEY := your-anvil-private-key

# Default deployment uses anvil
NETWORK_ARGS := --rpc-url http://localhost:8545 --private-key $(DEFAULT_ANVIL_KEY) --broadcast

deploy:
    @forge script script/DeployCrowdfunding.s.sol:DeployCrowdfunding $(NETWORK_ARGS)
Enter fullscreen mode Exit fullscreen mode

Make sure to replace your-anvil-private-key with one of the private keys generated by anvil.

To deploy the contract, run the following command in your terminal:

make deploy
Enter fullscreen mode Exit fullscreen mode

If the deployment is successful, the output will look something like this:

Anvil chain successful deployment

Deploying to the Sepolia Testnet

Note: To deploy on a testnet, you'll need a virtual wallet like Metamask.

For deployment to the Sepolia testnet, you'll need to generate your private key and RPC URL. You can follow the steps in this guide to set everything up.

After following the guide, you should have:

  • Created an account on Alchemy,
  • Set up an app and obtained an API key,
  • Added Sepolia testnet ETH to your wallet using a faucet,
  • Integrated your virtual wallet (such as Metamask) with your Alchemy project.

In addition, if you don't already have an account on Etherscan, create one and retrieve your API key from the "API keys" page. This will allow for contract verification post-deployment. Once these steps are completed, you're ready to proceed.

Now, in your project's root directory, create a .env file and add the following key-value pairs:

SEPOLIA_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/your-api-key"
ETHERSCAN_API_KEY = "your-etherscan-api-key"
PRIVATE_KEY = "your-metamask-private-key"
Enter fullscreen mode Exit fullscreen mode

Storing these values in the .env file helps keep sensitive data secure and prevents exposing it during deployment. Also, make sure the .env file is added to your .gitignore to avoid accidentally pushing confidential details to a public repository like GitHub.

Next, you'll need to update your Makefile to allow deployment to a testnet, such as Sepolia. This will enable switching between local and testnet environments seamlessly.

Update your Makefile as follows:

-include .env

DEFAULT_ANVIL_KEY := your-anvil-private-key

# Default deployment on anvil
NETWORK_ARGS := --rpc-url http://localhost:8545 --private-key $(DEFAULT_ANVIL_KEY) --broadcast

# Condition to deploy on Sepolia
ifeq ($(findstring --network sepolia,$(ARGS)),--network sepolia)
    NETWORK_ARGS := --rpc-url $(SEPOLIA_RPC_URL) --private-key $(PRIVATE_KEY) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv
endif

deploy:
    @forge script script/DeployCrowdfunding.s.sol:DeployCrowdfunding $(NETWORK_ARGS)
Enter fullscreen mode Exit fullscreen mode

To deploy your contract on the Sepolia testnet, use the following command:

make deploy ARGS="--network sepolia"
Enter fullscreen mode Exit fullscreen mode

This setup allows for easy switching between deploying locally using anvil or deploying to a testnet like Sepolia.

If you have sufficient ETH, the contract should deploy successfully. You’ll then be able to view the deployment details on your Alchemy dashboard. Additionally, once you paste the contract address into Etherscan, you should see that it has been successfully verified.

Conclusion

We've reached the end of this article, where I’ve walked you through the entire process of building, testing, and deploying a crowdfunding smart contract using Solidity, Foundry, and Makefiles. From deploying locally on anvil to pushing the contract to a testnet like Sepolia, you've learned how to set up a complete development and testing environment to ensure your contract operates securely and efficiently.

You can access the source code for this project here. If you have any suggestions or improvements, feel free to submit a PR or reach out via email at olasunkanmiibalogun@gmail.com. You can also connect with me on LinkedIn.

This article is part of my ongoing journey to landing my first role in the blockchain space. I’ll continue documenting what I build and learn along the way, so stay tuned for more content. Let’s keep building! 👊

Top comments (0)