Building a Mini dApp on Ethereum: From Smart Contract to Frontend

Isha ParekhIsha Parekh
7 min read

Building decentralized applications (dApps) on Ethereum has become increasingly accessible with modern tooling and frameworks. In this guide, we'll walk through creating a simple voting dApp that demonstrates core blockchain development concepts, smart contract deployment, and frontend integration.

What We're Building

Our mini dApp will be a simple voting system where users can:

  • Create voting proposals

  • Cast votes on proposals

  • View voting results in real-time

  • Connect their MetaMask wallet

Tech Stack Overview

Smart Contract Layer:

  • Solidity for smart contract development

  • Hardhat for development environment

  • OpenZeppelin for security standards

Frontend Layer:

  • React.js for the user interface

  • Ethers.js for blockchain interaction

  • MetaMask for wallet connectivity

Deployment & Testing:

  • Sepolia testnet for deployment

  • Hardhat local network for testing

Prerequisites

Before we start, ensure you have:

  • Node.js (v16 or higher)

  • MetaMask browser extension

  • Basic understanding of JavaScript and React

  • Some test ETH from Sepolia faucet

Step 1: Project Setup

First, let's initialize our project structure:

mkdir voting-dapp
cd voting-dapp
npm init -y

# Install Hardhat and dependencies
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts

# Initialize Hardhat project
npx hardhat init

Choose "Create a TypeScript project" when prompted.

Step 2: Writing the Smart Contract

Create contracts/Voting.sol:

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

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract Voting is Ownable, ReentrancyGuard {
    struct Proposal {
        uint256 id;
        string description;
        uint256 voteCount;
        bool exists;
        mapping(address => bool) hasVoted;
    }

    uint256 public proposalCount;
    mapping(uint256 => Proposal) public proposals;

    event ProposalCreated(uint256 indexed proposalId, string description);
    event VoteCast(uint256 indexed proposalId, address indexed voter);

    constructor() {}

    function createProposal(string memory _description) public onlyOwner {
        proposalCount++;
        Proposal storage newProposal = proposals[proposalCount];
        newProposal.id = proposalCount;
        newProposal.description = _description;
        newProposal.voteCount = 0;
        newProposal.exists = true;

        emit ProposalCreated(proposalCount, _description);
    }

    function vote(uint256 _proposalId) public nonReentrant {
        require(proposals[_proposalId].exists, "Proposal does not exist");
        require(!proposals[_proposalId].hasVoted[msg.sender], "Already voted");

        proposals[_proposalId].hasVoted[msg.sender] = true;
        proposals[_proposalId].voteCount++;

        emit VoteCast(_proposalId, msg.sender);
    }

    function getProposal(uint256 _proposalId) public view returns (
        uint256 id,
        string memory description,
        uint256 voteCount
    ) {
        require(proposals[_proposalId].exists, "Proposal does not exist");
        Proposal storage proposal = proposals[_proposalId];
        return (proposal.id, proposal.description, proposal.voteCount);
    }

    function hasVoted(uint256 _proposalId, address _voter) public view returns (bool) {
        return proposals[_proposalId].hasVoted[_voter];
    }
}

Step 3: Testing the Smart Contract

Create test/Voting.test.ts:

import { expect } from "chai";
import { ethers } from "hardhat";
import { Voting } from "../typechain-types";

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

  beforeEach(async function () {
    [owner, voter1, voter2] = await ethers.getSigners();

    const VotingFactory = await ethers.getContractFactory("Voting");
    voting = await VotingFactory.deploy();
    await voting.deployed();
  });

  it("Should create a proposal", async function () {
    await voting.createProposal("Should we implement feature X?");

    const proposal = await voting.getProposal(1);
    expect(proposal.description).to.equal("Should we implement feature X?");
    expect(proposal.voteCount).to.equal(0);
  });

  it("Should allow voting on proposals", async function () {
    await voting.createProposal("Test proposal");

    await voting.connect(voter1).vote(1);

    const proposal = await voting.getProposal(1);
    expect(proposal.voteCount).to.equal(1);
  });

  it("Should prevent double voting", async function () {
    await voting.createProposal("Test proposal");
    await voting.connect(voter1).vote(1);

    await expect(voting.connect(voter1).vote(1))
      .to.be.revertedWith("Already voted");
  });
});

Run tests with:

npx hardhat test

Step 4: Deployment Configuration

