Building a Simple dApp with Solidity and Hardhat

Building a Simple dApp with Solidity and Hardhat

Alright, so you want to build your first decentralized application (dApp)? That's awesome! I remember when I first started with blockchain development - it felt like trying to solve a puzzle where half the pieces were missing and the other half were on fire. But honestly, once you get the hang of it, it's pretty satisfying to see your code running on the blockchain.

Today we're gonna build a simple voting dApp using Solidity for our smart contract and Hardhat as our development framework. Nothing too fancy - just a basic voting system where people can create proposals and vote on them. Think of it as a stepping stone to more complex projects.

What We're Building Today

Our dApp will be pretty straightforward. Users can create voting proposals, cast their votes, and see the results. It's like a mini democracy on the blockchain, except nobody's gonna argue about gerrymandering or hanging chads.

The smart contract will handle all the logic - storing proposals, tracking votes, making sure people can't vote twice on the same proposal (because that would be cheating). The frontend will be a simple web interface where users can interact with our contract.

  • Create and deploy a Solidity smart contract for voting
  • Set up Hardhat for development and testing
  • Write some basic tests to make sure our contract works
  • Build a simple web interface to interact with the contract
  • Deploy everything to a test network

Setting Up Your Development Environment

First things first, we need to get our development environment ready. You'll need Node.js installed on your machine - if you don't have it, go grab it from nodejs.org. I'm assuming you're somewhat familiar with JavaScript and npm, but don't worry if you're not an expert.

Let's create a new project and install Hardhat:

mkdir voting-dapp
cd voting-dapp
npm init -y
npm install --save-dev hardhat
npx hardhat

When you run `npx hardhat`, it'll ask you some questions. Just go with "Create a JavaScript project" for now. This will set up a basic Hardhat project structure with some example contracts and tests.

You'll also want to install a few more dependencies:

npm install --save-dev @nomicfoundation/hardhat-toolbox
npm install --save-dev @nomiclabs/hardhat-ethers ethers
Code editor with blockchain development
Setting up your blockchain development environment
Terminal with code
Terminal commands for project setup

Writing Our Voting Smart Contract

Now for the fun part - writing our smart contract! Create a new file called `Voting.sol` in the `contracts` directory. Here's what our contract is gonna look like:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract Voting {
    struct Proposal {
        uint256 id;
        string description;
        uint256 voteCount;
        bool exists;
    }
    
    mapping(uint256 => Proposal) public proposals;
    mapping(uint256 => mapping(address => bool)) public hasVoted;
    
    uint256 public proposalCount;
    address public owner;
    
    event ProposalCreated(uint256 indexed proposalId, string description);
    event VoteCast(uint256 indexed proposalId, address indexed voter);
    
    modifier onlyOwner() {
        require(msg.sender == owner, "Only owner can call this function");
        _;
    }
    
    constructor() {
        owner = msg.sender;
        proposalCount = 0;
    }
    
    function createProposal(string memory _description) public onlyOwner {
        proposalCount++;
        proposals[proposalCount] = Proposal({
            id: proposalCount,
            description: _description,
            voteCount: 0,
            exists: true
        });
        
        emit ProposalCreated(proposalCount, _description);
    }
    
    function vote(uint256 _proposalId) public {
        require(proposals[_proposalId].exists, "Proposal does not exist");
        require(!hasVoted[_proposalId][msg.sender], "You have already voted");
        
        proposals[_proposalId].voteCount++;
        hasVoted[_proposalId][msg.sender] = true;
        
        emit VoteCast(_proposalId, msg.sender);
    }
    
    function getProposal(uint256 _proposalId) public view returns (uint256, string memory, uint256) {
        require(proposals[_proposalId].exists, "Proposal does not exist");
        Proposal memory proposal = proposals[_proposalId];
        return (proposal.id, proposal.description, proposal.voteCount);
    }
}

Let me break down what's happening here. We've got a `Proposal` struct that holds the basic info about each voting proposal. The `proposals` mapping stores all our proposals, and `hasVoted` keeps track of who has already voted on what (to prevent double voting).

The `createProposal` function lets the contract owner create new proposals, and the `vote` function lets anyone cast a vote. Pretty straightforward, right?

One thing I learned the hard way - always include proper error messages in your require statements. Trust me, you'll thank yourself later when you're debugging why a transaction failed.

