Build and Deploy a Decentralized Crowdfunding Smart Contract on Linea

Learn how to build and deploy a decentralized crowdfunding smart contract on Linea.

by Kingsley OkonkwoFebruary 5, 2025
Build and Deploy a Decentralized Crowdfunding Smart Contract on the Linea Feature image

Crowdfunding has changed how projects, causes, and businesses raise funds by allowing creators to connect directly with supporters and bypass traditional financial institutions. Blockchain technology enhances this model by adding transparency, decentralization, and immutability, ensuring a fairer system for both creators and contributors.

This article will guide you through building a Solidity-based Crowdfunding Smart Contract designed for EVM-compatible blockchains like Linea. The contract will allow users to create campaigns, securely contribute funds, withdraw funds upon successful goal achievement, and request refunds if a campaign falls short. By leveraging smart contract automation, the platform eliminates intermediaries and enforces accountability.

By exploring the contract's implementation, features, and functionality, developers will gain valuable insights into building decentralized crowdfunding platforms. Whether you are a blockchain enthusiast, developer, or entrepreneur, this guide will demonstrate how smart contracts can revolutionize crowdfunding by making it more secure, efficient, and trustless.

Prerequisite

- You should have a basic understanding of Solidity.
- You should have Nodejs and Foundry installed on your PC.

Overview of the contract

This contract enables:

  1. Campaign creation: Users can initiate fundraising campaigns by setting a goal and duration.
  2. Contributions: Users can contribute funds to active campaigns.
  3. Fund withdrawal: Campaign creators can withdraw funds if the fundraising goal is met after the campaign deadline.
  4. Refunds: Contributors can reclaim their contributions if the goal is not met within the deadline.

Project setup

Run the command below to initiate a Foundry project. We will be using the Foundry framework to build the smart contract.

forge init crowdfunding

Open the crowdfunding folder in VS Code or your favorite code editor, and delete the scripts/counter.s.sol, src/counter.sol, and test/counter.t.sol.

Install all dependencies

forge install foundry-rs/forge-std --no-commit

Full code of the crowdfunding smart contract

Create a crowdfunding.sol file in the src folder and add the code below to the file.

// SPDX-License-Identifier: MIT
  pragma solidity 0.8.18;

  contract Crowdfunding {
      struct Campaign {
          address creator;
          uint256 goal;
          uint256 deadline;
          uint256 amountRaised;
          bool isWithdrawn;
          mapping(address => uint256) contributions;
      }

      uint256 public campaignCount = 0;
      mapping(uint256 => Campaign) public campaigns;
      event CampaignCreated(uint256 campaignId, address creator, uint256 goal, uint256 deadline);
      event ContributionMade(uint256 campaignId, address contributor, uint256 amount);
      event FundsWithdrawn(uint256 campaignId, uint256 amount);
      event RefundIssued(uint256 campaignId, address contributor, uint256 amount);

      modifier campaignExists(uint256 campaignId) {
          require(campaignId < campaignCount, "Campaign does not exist");
          _;
      }

      modifier onlyCreator(uint256 campaignId) {
          require(msg.sender == campaigns[campaignId].creator, "Only the campaign creator can perform this action");
          _;
      }

      modifier beforeDeadline(uint256 campaignId) {
          require(block.timestamp <= campaigns[campaignId].deadline, "Campaign deadline has passed");
          _;
      }

      modifier afterDeadline(uint256 campaignId) {
          require(block.timestamp > campaigns[campaignId].deadline, "Campaign deadline has not passed yet");
          _;
      }

      // Create a new crowdfunding campaign
      function createCampaign(uint256 goal, uint256 duration) external {
          require(goal > 0, "Goal must be greater than zero");
          require(duration > 0, "Duration must be greater than zero");
          Campaign storage newCampaign = campaigns[campaignCount];
          newCampaign.creator = msg.sender;
          newCampaign.goal = goal;
          newCampaign.deadline = block.timestamp + duration;
          emit CampaignCreated(campaignCount, msg.sender, goal, newCampaign.deadline);
          campaignCount++;
      }

      // Contribute funds to a campaign
      function contribute(uint256 campaignId) external payable campaignExists(campaignId) beforeDeadline(campaignId) {
          require(msg.value > 0, "Contribution must be greater than zero");
          Campaign storage campaign = campaigns[campaignId];
          campaign.amountRaised += msg.value;
          campaign.contributions[msg.sender] += msg.value;
          emit ContributionMade(campaignId, msg.sender, msg.value);
      }

      // Withdraw funds if the campaign meets its goal
      function withdrawFunds(uint256 campaignId) external campaignExists(campaignId) onlyCreator(campaignId) afterDeadline(campaignId) {
          Campaign storage campaign = campaigns[campaignId];
          require(campaign.amountRaised >= campaign.goal, "Funding goal not reached");
          require(!campaign.isWithdrawn, "Funds already withdrawn");
          campaign.isWithdrawn = true;
          (bool sent, ) = campaign.creator.call{value: campaign.amountRaised}("");
          require(sent, "Failed to send Ether");
          emit FundsWithdrawn(campaignId, campaign.amountRaised);
      }

      // Request a refund if the campaign fails
      function requestRefund(uint256 campaignId) external campaignExists(campaignId) afterDeadline(campaignId) {
          Campaign storage campaign = campaigns[campaignId];
          require(campaign.amountRaised < campaign.goal, "Campaign succeeded, refunds not allowed");
          uint256 contribution = campaign.contributions[msg.sender];
          require(contribution > 0, "No contributions found for this campaign");
          campaign.contributions[msg.sender] = 0;
          (bool sent, ) = msg.sender.call{value: contribution}("");
          require(sent, "Failed to send Ether");
          emit RefundIssued(campaignId, msg.sender, contribution);
      }
  }