Update hardhat.config.ts:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import * as dotenv from "dotenv";

dotenv.config();

const config: HardhatUserConfig = {
  solidity: "0.8.19",
  networks: {
    sepolia: {
      url: process.env.SEPOLIA_URL || "",
      accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
    },
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API_KEY,
  },
};

export default config;

Create .env file:

SEPOLIA_URL=https://sepolia.infura.io/v3/YOUR_INFURA_PROJECT_ID
PRIVATE_KEY=your_wallet_private_key
ETHERSCAN_API_KEY=your_etherscan_api_key

Step 5: Deployment Script

Create scripts/deploy.ts:

import { ethers } from "hardhat";

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

  console.log("Deploying Voting contract...");
  const voting = await VotingFactory.deploy();
  await voting.deployed();

  console.log(`Voting contract deployed to: ${voting.address}`);

  // Create a sample proposal
  const tx = await voting.createProposal("Should we add more features to this dApp?");
  await tx.wait();

  console.log("Sample proposal created!");
}

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

Deploy to Sepolia:

npx hardhat run scripts/deploy.ts --network sepolia

Step 6: Frontend Development

Initialize React app:

npx create-react-app frontend --template typescript
cd frontend
npm install ethers @types/node

Create src/hooks/useContract.ts:

import { useState, useEffect } from 'react';
import { ethers } from 'ethers';
import VotingABI from '../contracts/Voting.json';

const CONTRACT_ADDRESS = 'YOUR_DEPLOYED_CONTRACT_ADDRESS';

export const useContract = () => {
  const [contract, setContract] = useState<ethers.Contract | null>(null);
  const [account, setAccount] = useState<string>('');
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);

  useEffect(() => {
    const initContract = async () => {
      if (window.ethereum) {
        const provider = new ethers.providers.Web3Provider(window.ethereum);
        const signer = provider.getSigner();
        const contract = new ethers.Contract(CONTRACT_ADDRESS, VotingABI.abi, signer);

        setProvider(provider);
        setContract(contract);
      }
    };

    initContract();
  }, []);

  const connectWallet = async () => {
    if (window.ethereum) {
      try {
        const accounts = await window.ethereum.request({
          method: 'eth_requestAccounts',
        });
        setAccount(accounts[0]);
      } catch (error) {
        console.error('Error connecting wallet:', error);
      }
    }
  };

  return { contract, account, provider, connectWallet };
};

Create main component src/components/VotingApp.tsx:

import React, { useState, useEffect } from 'react';
import { useContract } from '../hooks/useContract';

interface Proposal {
  id: number;
  description: string;
  voteCount: number;
}

const VotingApp: React.FC = () => {
  const { contract, account, connectWallet } = useContract();
  const [proposals, setProposals] = useState<Proposal[]>([]);
  const [newProposal, setNewProposal] = useState('');
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    loadProposals();
  }, [contract]);

  const loadProposals = async () => {
    if (!contract) return;

    try {
      const proposalCount = await contract.proposalCount();
      const loadedProposals: Proposal[] = [];

      for (let i = 1; i <= proposalCount.toNumber(); i++) {
        const proposal = await contract.getProposal(i);
        loadedProposals.push({
          id: proposal.id.toNumber(),
          description: proposal.description,
          voteCount: proposal.voteCount.toNumber(),
        });
      }

      setProposals(loadedProposals);
    } catch (error) {
      console.error('Error loading proposals:', error);
    }
  };

  const createProposal = async () => {
    if (!contract || !newProposal.trim()) return;

    setLoading(true);
    try {
      const tx = await contract.createProposal(newProposal);
      await tx.wait();
      setNewProposal('');
      loadProposals();
    } catch (error) {
      console.error('Error creating proposal:', error);
    }
    setLoading(false);
  };

  const vote = async (proposalId: number) => {
    if (!contract) return;

    setLoading(true);
    try {
      const tx = await contract.vote(proposalId);
      await tx.wait();
      loadProposals();
    } catch (error) {
      console.error('Error voting:', error);
    }
    setLoading(false);
  };

  return (
    <div className="min-h-screen bg-gray-100 py-8">
      <div className="max-w-4xl mx-auto px-4">
        <h1 className="text-3xl font-bold text-center mb-8">Decentralized Voting App</h1>

        {!account ? (
          <div className="text-center">
            <button
              onClick={connectWallet}
              className="bg-blue-500 text-white px-6 py-3 rounded-lg hover:bg-blue-600"
            >
              Connect Wallet
            </button>
          </div>
        ) : (
          <div className="space-y-8">
            <div className="bg-white p-6 rounded-lg shadow">
              <h2 className="text-xl font-semibold mb-4">Connected: {account.slice(0, 6)}...{account.slice(-4)}</h2>

              <div className="flex gap-4">
                <input
                  type="text"
                  value={newProposal}
                  onChange={(e) => setNewProposal(e.target.value)}
                  placeholder="Enter proposal description"
                  className="flex-1 border border-gray-300 px-4 py-2 rounded-lg"
                />
                <button
                  onClick={createProposal}
                  disabled={loading}
                  className="bg-green-500 text-white px-6 py-2 rounded-lg hover:bg-green-600 disabled:opacity-50"
                >
                  Create Proposal
                </button>
              </div>
            </div>

            <div className="space-y-4">
              <h2 className="text-xl font-semibold">Active Proposals</h2>
              {proposals.map((proposal) => (
                <div key={proposal.id} className="bg-white p-6 rounded-lg shadow">
                  <h3 className="font-semibold mb-2">Proposal #{proposal.id}</h3>
                  <p className="text-gray-700 mb-4">{proposal.description}</p>
                  <div className="flex justify-between items-center">
                    <span className="text-sm text-gray-500">Votes: {proposal.voteCount}</span>
                    <button
                      onClick={() => vote(proposal.id)}
                      disabled={loading}
                      className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
                    >
                      Vote
                    </button>
                  </div>
                </div>
              ))}
            </div>
          </div>
        )}
      </div>
    </div>
  );
};