A Developer Who's Been There

Writing Tests for Our Contract

Before we deploy anything, we should test our contract. Testing smart contracts is super important because once they're on the blockchain, you can't just patch them like a regular web app.

Create a test file called `Voting.js` in the `test` directory:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Voting Contract", function () {
    let voting;
    let owner;
    let voter1;
    let voter2;

    beforeEach(async function () {
        [owner, voter1, voter2] = await ethers.getSigners();
        
        const Voting = await ethers.getContractFactory("Voting");
        voting = await Voting.deploy();
        await voting.deployed();
    });

    it("Should create a proposal", async function () {
        await voting.createProposal("Should we build more parks?");
        
        const proposal = await voting.getProposal(1);
        expect(proposal[1]).to.equal("Should we build more parks?");
        expect(proposal[2]).to.equal(0); // vote count should be 0
    });

    it("Should allow voting on proposals", async function () {
        await voting.createProposal("Should we build more parks?");
        
        await voting.connect(voter1).vote(1);
        
        const proposal = await voting.getProposal(1);
        expect(proposal[2]).to.equal(1); // vote count should be 1
    });

    it("Should prevent double voting", async function () {
        await voting.createProposal("Should we build more parks?");
        
        await voting.connect(voter1).vote(1);
        
        await expect(
            voting.connect(voter1).vote(1)
        ).to.be.revertedWith("You have already voted");
    });
});

Run your tests with `npx hardhat test`. If everything's working correctly, you should see all tests passing. If not, well, time to debug!

Building the Frontend

Now let's build a simple web interface for our dApp. We'll keep it basic - just HTML, CSS, and JavaScript. No fancy frameworks needed for this tutorial.

