Education › Blockchain & Web3 › Guided project

Build a full-stack dApp

Combine everything into a real decentralised application: a crowdfunding smart contract with contributions, a funding goal, withdrawals and refunds, tested and deployed to a testnet, plus a web frontend that connects a wallet, reads on-chain state, and sends transactions.

Intermediate about 8 hours 6 phases · 25 steps 0 / 25 done
What you will have at the end

A working crowdfunding dApp on Sepolia — a tested contract applying checks-effects-interactions, and a browser frontend that connects MetaMask, shows the live campaign state, and lets anyone contribute, with the creator able to withdraw and backers to refund.

Before you start
  • The ERC-20 token project (or equivalent Hardhat comfort) for the contract workflow
  • The Blockchain lessons through Connecting a frontend and Smart-contract security — you'll use ethers.js, a provider/signer, and the checks-effects-interactions pattern
  • A wallet on Sepolia with test ETH, and basic HTML/JavaScript comfort for the frontend
Tools you will install
  • Hardhat — develop, test, and deploy the crowdfunding contract with the standard loop ↗
  • ethers.js — connect the frontend to the chain — provider to read, signer to write ↗
  • MetaMask + a Sepolia faucet — the wallet the dApp connects to, and free test ETH for contributions and gas ↗
  • A static file server — serve the frontend locally so the wallet can inject its provider (use any simple HTTP server) ↗

Tick each step as you finish it — your progress is saved in this browser only (back up or restore on the hub). Every code block has a copy button. If something goes wrong, the troubleshooting section at the end covers the usual suspects.

Phase 1

Design the dApp

Decide the contract's rules and the split of responsibilities before coding, so the security-sensitive parts are deliberate.

  1. Write the campaign rules in plain English: a creator sets a goal and a deadline; anyone can contribute ether before the deadline; if the goal is met, the creator can withdraw; if not, contributors can refund their own contribution. These rules become your require checks.
    Writing the rules first turns them directly into the contract's guards. Every 'if/then' here is a require or a condition in code.
  2. Note which layer owns what: the contract holds the funds and enforces the rules on-chain; the frontend only reads state and asks the wallet to send transactions; the wallet holds the keys and signs. Keeping this split clear is what makes it a real dApp rather than a normal web app.
    The contract is the backend. The frontend never holds funds or trust — it's a window onto the contract.
Phase 2

Write the crowdfunding contract

A contract that takes contributions, tracks them, and releases or refunds funds safely using checks-effects-interactions.

  1. Confirm Node.js is 18+ and set up a fresh Hardhat project with the toolbox installed, the same way as the earlier projects, ready for the contract.
    bash
    node --version
    mkdir crowdfund-dapp && cd crowdfund-dapp
    npm init -y
    npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
    npx hardhat init
    Check: The project scaffolds and Node is v18 or higher.
  2. Create contracts/Crowdfund.sol with the state: creator, goal, deadline, total raised, and a mapping of each backer's contribution.
    solidity
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.20;
    
    contract Crowdfund {
        address public creator;
        uint256 public goal;
        uint256 public deadline;
        uint256 public totalRaised;
        mapping(address => uint256) public contributionOf;
    
        event Contributed(address indexed backer, uint256 amount);
        event Withdrawn(uint256 amount);
        event Refunded(address indexed backer, uint256 amount);
    
        constructor(uint256 goalWei, uint256 durationSeconds) {
            creator = msg.sender;
            goal = goalWei;
            deadline = block.timestamp + durationSeconds;
        }
    }
  3. Add the contribute function: it's payable, rejects contributions after the deadline, records the backer's amount, updates the total, and emits an event.
    solidity
    function contribute() external payable {
        require(block.timestamp < deadline, "campaign ended");
        require(msg.value > 0, "zero contribution");
        contributionOf[msg.sender] += msg.value;   // effects
        totalRaised += msg.value;
        emit Contributed(msg.sender, msg.value);
    }
  4. Add withdraw for the creator, allowed only after the deadline and only if the goal was met. Apply checks-effects-interactions: verify conditions, then send. Guard it so only the creator can call it.
    solidity
    function withdraw() external {
        require(msg.sender == creator, "not creator");
        require(block.timestamp >= deadline, "not ended");
        require(totalRaised >= goal, "goal not met");
        uint256 amount = address(this).balance;
        (bool ok, ) = creator.call{value: amount}("");   // interaction last
        require(ok, "transfer failed");
        emit Withdrawn(amount);
    }
  5. Add refund for backers, allowed only after the deadline and only if the goal was NOT met. Zero the backer's recorded contribution BEFORE sending — checks-effects-interactions — to prevent a reentrancy drain.
    solidity
    function refund() external {
        require(block.timestamp >= deadline, "not ended");
        require(totalRaised < goal, "goal was met");
        uint256 amount = contributionOf[msg.sender];
        require(amount > 0, "nothing to refund");
        contributionOf[msg.sender] = 0;                  // effects BEFORE sending
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "refund failed");
        emit Refunded(msg.sender, amount);
    }
  6. Compile the contract and re-read the withdraw and refund functions specifically for the ordering: every state change happens before the external call. Confirm you never send ether before updating state.
    bash
    npx hardhat compile
    Check: Crowdfund compiles, and you've confirmed state is updated before any transfer.