export default VotingApp;

Step 7: Integration with Different Protocols

Layer 2 Solutions

Polygon Integration:

// In hardhat.config.ts, add Polygon network
polygon: {
  url: "https://polygon-rpc.com/",
  accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},

Arbitrum Integration:

arbitrum: {
  url: "https://arb1.arbitrum.io/rpc",
  accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},

IPFS Integration for Metadata

// Install IPFS client
npm install ipfs-http-client

// In your React component
import { create } from 'ipfs-http-client';

const ipfs = create({ url: 'https://ipfs.infura.io:5001/api/v0' });

const uploadToIPFS = async (data: any) => {
  try {
    const result = await ipfs.add(JSON.stringify(data));
    return result.path;
  } catch (error) {
    console.error('IPFS upload error:', error);
  }
};

Step 8: Security Best Practices

  1. Access Control: Use OpenZeppelin's Ownable and AccessControl

  2. Reentrancy Protection: Implement ReentrancyGuard

  3. Integer Overflow: Solidity 0.8+ has built-in protection

  4. Gas Optimization: Use view and pure functions where possible

  5. Event Logging: Emit events for important state changes

Step 9: Testing and Deployment

Local Testing

# Start local Hardhat network
npx hardhat node

# Deploy to local network
npx hardhat run scripts/deploy.ts --network localhost

Testnet Deployment

# Deploy to Sepolia
npx hardhat run scripts/deploy.ts --network sepolia

# Verify contract
npx hardhat verify --network sepolia CONTRACT_ADDRESS

Conclusion

We've successfully built a complete voting dApp that demonstrates:

  • Smart contract development with Solidity

  • Security best practices with OpenZeppelin

  • Frontend integration with React and Ethers.js

  • Multi-network deployment capabilities

  • Testing and verification workflows

This foundation can be extended with features like:

  • Time-based voting periods

  • Token-weighted voting

  • Proposal categories

  • Advanced governance mechanisms

  • Integration with ENS for human-readable addresses

The modular architecture makes it easy to adapt this dApp for different use cases while maintaining security and scalability principles essential for production blockchain applications.

Next Steps

  1. Enhanced UI/UX: Add loading states, error handling, and responsive design

  2. Advanced Features: Implement delegation, quadratic voting, or liquid democracy

  3. Analytics: Add proposal analytics and voting history

  4. Mobile Support: Create React Native version or PWA

  5. Integration: Connect with existing governance tokens or DAOs


Start building, experiment with different protocols, and remember that the blockchain development ecosystem is rapidly evolving. Stay updated with the latest tools. Keep building ❤️

12
Subscribe to my newsletter

Read articles from Isha Parekh directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Isha Parekh
Isha Parekh

Building in public