Create an `index.html` file in your project root:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Voting dApp</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f5f5f5;
        }
        .container {
            background: white;
            padding: 30px;
            border-radius: 10px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        button {
            background-color: #007bff;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 5px;
            cursor: pointer;
            margin: 5px;
        }
        button:hover {
            background-color: #0056b3;
        }
        input[type="text"] {
            width: 100%;
            padding: 10px;
            margin: 10px 0;
            border: 1px solid #ddd;
            border-radius: 5px;
        }
        .proposal {
            border: 1px solid #ddd;
            padding: 15px;
            margin: 10px 0;
            border-radius: 5px;
            background-color: #f9f9f9;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Voting dApp</h1>
        
        <div id="account-info">
            <p>Connect your wallet to get started</p>
            <button onclick="connectWallet()">Connect Wallet</button>
        </div>

        <div id="create-proposal" style="display: none;">
            <h3>Create New Proposal</h3>
            <input type="text" id="proposal-text" placeholder="Enter your proposal...">
            <button onclick="createProposal()">Create Proposal</button>
        </div>

        <div id="proposals">
            <h3>Current Proposals</h3>
            <div id="proposal-list"></div>
        </div>
    </div>

    <script src="https://cdn.ethers.io/lib/ethers-5.7.2.umd.min.js"></script>
    <script src="app.js"></script>
</body>
</html>

And here's the JavaScript to make it all work (`app.js`):

let provider;
let signer;
let contract;
let userAccount;

const contractAddress = "YOUR_CONTRACT_ADDRESS_HERE"; // You'll get this after deployment
const contractABI = [
    // We'll add the ABI here after compiling our contract
];

async function connectWallet() {
    if (typeof window.ethereum !== 'undefined') {
        try {
            await window.ethereum.request({ method: 'eth_requestAccounts' });
            provider = new ethers.providers.Web3Provider(window.ethereum);
            signer = provider.getSigner();
            userAccount = await signer.getAddress();
            
            contract = new ethers.Contract(contractAddress, contractABI, signer);
            
            document.getElementById('account-info').innerHTML = 
                `<p>Connected: ${userAccount.substring(0, 6)}...${userAccount.substring(38)}</p>`;
            
            document.getElementById('create-proposal').style.display = 'block';
            loadProposals();
        } catch (error) {
            console.error('Error connecting wallet:', error);
        }
    } else {
        alert('Please install MetaMask!');
    }
}

async function createProposal() {
    const proposalText = document.getElementById('proposal-text').value;
    if (!proposalText) {
        alert('Please enter a proposal');
        return;
    }
    
    try {
        const tx = await contract.createProposal(proposalText);
        await tx.wait();
        
        document.getElementById('proposal-text').value = '';
        loadProposals();
        alert('Proposal created successfully!');
    } catch (error) {
        console.error('Error creating proposal:', error);
        alert('Error creating proposal. Make sure you are the owner.');
    }
}

async function vote(proposalId) {
    try {
        const tx = await contract.vote(proposalId);
        await tx.wait();
        
        loadProposals();
        alert('Vote cast successfully!');
    } catch (error) {
        console.error('Error voting:', error);
        alert('Error casting vote. You may have already voted.');
    }
}

async function loadProposals() {
    try {
        const proposalCount = await contract.proposalCount();
        const proposalList = document.getElementById('proposal-list');
        proposalList.innerHTML = '';
        
        for (let i = 1; i <= proposalCount; i++) {
            const proposal = await contract.getProposal(i);
            const proposalDiv = document.createElement('div');
            proposalDiv.className = 'proposal';
            proposalDiv.innerHTML = `
                <h4>${proposal[1]}</h4>
                <p>Votes: ${proposal[2]}</p>
                <button onclick="vote(${proposal[0]})">Vote</button>
            `;
            proposalList.appendChild(proposalDiv);
        }
    } catch (error) {
        console.error('Error loading proposals:', error);
    }
}

Deploying to a Test Network

Before we deploy to mainnet (which costs real money), let's deploy to a test network. I recommend using Sepolia testnet - it's reliable and easy to get test ETH for.

First, you'll need to update your `hardhat.config.js` file:

require("@nomicfoundation/hardhat-toolbox");

module.exports = {
  solidity: "0.8.19",
  networks: {
    sepolia: {
      url: "https://sepolia.infura.io/v3/YOUR_INFURA_PROJECT_ID",
      accounts: ["YOUR_PRIVATE_KEY_HERE"]
    }
  }
};

You'll need to sign up for an Infura account (it's free) to get an API key, and you'll need some test ETH in your wallet. You can get test ETH from faucets like sepolia-faucet.pk910.de.

Create a deployment script (`scripts/deploy.js`):

async function main() {
  const Voting = await ethers.getContractFactory("Voting");
  const voting = await Voting.deploy();

  await voting.deployed();

  console.log("Voting contract deployed to:", voting.address);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Deploy with: `npx hardhat run scripts/deploy.js --network sepolia`

Getting the Contract ABI

After compiling your contract with `npx hardhat compile`, you'll find the ABI in `artifacts/contracts/Voting.sol/Voting.json`. Copy the ABI array and paste it into your `app.js` file where we left the placeholder.

Also update the contract address in `app.js` with the address you got from deployment.

Testing Everything Together

Now comes the moment of truth! Open your `index.html` file in a browser, make sure you have MetaMask installed and connected to Sepolia testnet, and try creating a proposal and voting on it.

If everything works, congratulations! You've just built your first dApp. If something breaks (and let's be honest, something probably will), check the browser console for error messages and debug from there.

Common Issues You Might Run Into

Here are some problems I've encountered when building dApps and how to fix them:

  • MetaMask not connecting - make sure you're on the right network and have some test ETH
  • Contract calls failing - check that your ABI is correct and the contract address is right
  • Transactions reverting - look at the error message, it usually tells you what went wrong
  • Frontend not updating - make sure you're calling the load functions after state changes

One thing that trips up a lot of beginners is gas estimation. Sometimes your transactions will fail because there's not enough gas. You can usually fix this by adding a gas limit to your contract calls.

What's Next?

This is just the beginning! There's so much more you can do with dApps. You could add features like proposal deadlines, weighted voting, or integration with governance tokens. You could also improve the UI with a proper frontend framework like React or Vue.

Security is another big topic - our simple contract is fine for learning, but production contracts need more thorough security considerations. Look into things like reentrancy guards, proper access controls, and maybe even get your contracts audited.

The blockchain space moves fast, so keep learning and experimenting. Build things, break them, fix them, and repeat. That's honestly the best way to get good at this stuff.

And remember - every expert was once a beginner who didn't give up when their first contract deployment failed. Keep at it, and before you know it, you'll be building the next big DeFi protocol or NFT marketplace!

0 Comment

Share your thoughts

Your email address will not be published. Required fields are marked *