Key features of the code

Campaign structure The contract uses a struct named Campaign to store campaign details.

   struct Campaign {  
       address creator;            // Campaign creator  
       uint256 goal;               // Fundraising goal  
       uint256 deadline;           // Deadline to achieve the goal  
       uint256 amountRaised;       // Total contributions raised  
       bool isWithdrawn;           // Tracks if funds were withdrawn  
       mapping(address \=\> uint256) contributions; // Tracks contributions by users  
   }  

Campaign count Each campaign is uniquely identified by an incrementing campaignId, ensuring scalability and simplifying the management of multiple campaigns.

    uint256 public campaignCount = 0;
    mapping(uint256 => Campaign) public campaigns;

Campaign creation

The createCampaign function allows a user to initiate a campaign. It takes in two parameters: goal and duration. The goal represents the target amount the user wants to raise, while the duration specifies the time in seconds allotted to reach that goal.

The function ensures that both the goal and duration are greater than zero to prevent invalid entries. The campaign details are then stored in the Campaign struct, and the campaignId is assigned accordingly.

The campaignCount is incremented to ensure that newly created campaigns have unique campaignIds and do not overlap with previous campaigns.

   function createCampaign(uint256 goal, uint256 duration) external {
       require(goal > 0, "Goal must be greater than zero");
       require(duration > 0, "Duration must be greater than zero");

       Campaign storage newCampaign = campaigns[campaignCount];
       newCampaign.creator = msg.sender;
       newCampaign.goal = goal;
       newCampaign.deadline = block.timestamp + duration;

       emit CampaignCreated(campaignCount, msg.sender, goal, newCampaign.deadline);
       campaignCount++;
   }

Contribution mechanism

The contribute function allows users to fund active campaigns. It takes in one parameter, campaignId, which serves as the unique identifier for each campaign and determines where the contribution will be allocated.

The function ensures that users do not contribute a zero amount. It tracks the amountRaised for the campaign and records the total contribution made by each user.

  function contribute(uint256 campaignId) external payable campaignExists(campaignId) beforeDeadline(campaignId) {
       require(msg.value > 0, "Contribution must be greater than zero");

       Campaign storage campaign = campaigns[campaignId];
       campaign.amountRaised += msg.value;
       campaign.contributions[msg.sender] += msg.value;

       emit ContributionMade(campaignId, msg.sender, msg.value);
   }

The campaignExists modifier in the contribute function verifies whether the campaignId is valid and throws an error if the campaign does not exist.

   modifier campaignExists(uint256 campaignId) {
           require(campaignId < campaignCount, "Campaign does not exist");
           _;
       }