Phase 3

Test the rules and the failures

Prove every rule holds and every disallowed action reverts — critical for a contract that moves ether.

  1. Create test/Crowdfund.js. Deploy with a small goal and short duration, have two accounts contribute, and check totalRaised and each contribution are recorded.
    javascript
    const { expect } = require("chai");
    const { ethers } = require("hardhat");
    
    it("records contributions", async function () {
      const [creator, alice] = await ethers.getSigners();
      const CF = await ethers.getContractFactory("Crowdfund");
      const cf = await CF.deploy(ethers.parseEther("1"), 60);
      await cf.connect(alice).contribute({ value: ethers.parseEther("0.4") });
      expect(await cf.totalRaised()).to.equal(ethers.parseEther("0.4"));
    });
  2. Test the success path: contribute enough to meet the goal, fast-forward time past the deadline (Hardhat lets you advance the clock), and confirm the creator can withdraw and a non-creator cannot.
    javascript
    // advance time in a Hardhat test
    await ethers.provider.send("evm_increaseTime", [61]);
    await ethers.provider.send("evm_mine", []);
    // then assert creator can withdraw and others revert with 'not creator'
  3. Test the failure path: a campaign that does NOT meet its goal, and confirm backers can refund their exact contribution after the deadline, while the creator cannot withdraw.
    Cover both outcomes: goal met (creator withdraws, no refunds) and goal missed (backers refund, no withdrawal). These two branches are the heart of the contract.
  4. Add reverts you expect: contributing after the deadline, withdrawing before it, refunding when the goal was met. Run the whole suite and make it green.
    bash
    npx hardhat test
    Check: All tests pass, including the reverts for disallowed actions.
Phase 4

Deploy to a testnet

Put the campaign contract on Sepolia so the frontend has a real contract to talk to.

  1. Set up .env (Sepolia RPC URL + test private key), add the sepolia network to hardhat.config.js, and .gitignore the .env — the secure setup from earlier projects.
    bash
    printf 'SEPOLIA_RPC_URL=your-rpc-url\nPRIVATE_KEY=your-test-key\n' > .env
    echo '.env' >> .gitignore
    Test-only key with test ETH; never commit secrets.
  2. Write scripts/deploy.js that deploys Crowdfund with a small goal and a short duration (so you can test the full lifecycle quickly), then deploy to Sepolia and save the address.
    bash
    npx hardhat run scripts/deploy.js --network sepolia
    Check: The contract address prints and appears on sepolia.etherscan.io.
  3. Export the contract's ABI from the Hardhat artifacts — the frontend needs it to encode calls. Copy the abi array from artifacts/contracts/Crowdfund.sol/Crowdfund.json.
    The ABI plus the deployed address are the two things the frontend needs to talk to your contract.
Phase 5

Build the frontend

