# Building a Voting dApp on Starknet
A complete step-by-step guide to building a decentralized voting application on Starknet using Cairo and Next.js.
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Project Setup](#project-setup)
3. [Building the Smart Contract](#building-the-smart-contract)
4. [Writing Contract Tests](#writing-contract-tests)
5. [Deploying the Contract](#deploying-the-contract)
6. [Building the Frontend](#building-the-frontend)
7. [Testing the Application](#testing-the-application)
---
## Prerequisites
Before starting, ensure you have:
- Node.js (v22 or higher)
- Yarn package manager
- Git
- A Starknet wallet (Argent X or Braavos)
- Testnet ETH/STRK for Sepolia
### Required Knowledge
- Basic understanding of blockchain concepts
- JavaScript/TypeScript fundamentals
- React basics
- Cairo programming basics
---
## Project Setup
### Step 1: Create a Scaffold-Stark Project
```bash
npx create-stark@latest
```
When prompted:
- **Project Name:** `voting-dapp` (or your preferred name)
- **Development Tools:** Choose native tools or Dev Containers
### Step 2: Navigate to Project Directory
```bash
cd voting-dapp
```
### Step 3: Install Dependencies
```bash
yarn install
```
### Step 4: Verify Installation
```bash
# Terminal 1: Start local blockchain
yarn chain
# Terminal 2: In a new terminal
yarn deploy
# Terminal 3: In another new terminal
yarn start
```
Visit `http://localhost:3000` to verify the scaffold is working.
---
## Building the Smart Contract
### Step 1: Create the Voting Contract File
Navigate to `packages/snfoundry/contracts/src/` and create a new file called `voting_contract.cairo`:
```bash
touch packages/snfoundry/contracts/src/voting_contract.cairo
```
### Step 2: Write the Voting Contract
Paste the following code into `voting_contract.cairo`:
```cairo
use starknet::ContractAddress;
#[starknet::interface]
pub trait IVoting<TContractState> {
fn register_voter(ref self: TContractState);
fn create_proposal(ref self: TContractState, description: ByteArray);
fn vote(ref self: TContractState, proposal_id: u256, vote: bool);
fn get_proposal_details(self: @TContractState, proposal_id: u256) -> ProposalDetails;
fn get_proposal_count(self: @TContractState) -> u256;
fn is_voter_registered(self: @TContractState, voter: ContractAddress) -> bool;
}
#[derive(Drop, Serde, starknet::Store)]
pub struct Proposal {
pub id: u256,
pub description: ByteArray,
pub yes_votes: u256,
pub no_votes: u256,
pub ended: bool,
}
#[derive(Drop, Serde)]
pub struct ProposalDetails {
pub id: u256,
pub description: ByteArray,
pub yes_votes: u256,
pub no_votes: u256,
pub ended: bool,
}
#[starknet::contract]
pub mod VotingContract {
use openzeppelin_access::ownable::OwnableComponent;
use starknet::storage::{
Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess,
StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_caller_address};
use super::{IVoting, Proposal, ProposalDetails};
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
#[abi(embed_v0)]
impl OwnableImpl = OwnableComponent::OwnableImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
OwnableEvent: OwnableComponent::Event,
VoterRegistered: VoterRegistered,
ProposalCreated: ProposalCreated,
VoteCast: VoteCast,
}
#[derive(Drop, starknet::Event)]
struct VoterRegistered {
#[key]
voter: ContractAddress,
}
#[derive(Drop, starknet::Event)]
struct ProposalCreated {
#[key]
proposal_id: u256,
description: ByteArray,
}
#[derive(Drop, starknet::Event)]
struct VoteCast {
#[key]
proposal_id: u256,
#[key]
voter: ContractAddress,
vote: bool,
}
#[storage]
struct Storage {
registered_voters: Map<ContractAddress, bool>,
proposals: Map<u256, Proposal>,
proposal_count: u256,
has_voted: Map<(u256, ContractAddress), bool>,
#[substorage(v0)]
ownable: OwnableComponent::Storage,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
self.ownable.initializer(owner);
self.proposal_count.write(0);
}
#[abi(embed_v0)]
impl VotingImpl of IVoting<ContractState> {
fn register_voter(ref self: ContractState) {
let caller = get_caller_address();
assert!(!self.registered_voters.read(caller), "Already registered");
self.registered_voters.write(caller, true);
self.emit(VoterRegistered { voter: caller });
}
fn create_proposal(ref self: ContractState, description: ByteArray) {
self.ownable.assert_only_owner();
let proposal_id = self.proposal_count.read();
let new_proposal = Proposal {
id: proposal_id,
description: description.clone(),
yes_votes: 0,
no_votes: 0,
ended: false,
};
self.proposals.write(proposal_id, new_proposal);
self.proposal_count.write(proposal_id + 1);
self.emit(ProposalCreated { proposal_id, description });
}
fn vote(ref self: ContractState, proposal_id: u256, vote: bool) {
let caller = get_caller_address();
assert!(self.registered_voters.read(caller), "Not registered");
assert!(proposal_id < self.proposal_count.read(), "Invalid proposal");
assert!(!self.has_voted.read((proposal_id, caller)), "Already voted");
let mut proposal = self.proposals.read(proposal_id);
assert!(!proposal.ended, "Proposal ended");
if vote {
proposal.yes_votes += 1;
} else {
proposal.no_votes += 1;
}
self.proposals.write(proposal_id, proposal);
self.has_voted.write((proposal_id, caller), true);
self.emit(VoteCast { proposal_id, voter: caller, vote });
}
fn get_proposal_details(self: @ContractState, proposal_id: u256) -> ProposalDetails {
assert!(proposal_id < self.proposal_count.read(), "Invalid proposal");
let proposal = self.proposals.read(proposal_id);
ProposalDetails {
id: proposal.id,
description: proposal.description,
yes_votes: proposal.yes_votes,
no_votes: proposal.no_votes,
ended: proposal.ended,
}
}
fn get_proposal_count(self: @ContractState) -> u256 {
self.proposal_count.read()
}
fn is_voter_registered(self: @ContractState, voter: ContractAddress) -> bool {
self.registered_voters.read(voter)
}
}
}
```
#### 📖 Code Explanation
This voting contract demonstrates several key Cairo concepts:
**1. Interface Definition (`IVoting`)**
- Defines 6 public functions that external applications can call
- `register_voter()`: Allows anyone to register as a voter
- `create_proposal()`: Owner-only function to create new proposals
- `vote()`: Registered voters can cast yes/no votes
- `get_proposal_details()`: Read proposal information
- `get_proposal_count()`: Get total number of proposals
- `is_voter_registered()`: Check if an address is registered
**2. Data Structures**
- `Proposal` struct: Stored on-chain with all proposal data
- `ProposalDetails` struct: Used to return data to external callers
- Both contain: id, description, vote counts, and ended status
**3. Storage**
- `registered_voters`: Maps addresses to registration status
- `proposals`: Maps proposal IDs to proposal data
- `proposal_count`: Tracks total proposals created
- `has_voted`: Prevents double voting using composite key (proposal_id, voter)
- `ownable`: OpenZeppelin component for access control
**4. Access Control**
- Uses OpenZeppelin's `OwnableComponent` for secure owner management
- Only owner can create proposals (`assert_only_owner()`)
- Anyone can register as voter
- Only registered voters can vote
**5. Security Features**
- `assert!` statements validate all inputs before execution
- Double voting prevented by tracking (proposal_id, voter) pairs
- Registration required before voting
- Events emitted for transparency
**6. Events**
- `VoterRegistered`: Logged when someone registers
- `ProposalCreated`: Logged when proposal is created
- `VoteCast`: Logged when someone votes
- Events enable tracking all actions on the blockchain
### Step 3: Register the Contract in lib.cairo
Open `packages/snfoundry/contracts/src/lib.cairo` and add:
```cairo
pub mod your_contract;
pub mod voting_contract;
```
### Step 4: Compile the Contract
```bash
cd packages/snfoundry/contracts
scarb build
```
You should see: `✓ Compiled successfully`
---
## Writing Contract Tests
### Step 1: Create Test File
Create `packages/snfoundry/contracts/tests/test_voting.cairo`:
```bash
touch packages/snfoundry/contracts/tests/test_voting.cairo
```
### Step 2: Write Comprehensive Tests
Paste the following test code:
```cairo
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address};
use starknet::ContractAddress;
use core::starknet::contract_address::contract_address_const;
use contracts::voting_contract::{IVotingDispatcher, IVotingDispatcherTrait};
fn deploy_voting_contract() -> (IVotingDispatcher, ContractAddress) {
let owner: ContractAddress = contract_address_const::<'owner'>();
let contract = declare("VotingContract").unwrap().contract_class();
let mut constructor_calldata = array![owner.into()];
let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap();
let dispatcher = IVotingDispatcher { contract_address };
(dispatcher, owner)
}
#[test]
fn test_voter_registration() {
let (voting_contract, _owner) = deploy_voting_contract();
let voter: ContractAddress = contract_address_const::<'voter1'>();
start_cheat_caller_address(voting_contract.contract_address, voter);
voting_contract.register_voter();
let is_registered = voting_contract.is_voter_registered(voter);
assert!(is_registered, "Voter should be registered");
}
#[test]
#[should_panic]
fn test_double_registration_fails() {
let (voting_contract, _owner) = deploy_voting_contract();
let voter: ContractAddress = contract_address_const::<'voter1'>();
start_cheat_caller_address(voting_contract.contract_address, voter);
voting_contract.register_voter();
voting_contract.register_voter();
}
#[test]
fn test_create_proposal() {
let (voting_contract, owner) = deploy_voting_contract();
start_cheat_caller_address(voting_contract.contract_address, owner);
voting_contract.create_proposal("Should we upgrade the protocol?");
let count = voting_contract.get_proposal_count();
assert!(count == 1, "Proposal count should be 1");
let proposal = voting_contract.get_proposal_details(0);
assert!(proposal.id == 0, "Proposal ID should be 0");
assert!(proposal.yes_votes == 0, "Yes votes should be 0");
assert!(proposal.no_votes == 0, "No votes should be 0");
assert!(!proposal.ended, "Proposal should not be ended");
}
#[test]
#[should_panic]
fn test_non_owner_cannot_create_proposal() {
let (voting_contract, _owner) = deploy_voting_contract();
let non_owner: ContractAddress = contract_address_const::<'non_owner'>();
start_cheat_caller_address(voting_contract.contract_address, non_owner);
voting_contract.create_proposal("This should fail");
}
#[test]
fn test_voting_yes() {
let (voting_contract, owner) = deploy_voting_contract();
let voter: ContractAddress = contract_address_const::<'voter1'>();
start_cheat_caller_address(voting_contract.contract_address, voter);
voting_contract.register_voter();
start_cheat_caller_address(voting_contract.contract_address, owner);
voting_contract.create_proposal("Should we do this?");
start_cheat_caller_address(voting_contract.contract_address, voter);
voting_contract.vote(0, true);
let proposal = voting_contract.get_proposal_details(0);
assert!(proposal.yes_votes == 1, "Yes votes should be 1");
assert!(proposal.no_votes == 0, "No votes should be 0");
}
#[test]
fn test_voting_no() {
let (voting_contract, owner) = deploy_voting_contract();
let voter: ContractAddress = contract_address_const::<'voter1'>();
start_cheat_caller_address(voting_contract.contract_address, voter);
voting_contract.register_voter();
start_cheat_caller_address(voting_contract.contract_address, owner);
voting_contract.create_proposal("Should we do this?");
start_cheat_caller_address(voting_contract.contract_address, voter);
voting_contract.vote(0, false);
let proposal = voting_contract.get_proposal_details(0);
assert!(proposal.yes_votes == 0, "Yes votes should be 0");
assert!(proposal.no_votes == 1, "No votes should be 1");
}
#[test]
fn test_multiple_voters() {
let (voting_contract, owner) = deploy_voting_contract();
let voter1: ContractAddress = contract_address_const::<'voter1'>();
let voter2: ContractAddress = contract_address_const::<'voter2'>();
let voter3: ContractAddress = contract_address_const::<'voter3'>();
start_cheat_caller_address(voting_contract.contract_address, voter1);
voting_contract.register_voter();
start_cheat_caller_address(voting_contract.contract_address, voter2);
voting_contract.register_voter();
start_cheat_caller_address(voting_contract.contract_address, voter3);
voting_contract.register_voter();
start_cheat_caller_address(voting_contract.contract_address, owner);
voting_contract.create_proposal("Multiple voters test");
start_cheat_caller_address(voting_contract.contract_address, voter1);
voting_contract.vote(0, true);
start_cheat_caller_address(voting_contract.contract_address, voter2);
voting_contract.vote(0, true);
start_cheat_caller_address(voting_contract.contract_address, voter3);
voting_contract.vote(0, false);
let proposal = voting_contract.get_proposal_details(0);
assert!(proposal.yes_votes == 2, "Yes votes should be 2");
assert!(proposal.no_votes == 1, "No votes should be 1");
}
#[test]
#[should_panic]
fn test_unregistered_voter_cannot_vote() {
let (voting_contract, owner) = deploy_voting_contract();
let voter: ContractAddress = contract_address_const::<'voter1'>();
start_cheat_caller_address(voting_contract.contract_address, owner);
voting_contract.create_proposal("Test proposal");
start_cheat_caller_address(voting_contract.contract_address, voter);
voting_contract.vote(0, true);
}
#[test]
#[should_panic]
fn test_cannot_vote_twice() {
let (voting_contract, owner) = deploy_voting_contract();
let voter: ContractAddress = contract_address_const::<'voter1'>();
start_cheat_caller_address(voting_contract.contract_address, voter);
voting_contract.register_voter();
start_cheat_caller_address(voting_contract.contract_address, owner);
voting_contract.create_proposal("Test proposal");
start_cheat_caller_address(voting_contract.contract_address, voter);
voting_contract.vote(0, true);
voting_contract.vote(0, true);
}
#[test]
#[should_panic]
fn test_vote_on_invalid_proposal() {
let (voting_contract, _owner) = deploy_voting_contract();
let voter: ContractAddress = contract_address_const::<'voter1'>();
start_cheat_caller_address(voting_contract.contract_address, voter);
voting_contract.register_voter();
voting_contract.vote(999, true);
}
```
#### 📖 Code Explanation
This test suite validates all contract functionality:
**1. Test Helper Function**
- `deploy_voting_contract()`: Deploys a fresh contract instance for each test
- Creates owner address and deploys contract with owner as constructor arg
- Returns dispatcher (contract interface) and owner address
**2. Voter Registration Tests**
- `test_voter_registration()`: Verifies users can register successfully
- `test_double_registration_fails()`: Ensures same user can't register twice
- Uses `start_cheat_caller_address()` to simulate different callers
**3. Proposal Creation Tests**
- `test_create_proposal()`: Validates owner can create proposals
- Checks proposal count increments correctly
- Verifies proposal data stored properly (id, votes start at 0)
- `test_non_owner_cannot_create_proposal()`: Security test - non-owners blocked
**4. Voting Tests**
- `test_voting_yes()`: Tests voting "yes" increments yes_votes
- `test_voting_no()`: Tests voting "no" increments no_votes
- `test_multiple_voters()`: Validates multiple users can vote on same proposal
- Each test verifies vote counts are accurate
**5. Security Tests**
- `test_unregistered_voter_cannot_vote()`: Prevents unregistered voting
- `test_cannot_vote_twice()`: Prevents double voting
- `test_vote_on_invalid_proposal()`: Validates proposal ID exists
- All use `#[should_panic]` to expect failure
**6. Testing Pattern**
- Each test is isolated (fresh contract deployment)
- Uses `start_cheat_caller_address()` to simulate different users
- Tests both success paths and failure paths
- Comprehensive coverage of all contract functions
### Step 3: Run Tests
```bash
cd packages/snfoundry/contracts
snforge test
```
You should see all 12 tests passing ✅
---
## Deploying the Contract
### Step 1: Update Deployment Script
Open `packages/snfoundry/scripts-ts/deploy.ts` and update the `deployScript` function:
```typescript
const deployScript = async (): Promise<void> => {
await deployContract({
contract: "YourContract",
constructorArgs: {
owner: deployer.address,
},
});
await deployContract({
contract: "VotingContract",
constructorArgs: {
owner: deployer.address,
},
});
};
```
#### 📖 Code Explanation
**Deployment Script Structure:**
1. **First Contract**: Deploys `YourContract` (the default scaffold contract)
- Uses `deployer.address` as the owner
- This is the example contract that comes with Scaffold-Stark
2. **Second Contract**: Deploys `VotingContract` (your new contract)
- Also uses `deployer.address` as owner
- Owner will be able to create proposals
3. **Constructor Arguments**:
- Both contracts require an `owner` parameter
- The deployer's wallet address is set as owner
- Owner has special privileges (creating proposals in VotingContract)
4. **Deployment Order**:
- Contracts deploy sequentially
- Each deployment returns contract address
- Addresses auto-saved to `deployedContracts.ts` for frontend use
### Step 2: Configure Network
Open `packages/nextjs/scaffold.config.ts` and set target network:
```typescript
const scaffoldConfig = {
targetNetworks: [chains.sepolia], // Change from devnet to sepolia
onlyLocalBurnerWallet: false,
pollingInterval: 30_000,
autoConnectTTL: 60000,
walletAutoConnect: true,
} as const satisfies ScaffoldConfig;
```
#### 📖 Code Explanation
**Network Configuration:**
1. **Target Networks**:
- `targetNetworks: [chains.sepolia]`: Sets which blockchain to use
- Changed from `chains.devnet` (local) to `chains.sepolia` (testnet)
- Your contract is deployed on Sepolia, so frontend must match
2. **Configuration Options**:
- `onlyLocalBurnerWallet: false`: Allows real wallets (Argent X, Braavos)
- `pollingInterval: 30_000`: Fetch new data every 30 seconds
- `autoConnectTTL: 60000`: Keep wallet connected for 60 seconds
- `walletAutoConnect: true`: Reconnect wallet on page reload
3. **Why Sepolia**:
- Public testnet accessible to everyone
- Free testnet tokens available from faucets
- Persistent (unlike local devnet)
- Similar to mainnet but for testing
4. **Impact on Frontend**:
- All contract reads/writes go to Sepolia
- Wallet must be on Sepolia network
- Uses Sepolia RPC endpoints
- Block explorer links point to Sepolia
5. **Type Safety**:
- `as const satisfies ScaffoldConfig`: Ensures type correctness
- TypeScript validates all configuration
- Prevents typos and invalid values
### Step 3: Setup Environment Variables
Create `.env` in `packages/snfoundry/`:
```bash
# Sepolia
ACCOUNT_ADDRESS_SEPOLIA=0x... # Your wallet address
PRIVATE_KEY_SEPOLIA=0x... # Your private key
```
**⚠️ Security Warning:** Never commit your `.env` file to Git!
### Step 4: Deploy to Sepolia
```bash
yarn deploy --network sepolia
```
Save the contract address from the output!
---
## Building the Frontend
### Step 1: Export Scaffold-Stark Hooks
Open `packages/nextjs/hooks/scaffold-stark/index.ts` and add:
```typescript
export * from "./useOutsideClick";
export * from "./useDeployedContractInfo";
export * from "./useNetworkColor";
export * from "./useAnimationConfig";
export * from "./useTransactor";
export * from "./useAutoConnect";
export * from "./useSwitchNetwork";
export * from "./useScaffoldReadContract";
export * from "./useScaffoldWriteContract";
export * from "./useScaffoldContract";
export * from "./useScaffoldEventHistory";
export * from "./useScaffoldWatchContractEvent";
export * from "./useScaffoldMultiWriteContract";
export * from "./useTargetNetwork";
```
#### 📖 Code Explanation
**Exporting Scaffold-Stark Hooks:**
1. **Purpose**:
- Makes all Scaffold-Stark custom hooks available for import
- Centralizes exports in one file for cleaner imports
- Required for TypeScript to find the hooks
2. **Key Hooks for Voting dApp**:
- `useScaffoldReadContract`: Read data from smart contracts
- `useScaffoldWriteContract`: Write data to smart contracts
- `useTargetNetwork`: Get current network configuration
- `useTransactor`: Handle transaction with UI feedback
3. **Why This is Needed**:
- By default, not all hooks are exported from index.ts
- Your components import from `~~/hooks/scaffold-stark`
- This file determines what's available
- Without these exports, you'll get "module not found" errors
4. **Best Practice**:
- Export all hooks even if not currently used
- Enables easy access for future features
- Follows standard module export pattern
### Step 2: Create Voting Page
Create `packages/nextjs/app/voting/page.tsx`:
```typescript
"use client";
import { useState, useEffect } from "react";
import { useAccount } from "@starknet-react/core";
import { VoterRegistration } from "./_components/VoterRegistration";
import { CreateProposal } from "./_components/CreateProposal";
import { ProposalList } from "./_components/ProposalList";
import { useScaffoldReadContract } from "~~/hooks/scaffold-stark";
import { Address } from "~~/components/scaffold-stark";
export default function VotingPage() {
const { address: connectedAddress } = useAccount();
const { data: isRegisteredData, refetch: refetchIsRegistered } =
useScaffoldReadContract({
contractName: "VotingContract",
functionName: "is_voter_registered",
args: [connectedAddress as `0x${string}`],
enabled: !!connectedAddress,
});
const isRegistered = isRegisteredData as unknown as boolean | undefined;
const { data: contractOwnerData } = useScaffoldReadContract({
contractName: "VotingContract",
functionName: "owner",
});
const contractOwner = contractOwnerData as unknown as
| `0x${string}`
| undefined;
const { data: proposalCountData, refetch: refetchProposalCount } =
useScaffoldReadContract({
contractName: "VotingContract",
functionName: "get_proposal_count",
});
const proposalCount = proposalCountData as unknown as bigint | undefined;
const isOwner =
connectedAddress &&
contractOwner &&
connectedAddress.toLowerCase() === contractOwner.toLowerCase();
return (
<div className="flex items-center flex-col flex-grow pt-10">
<div className="px-5 w-full max-w-7xl">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold mb-2">🗳️ Voting dApp</h1>
<p className="text-lg opacity-80">
Decentralized voting powered by Starknet
</p>
{contractOwner && (
<div className="mt-4 text-sm">
<span className="opacity-70">Contract Owner: </span>
<Address address={contractOwner} />
</div>
)}
</div>
{!connectedAddress ? (
<div className="bg-base-200 rounded-xl p-8 text-center">
<h2 className="text-2xl mb-4">👋 Welcome!</h2>
<p className="text-lg mb-4">
Please connect your wallet to participate in voting
</p>
</div>
) : (
<>
{!isRegistered && (
<div className="mb-8">
<VoterRegistration
onRegistrationSuccess={() =>
refetchIsRegistered()
}
/>
</div>
)}
{isRegistered && (
<div className="alert alert-success mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>You are registered as a voter!</span>
</div>
)}
{isOwner && (
<div className="mb-8">
<CreateProposal
onProposalCreated={() =>
refetchProposalCount()
}
/>
</div>
)}
<div className="mb-8">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold">
📋 Proposals
{proposalCount !== undefined && (
<span className="ml-2 text-lg opacity-70">
({proposalCount.toString()})
</span>
)}
</h2>
</div>
<ProposalList
proposalCount={proposalCount}
isRegistered={!!isRegistered}
/>
</div>
</>
)}
</div>
</div>
);
}
```
#### 📖 Code Explanation
**Main Voting Page Component:**
1. **State Management**:
- `connectedAddress`: Gets the currently connected wallet address
- `isRegistered`: Checks if current user is a registered voter
- `contractOwner`: Fetches the contract owner address
- `proposalCount`: Gets total number of proposals
2. **Smart Contract Reads** (using `useScaffoldReadContract`):
- `is_voter_registered`: Checks registration status for connected address
- `owner`: Gets contract owner (to show owner-only features)
- `get_proposal_count`: Gets total proposals (to display count)
- `enabled: !!connectedAddress`: Only fetch when wallet is connected
3. **Type Casting**:
- `as unknown as boolean | undefined`: Converts Starknet return types to TypeScript
- Necessary because Starknet returns arrays, we extract the actual value
4. **Owner Detection**:
- Compares connected address with contract owner
- Uses `.toLowerCase()` for case-insensitive comparison
- Determines if "Create Proposal" section should show
5. **Conditional Rendering**:
- **Not Connected**: Shows welcome message
- **Not Registered**: Shows registration component
- **Registered**: Shows success badge
- **Is Owner**: Shows proposal creation form
- **Always**: Shows proposal list
6. **Data Refetching**:
- `refetchIsRegistered()`: Updates registration status after registration
- `refetchProposalCount()`: Updates count after proposal creation
- Keeps UI in sync with blockchain state
### Step 3: Create VoterRegistration Component
Create `packages/nextjs/app/voting/_components/VoterRegistration.tsx`:
```typescript
"use client";
import { useState } from "react";
import { useScaffoldWriteContract } from "~~/hooks/scaffold-stark";
interface VoterRegistrationProps {
onRegistrationSuccess: () => void;
}
export const VoterRegistration = ({
onRegistrationSuccess,
}: VoterRegistrationProps) => {
const [isRegistering, setIsRegistering] = useState(false);
const { sendAsync: registerVoter } = useScaffoldWriteContract({
contractName: "VotingContract",
functionName: "register_voter",
});
const handleRegister = async () => {
try {
setIsRegistering(true);
await registerVoter();
onRegistrationSuccess();
} catch (e) {
console.error("Error registering as voter:", e);
} finally {
setIsRegistering(false);
}
};
return (
<div className="bg-primary/10 rounded-xl p-8 border-2 border-primary">
<div className="flex flex-col items-center">
<div className="text-6xl mb-4">✋</div>
<h2 className="text-2xl font-bold mb-2">Register to Vote</h2>
<p className="text-center mb-6 opacity-80">
You need to register before you can participate in voting
</p>
<button
className="btn btn-primary btn-lg"
onClick={handleRegister}
disabled={isRegistering}
>
{isRegistering ? (
<>
<span className="loading loading-spinner loading-md"></span>
Registering...
</>
) : (
"Register as Voter"
)}
</button>
</div>
</div>
);
};
```
#### 📖 Code Explanation
**VoterRegistration Component:**
1. **Local State**:
- `isRegistering`: Boolean to track if registration transaction is in progress
- Used to disable button and show loading spinner
2. **Smart Contract Write** (using `useScaffoldWriteContract`):
- `contractName: "VotingContract"`: Targets the voting contract
- `functionName: "register_voter"`: Calls the registration function
- `sendAsync`: Returns a function to execute the transaction
3. **Registration Handler**:
- `handleRegister()`: Async function to process registration
- **Try block**: Calls `registerVoter()` and triggers success callback
- **Catch block**: Logs errors to console
- **Finally block**: Resets loading state regardless of outcome
4. **Callback Pattern**:
- `onRegistrationSuccess()`: Called after successful registration
- Triggers parent component to refetch registration status
- Updates UI to show registered state
5. **UI/UX Features**:
- Large emoji (✋) for visual appeal
- Clear instructions for users
- Loading spinner during transaction
- Button disabled during registration to prevent double-clicks
- Primary color theme to draw attention
### Step 4: Create CreateProposal Component
Create `packages/nextjs/app/voting/_components/CreateProposal.tsx`:
```typescript
"use client";
import { useState } from "react";
import { useScaffoldWriteContract } from "~~/hooks/scaffold-stark";
interface CreateProposalProps {
onProposalCreated: () => void;
}
export const CreateProposal = ({ onProposalCreated }: CreateProposalProps) => {
const [description, setDescription] = useState("");
const [isCreating, setIsCreating] = useState(false);
const { sendAsync: createProposal } = useScaffoldWriteContract({
contractName: "VotingContract",
functionName: "create_proposal",
args: [description],
});
const handleCreateProposal = async () => {
if (!description.trim()) {
alert("Please enter a proposal description");
return;
}
try {
setIsCreating(true);
await createProposal();
setDescription("");
onProposalCreated();
} catch (e) {
console.error("Error creating proposal:", e);
} finally {
setIsCreating(false);
}
};
return (
<div className="bg-warning/10 rounded-xl p-6 border-2 border-warning">
<div className="flex items-center gap-2 mb-4">
<span className="text-2xl">👑</span>
<h2 className="text-2xl font-bold">
Create Proposal (Owner Only)
</h2>
</div>
<div className="space-y-4">
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">
Proposal Description
</span>
</label>
<textarea
className="textarea textarea-bordered h-24 text-base"
placeholder="e.g., Should we upgrade the protocol to version 2.0?"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isCreating}
/>
</div>
<button
className="btn btn-warning btn-block"
onClick={handleCreateProposal}
disabled={isCreating || !description.trim()}
>
{isCreating ? (
<>
<span className="loading loading-spinner loading-md"></span>
Creating Proposal...
</>
) : (
"Create Proposal"
)}
</button>
</div>
</div>
);
};
```
#### 📖 Code Explanation
**CreateProposal Component:**
1. **Local State**:
- `description`: Stores the proposal description text
- `isCreating`: Tracks if proposal creation is in progress
2. **Smart Contract Write**:
- Calls `create_proposal` function on VotingContract
- Passes `description` as argument
- Only contract owner can successfully call this
3. **Input Validation**:
- Checks if description is not empty (`!description.trim()`)
- Shows alert if user tries to submit empty proposal
- Prevents wasted gas on invalid transactions
4. **Proposal Creation Handler**:
- **Validation**: Ensures description is provided
- **Try block**: Calls contract, clears form, triggers callback
- **Catch block**: Handles errors
- **Finally block**: Resets loading state
5. **Form Reset**:
- `setDescription("")`: Clears input after successful creation
- Prepares form for next proposal
6. **UI Design**:
- Warning color theme (yellow): Indicates owner-only action
- Crown emoji (👑): Visual indicator of privileged action
- Textarea for multi-line descriptions
- Button disabled when creating or description empty
- Placeholder text guides users on what to write
### Step 5: Create ProposalList Component
Create `packages/nextjs/app/voting/_components/ProposalList.tsx`:
```typescript
"use client";
import { ProposalCard } from "./ProposalCard";
interface ProposalListProps {
proposalCount: bigint | undefined;
isRegistered: boolean;
}
export const ProposalList = ({
proposalCount,
isRegistered,
}: ProposalListProps) => {
if (proposalCount === undefined) {
return (
<div className="flex justify-center items-center p-12">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
const count = Number(proposalCount);
if (count === 0) {
return (
<div className="bg-base-200 rounded-xl p-12 text-center">
<div className="text-6xl mb-4">📭</div>
<h3 className="text-xl font-bold mb-2">No Proposals Yet</h3>
<p className="opacity-70">
Proposals created by the contract owner will appear here
</p>
</div>
);
}
return (
<div className="space-y-4">
{Array.from({ length: count }, (_, index) => (
<ProposalCard
key={index}
proposalId={BigInt(index)}
isRegistered={isRegistered}
/>
))}
</div>
);
};
```
#### 📖 Code Explanation
**ProposalList Component:**
1. **Props**:
- `proposalCount`: Total number of proposals (bigint)
- `isRegistered`: Boolean indicating if user can vote
2. **Loading State**:
- Shows spinner while `proposalCount` is undefined
- Fetching data from blockchain takes time
- Provides visual feedback to user
3. **Empty State**:
- When `count === 0`, shows empty state UI
- Inbox emoji (📭) and helpful message
- Better UX than showing nothing
4. **Dynamic List Rendering**:
- `Array.from({ length: count })`: Creates array with N elements
- `(_, index)`: Ignores value, uses index as proposal ID
- `BigInt(index)`: Converts to bigint for contract compatibility
- Each ProposalCard rendered with unique key
5. **Props Passing**:
- Passes `proposalId` to each card
- Passes `isRegistered` to enable/disable voting
- Each card fetches its own data independently
6. **Scalability**:
- Works with any number of proposals
- Automatically updates when count changes
- Each card is self-contained
### Step 6: Create ProposalCard Component
Create `packages/nextjs/app/voting/_components/ProposalCard.tsx`:
```typescript
"use client";
import { useState } from "react";
import {
useScaffoldReadContract,
useScaffoldWriteContract,
} from "~~/hooks/scaffold-stark";
interface ProposalCardProps {
proposalId: bigint;
isRegistered: boolean;
}
interface ProposalDetails {
id: bigint;
description: string;
yes_votes: bigint;
no_votes: bigint;
ended: boolean;
}
export const ProposalCard = ({
proposalId,
isRegistered,
}: ProposalCardProps) => {
const [isVoting, setIsVoting] = useState(false);
const { data: proposalData, refetch: refetchProposal } =
useScaffoldReadContract({
contractName: "VotingContract",
functionName: "get_proposal_details",
args: [proposalId],
});
const proposal = proposalData as unknown as ProposalDetails | undefined;
const { sendAsync: vote } = useScaffoldWriteContract({
contractName: "VotingContract",
functionName: "vote",
args: [proposalId, true],
});
const handleVote = async (voteYes: boolean) => {
try {
setIsVoting(true);
await vote({ args: [proposalId, voteYes] });
await refetchProposal();
} catch (e) {
console.error("Error voting:", e);
} finally {
setIsVoting(false);
}
};
if (!proposal) {
return (
<div className="card bg-base-200 shadow-xl">
<div className="card-body">
<div className="flex justify-center">
<span className="loading loading-spinner loading-md"></span>
</div>
</div>
</div>
);
}
const yesVotes = Number(proposal.yes_votes);
const noVotes = Number(proposal.no_votes);
const totalVotes = yesVotes + noVotes;
const yesPercentage = totalVotes > 0 ? (yesVotes / totalVotes) * 100 : 0;
const noPercentage = totalVotes > 0 ? (noVotes / totalVotes) * 100 : 0;
return (
<div className="card bg-base-100 shadow-xl border border-base-300">
<div className="card-body">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="card-title text-2xl">
Proposal #{proposal.id.toString()}
</h3>
{proposal.ended && (
<div className="badge badge-error mt-2">Ended</div>
)}
</div>
</div>
<div className="bg-base-200 rounded-lg p-4 mb-4">
<p className="text-lg">{proposal.description}</p>
</div>
<div className="space-y-3">
<div>
<div className="flex justify-between mb-1">
<span className="text-sm font-semibold flex items-center gap-2">
<span className="text-success">✓</span> Yes
</span>
<span className="text-sm font-semibold">
{yesVotes} votes ({yesPercentage.toFixed(1)}%)
</span>
</div>
<div className="w-full bg-base-300 rounded-full h-3">
<div
className="bg-success h-3 rounded-full transition-all duration-300"
style={{ width: `${yesPercentage}%` }}
></div>
</div>
</div>
<div>
<div className="flex justify-between mb-1">
<span className="text-sm font-semibold flex items-center gap-2">
<span className="text-error">✗</span> No
</span>
<span className="text-sm font-semibold">
{noVotes} votes ({noPercentage.toFixed(1)}%)
</span>
</div>
<div className="w-full bg-base-300 rounded-full h-3">
<div
className="bg-error h-3 rounded-full transition-all duration-300"
style={{ width: `${noPercentage}%` }}
></div>
</div>
</div>
<div className="text-sm opacity-70 text-center pt-2">
Total Votes: {totalVotes}
</div>
</div>
{!proposal.ended && isRegistered && (
<div className="card-actions justify-center mt-4 gap-4">
<button
className="btn btn-success flex-1"
onClick={() => handleVote(true)}
disabled={isVoting}
>
{isVoting ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
<>
<span>✓</span>
Vote Yes
</>
)}
</button>
<button
className="btn btn-error flex-1"
onClick={() => handleVote(false)}
disabled={isVoting}
>
{isVoting ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
<>
<span>✗</span>
Vote No
</>
)}
</button>
</div>
)}
{proposal.ended && (
<div className="alert alert-error mt-4">
<span>This proposal has ended. Voting is closed.</span>
</div>
)}
{!isRegistered && !proposal.ended && (
<div className="alert alert-warning mt-4">
<span>
Register as a voter to participate in this proposal
</span>
</div>
)}
</div>
</div>
);
};
```
#### 📖 Code Explanation
**ProposalCard Component (Most Complex Component):**
1. **Props & Types**:
- `proposalId`: The ID of this specific proposal
- `isRegistered`: Whether current user can vote
- `ProposalDetails` interface: Defines shape of proposal data
2. **Data Fetching**:
- `useScaffoldReadContract`: Fetches proposal details from blockchain
- `args: [proposalId]`: Passes proposal ID to contract function
- `refetchProposal`: Function to refresh data after voting
- Type cast to `ProposalDetails` for TypeScript
3. **Voting Functionality**:
- `useScaffoldWriteContract`: Prepares vote transaction
- `args: [proposalId, true]`: Default args (overridden when called)
- `handleVote(voteYes)`: Handles both yes and no votes
- `vote({ args: [proposalId, voteYes] })`: Overrides args at call time
4. **Vote Calculations**:
- `yesVotes` & `noVotes`: Convert bigint to number for display
- `totalVotes`: Sum of yes and no votes
- `yesPercentage`: (yes / total) \* 100
- `noPercentage`: (no / total) \* 100
- Handles division by zero (shows 0% when no votes)
5. **Progress Bars**:
- Visual representation of voting results
- Green bar for "Yes" votes
- Red bar for "No" votes
- Width based on percentage (`style={{ width: ${percentage}% }}`)
- Smooth transitions (`transition-all duration-300`)
6. **Conditional Rendering**:
- **Loading**: Shows spinner while fetching data
- **Vote Buttons**: Only shown if proposal not ended AND user registered
- **Ended Badge**: Shows if proposal is ended
- **Warning**: Shown to unregistered users
7. **Vote Handler**:
- Sets loading state during transaction
- Calls vote function with yes/no parameter
- Refetches proposal data after voting
- Updates UI automatically with new vote counts
- Handles errors gracefully
8. **UI/UX Features**:
- Color-coded voting (green=yes, red=no)
- Disabled buttons during voting (prevents double-voting)
- Loading spinners for async operations
- Percentage display for easy understanding
- Accessibility with proper labels and states
### Step 7: Add Navigation Link
Open `packages/nextjs/components/Header.tsx` and update the `menuLinks` array:
```typescript
export const menuLinks: HeaderMenuLink[] = [
{
label: "Home",
href: "/",
},
{
label: "Voting",
href: "/voting",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-4 w-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
/>
</svg>
),
},
{
label: "Debug Contracts",
href: "/debug",
icon: <BugAntIcon className="h-4 w-4" />,
},
];
```
#### 📖 Code Explanation
**Navigation Menu Update:**
1. **Menu Structure**:
- Array of objects defining navigation links
- Each link has: label, href, and optional icon
2. **New "Voting" Link**:
- `label: "Voting"`: Text shown in menu
- `href: "/voting"`: Route to voting page
- `icon`: SVG clipboard icon for visual identification
3. **Icon Design**:
- Uses Heroicons SVG format
- Clipboard/ballot icon appropriate for voting
- Consistent size (h-4 w-4) with other menu items
4. **Menu Order**:
- Home (landing page)
- Voting (your new feature)
- Debug Contracts (developer tools)
5. **Integration**:
- Header component automatically renders all links
- Active state highlighting included
- Mobile-responsive menu behavior
- Consistent styling with existing links
### Step 8: Build and Test
```bash
# Clear cache and start dev server
cd packages/nextjs
rm -rf .next
yarn dev
```
Visit `http://localhost:3000/voting`
---
## Testing the Application
### End-to-End Testing Flow
#### Test 1: Voter Registration
1. Connect your Starknet wallet
2. Click "Register as Voter"
3. Confirm transaction in wallet
4. Wait for confirmation
5. Verify success message appears
#### Test 2: Create Proposal (Owner Only)
1. Connect with owner wallet (the one that deployed)
2. Enter proposal description: "Should we upgrade to v2.0?"
3. Click "Create Proposal"
4. Confirm transaction
5. Verify proposal appears in list
#### Test 3: Vote on Proposal
1. Ensure you're registered
2. Find the proposal
3. Click "Vote Yes" or "Vote No"
4. Confirm transaction
5. Watch vote count update
#### Test 4: Multiple Voters
1. Switch to a different wallet
2. Register as voter
3. Vote on same proposal
4. Verify total votes update correctly
### Common Issues and Solutions
**Issue: "Cannot find module"**
```bash
# Clear Next.js cache
cd packages/nextjs
rm -rf .next
# Restart TS server in VS Code: Cmd+Shift+P > "TypeScript: Restart TS Server"
```
**Issue: Transaction failing**
- Ensure you have testnet ETH/STRK
- Check you're on Sepolia network
- Verify contract is deployed
**Issue: Wallet not connecting**
- Install Argent X or Braavos extension
- Switch to Sepolia testnet in wallet
- Refresh page
---
## Key Learning Points
### Smart Contract Concepts
1. **State Management**: Using storage maps for voters and proposals
2. **Access Control**: OpenZeppelin Ownable for admin functions
3. **Events**: Emitting events for transparency
4. **Validation**: Assert statements for security
### Frontend Integration
1. **Hooks**: Using Scaffold-Stark hooks for contract interaction
2. **State Management**: React hooks for UI state
3. **Type Safety**: TypeScript for type checking
4. **Real-time Updates**: Automatic refetching of data
### Security Best Practices
1. **Double Voting Prevention**: Tracking votes per user per proposal
2. **Registration Required**: Only registered voters can vote
3. **Owner Verification**: Only owner can create proposals
4. **Input Validation**: Checking all inputs before execution
---
## Next Steps
### Enhancements to Try
1. **Add Time-based Voting**
- Add start/end timestamps to proposals
- Implement automatic proposal ending
2. **Proposal Categories**
- Add category field to proposals
- Implement filtering by category
3. **Vote Delegation**
- Allow voters to delegate their votes
- Implement vote weight system
4. **Results Finalization**
- Add owner function to end proposals
- Execute actions based on results
5. **Vote History**
- Track user's voting history
- Display proposal archive
---
## Resources
### Documentation
- [Starknet Documentation](https://docs.starknet.io)
- [Cairo Book](https://book.cairo-lang.org)
- [Scaffold-Stark Docs](https://www.scaffoldstark.com)
- [Starknet Foundry](https://foundry-rs.github.io/starknet-foundry/)
### Tools
- [Starknet Explorer](https://sepolia.starkscan.co)
- [Argent X Wallet](https://www.argent.xyz)
- [Braavos Wallet](https://braavos.app)
### Community
- [Starknet Discord](https://discord.gg/starknet)
- [Scaffold-Stark GitHub](https://github.com/Scaffold-Stark/scaffold-stark-2)
---
## Conclusion
Congratulations! 🎉 You've built a complete decentralized voting application on Starknet. You've learned:
- ✅ Cairo smart contract development
- ✅ Writing comprehensive tests
- ✅ Deploying to Starknet testnet
- ✅ Building a React/Next.js frontend
- ✅ Integrating wallet connectivity
- ✅ Real-time blockchain data display
This foundation can be extended to build more complex dApps on Starknet. Keep building! 🚀
---
**Made with ❤️ using Scaffold-Stark 2**