The beforeDeadline modifier verifies whether the campaign's deadline has passed and throws an error if it has.

   modifier beforeDeadline(uint256 campaignId) {
           require(block.timestamp <= campaigns[campaignId].deadline, "Campaign deadline has passed");
           _;
       }

Withdrawing funds

Campaign creators can withdraw funds using the withdrawFunds function, which takes in one parameter, campaignId, serving as the unique identifier for each campaign.

Only the campaign creator can withdraw funds. If any other user attempts to do so, the function will throw an error.

The function ensures that the funding goal has been met and that the funds have not already been withdrawn. If the goal is not met or the funds have already been withdrawn, the function will throw an error.

Upon successful withdrawal, the function updates the isWithdrawn status to true and transfers the funds to the campaign creator.

``Solidity function withdrawFunds(uint256 campaignId) external campaignExists(campaignId) onlyCreator(campaignId) afterDeadline(campaignId) { Campaign storage campaign = campaigns[campaignId]; require(campaign.amountRaised >= campaign.goal, "Funding goal not reached"); require(!campaign.isWithdrawn, "Funds already withdrawn");

   campaign.isWithdrawn = true;
   (bool sent, ) = campaign.creator.call{value: campaign.amountRaised}("");
   require(sent, "Failed to send Ether");

   emit FundsWithdrawn(campaignId, campaign.amountRaised);

}

The `onlyCreator` modifier verifies whether the caller is the campaign creator and throws an error if they are not.

``Solidity
   modifier onlyCreator(uint256 campaignId) {
         require(msg.sender == campaigns[campaignId].creator, "Only the campaign creator can perform this action");
         _;
     }

The afterDeadline modifier ensures that the campaign's deadline has passed and throws an error if it has not.

   modifier afterDeadline(uint256 campaignId) {
           require(block.timestamp > campaigns[campaignId].deadline, "Campaign deadline has not passed yet");
           _;
       }

Refunds for failed campaigns

Contributors can withdraw their contributions if the campaign fails. The function takes in one parameter, campaignId, which uniquely identifies each campaign.

It verifies that the campaign did not meet its funding goal and that the user has made a contribution. If both conditions are met, the function resets the user's contribution to zero and processes the refund.

    function requestRefund(uint256 campaignId) external campaignExists(campaignId) afterDeadline(campaignId) {
           Campaign storage campaign = campaigns[campaignId];
           require(campaign.amountRaised < campaign.goal, "Campaign succeeded, refunds not allowed");
           uint256 contribution = campaign.contributions[msg.sender];
           require(contribution > 0, "No contributions found for this campaign");
           campaign.contributions[msg.sender] = 0;
           (bool sent, ) = msg.sender.call{value: contribution}("");
           require(sent, "Failed to send Ether");
           emit RefundIssued(campaignId, msg.sender, contribution);
       }

Events
The contract emits events for key actions:

  • CampaignCreated: Logs details of a new campaign.
  • ContributionMade: Logs contributions to a campaign.
  • FundsWithdrawn: Logs successful fund withdrawals.
  • RefundIssued: Logs refunds issued to contributors.
       event CampaignCreated(uint256 campaignId, address creator, uint256 goal, uint256 deadline);
       event ContributionMade(uint256 campaignId, address contributor, uint256 amount);
       event FundsWithdrawn(uint256 campaignId, uint256 amount);
       event RefundIssued(uint256 campaignId, address contributor, uint256 amount);

Tests

Create a crowdfunding.t.sol file in the test folder and add the code below to the file.