A web page that connects the wallet, shows the live campaign state, and lets a user contribute.

  1. Create an index.html with a Connect Wallet button, a display area for the campaign state (goal, raised, deadline), a contribute input and button, and withdraw/refund buttons. Load ethers.js from a script tag.
    Keep the UI minimal — the goal is a working dApp, not a polished product. Structure it so JavaScript can fill in the state and wire the buttons.
  2. In JavaScript, connect the wallet: create a BrowserProvider from window.ethereum, request accounts, get a signer, and build a contract object from your address and ABI. Store the connected account to show it.
    javascript
    const provider = new ethers.BrowserProvider(window.ethereum);
    await provider.send("eth_requestAccounts", []);
    const signer = await provider.getSigner();
    const cf = new ethers.Contract(CONTRACT_ADDRESS, ABI, signer);
  3. Read and display the live state using view calls (free, no wallet prompt): goal, totalRaised, and deadline. Format the wei values to ether for display.
    javascript
    const goal = await cf.goal();
    const raised = await cf.totalRaised();
    document.getElementById("raised").textContent =
      ethers.formatEther(raised) + " / " + ethers.formatEther(goal) + " ETH";
  4. Wire the Contribute button to send a transaction: call contribute with an ether value from the input, wait for it to confirm, then re-read and refresh the displayed state.
    javascript
    async function contribute(amountEth) {
      const tx = await cf.contribute({ value: ethers.parseEther(amountEth) });
      await tx.wait();            // wait for confirmation
      await refreshState();      // re-read the updated totals
    }
  5. Wire the Withdraw and Refund buttons to their contract calls, showing a clear pending state while each transaction is mined and a message if it reverts (e.g. 'goal not met'). Handle errors so a rejected or failing transaction doesn't leave the UI stuck.
    Surfacing pending and error states is what makes a dApp usable — the user must see when they're waiting on the chain and understand a revert.
Phase 6

Run it end to end

Use your dApp as a real user would, exercising the whole lifecycle on the testnet.

  1. Serve the frontend from a local static server (opening the file directly can block the wallet), open it in a browser with MetaMask on Sepolia, and connect your wallet.
    bash
    python3 -m http.server 8000
    Check: The page loads, Connect Wallet works, and it shows your account and the live campaign state.
  2. Contribute from two different accounts and watch the raised total update in the UI after each transaction confirms, confirming reads and writes both work through the frontend.
    Check: Each contribution prompts the wallet, confirms, and the displayed total increases.
  3. Drive the campaign to a conclusion: either meet the goal and withdraw as the creator, or let the deadline pass unmet and refund as a backer. Confirm the funds move and the disallowed action is blocked.
    Check: The correct outcome path works end to end and the wrong one reverts with a clear message.
  4. Open one of your contribute transactions on sepolia.etherscan.io and find the Contributed event in its logs, confirming your contract emits the events a real frontend or indexer would listen to.
    Check: The transaction's logs show your Contributed event with the backer and amount.
  5. Verify the contract on Etherscan and write a README with the address, ABI location, and how to run the frontend, so the whole dApp is a shareable portfolio piece.
    bash
    npx hardhat verify --network sepolia YOUR_CONTRACT_ADDRESS GOAL_WEI DURATION_SECONDS
    Check: Etherscan shows the verified source, and your README lets someone else run the dApp.
Help

Troubleshooting

The frontend's Connect Wallet does nothing or window.ethereum is undefined.
The page must be served over http (not opened as a file://), and MetaMask must be installed and unlocked. Run a local server, reload, and confirm the wallet is on the Sepolia network.
A contribute transaction reverts with 'campaign ended'.
The deadline has passed. Deploy a fresh campaign with a longer duration for testing, or note that contributions are correctly blocked after the deadline — which is the intended rule.
Withdraw reverts even though contributions were made.
Withdraw is only allowed after the deadline AND when totalRaised is at least the goal, and only for the creator. Check all three: the clock is past the deadline, the goal was actually met, and you're calling from the creator account.
The displayed amounts look enormous or wrong.
You're showing raw wei instead of ether. Use ethers.formatEther to display and ethers.parseEther to send. Mixing the two scales is the most common frontend bug with values.
Etherscan verification fails for the contract.
A contract with constructor arguments must be verified with those exact arguments (the goal in wei and the duration in seconds you deployed with), using the same compiler version and settings. Pass the constructor args to hardhat verify.
Next

Where to go from here

Did a step fail or feel unclear? Tell me which one →