# 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**