// SPDX-License-Identifier: UNLICENSED
  pragma solidity ^0.8.13;
  import {Test, console} from "forge-std/Test.sol";
  import {Crowdfunding} from "../src/Crowdfunding.sol";
  contract CounterTest is Test {
      Crowdfunding public crowd;
      address public user1 = makeAddr("user1");
      address public user2 = makeAddr("user2");
      address public user3 = makeAddr("user3");

      function setUp() public {
          crowd = new Crowdfunding();
      }

      function test_createCampaign() public {
          vm.startPrank(user1);
          crowd.createCampaign(10 ether, 10 hours);
      }

      function test_contributeCampaign() public {
          vm.startPrank(user1);
          crowd.createCampaign(10 ether, 10 hours);
          deal(user2, 10 ether);
          deal(user3, 10 ether);
          vm.startPrank(user2);
          crowd.contribute{value: 6 ether}(0);
           vm.startPrank(user3);
          crowd.contribute{value: 6 ether}(0);
      }

       function test_withdrawCampaign() public {
          vm.startPrank(user1);
          crowd.createCampaign(10 ether, 10 hours);
          deal(user2, 10 ether);
          deal(user3, 10 ether);
          vm.startPrank(user2);
          crowd.contribute{value: 6 ether}(0);
           vm.startPrank(user3);
          crowd.contribute{value: 6 ether}(0);
          skip(11 hours);
          console.log("b4", user1.balance);
          vm.startPrank(user1);
          crowd.withdrawFunds(0);
          console.log("after",user1.balance);
      }

      function test_requestCampaign() public {
          vm.startPrank(user1);
          crowd.createCampaign(10 ether, 10 hours);
          deal(user2, 10 ether);
          deal(user3, 10 ether);
          vm.startPrank(user2);
          crowd.contribute{value: 2 ether}(0);
           vm.startPrank(user3);
          crowd.contribute{value: 2 ether}(0);
          skip(11 hours);
          vm.startPrank(user2);
          crowd.requestRefund(0);
          vm.startPrank(user3);
          crowd.requestRefund(0);
          console.log("after",user2.balance);
          console.log("after",user3.balance);
      }

  }

The test suite ensures that the Crowdfunding contract functions correctly across its core features. Below is a breakdown of the tests:

test_createCampaign
This test verifies that a user can successfully create a crowdfunding campaign. The campaign is created without errors, confirming that the function works as expected.

test_contributeCampaign
This test verifies that multiple users can contribute to a specific campaign. In the test, user1 creates a campaign, while user2 and user3 each receive 10 ether using Foundry's deal function. Both user2 and user3 contribute 6 ether to the campaign, and the contract correctly updates the campaign’s total amount raised.

test_withdrawCampaign
This test verifies that the campaign creator can withdraw funds once the campaign meets its funding goal and the deadline has passed. After user2 and user3 contribute to the campaign and 11 hours have passed (simulated using skip), user1 calls withdrawFunds to withdraw the raised funds. The funds are successfully transferred, and the contract’s balance reflects the withdrawal.

test_requestCampaign
This test verifies that contributors can request refunds if the campaign fails to meet its funding goal. After user2 and user3 contribute to the campaign and 11 hours have passed (simulated using skip), both user2 and user3 call requestRefund. Each user successfully receives their contributed ether back.

Run test

forge test

Deployment and verification on Linea

Add the required environment variables to the .env file, ensuring it is placed at the root level of the project. These variables store sensitive credentials needed for blockchain interactions.

LINEA_RPC_URL=https://linea-mainnet.infura.io/v3/ // Infura RPC endpoint for connecting to Linea 
PRIVATE_KEY=************************************************* // Wallet private key for signing transactions LINEA_API_KEY=************************* // API key for accessing Linea services via Infura

Add the details below to foundry.toml

   [etherscan]
   linea = { key = "${LINEA_API_KEY}", url = "https://api-sepolia.lineascan.build/api" }

Create a Makefile and add the details below.

   deploy:
       forge create src/Crowdfunding.sol:Crowdfunding --rpc-url $(LINEA_RPC_URL) --private-key $(PRIVATE_KEY)

   verify:; forge verify-contract --rpc-url $(LINEA_RPC_URL) --chain linea <contract address> src/Crowdfunding.sol:Crowdfunding

Deployment

make deploy

Verification

make verify

Conclusion

This guide covered the fundamentals of building and deploying a decentralized crowdfunding smart contract on the Linea blockchain. We explored key contract functionalities such as campaign creation, contributions, withdrawals, and refunds, along with writing and running tests to validate the contract’s behavior. Finally, we walked through the deployment and verification process to make the contract live on Linea.

For more hands-on tutorials and deep dives into blockchain development, check out the MetaMask Developer YouTube channel, and stay updated with our latest insights by following us on Twitter.

Receive our Newsletter