Building a Gas-Sponsored Voting dApp on Conflux Network

Introduction

In the world of blockchain, gas fees can be a significant barrier to user adoption. What if users could interact with your decentralized application without paying gas fees? This is exactly what we’ve built - a Gas-Sponsored Voting dApp on the Conflux Network that allows users to vote on proposals completely free of charge, thanks to Conflux’s innovative gas sponsorship mechanism.

This project demonstrates how to build a fully functional voting system where the contract owner sponsors all transaction fees, making it accessible to users regardless of their CFX balance. Let’s dive into the architecture, implementation, and key features of this dApp.

Video Tutorial: https://youtu.be/f53TPiqhMyE?si=kIBl2LFNTMHNHxGI

GitHub Repo: https://github.com/Vikash-8090-Yadav/cfxGassponsor

Project Overview

The Conflux Voting dApp is a Next.js-based decentralized application that enables:

  • Creating voting proposals (owner-only)
  • Voting on proposals (anyone can vote)
  • Real-time vote tracking and visualization
  • Gas-free transactions for all users
  • Seamless wallet integration with Fluent Wallet

Architecture

The project follows a modern full-stack architecture:

┌─────────────────┐
│   Next.js App   │  (Frontend - React/TypeScript)
│   (Frontend)    │
└────────┬────────┘
         │
         │ js-conflux-sdk
         │
┌────────▼────────┐
│  Conflux RPC    │  (Blockchain Network)
│     Node        │
└────────┬────────┘
         │
┌────────▼────────┐
│  Smart Contract │  (Solidity - GasSponsoredVoting.sol)
│   on Conflux    │
└─────────────────┘

Smart Contract Implementation

The heart of our dApp is the GasSponsoredVoting smart contract. Let’s examine its key components:

Contract Structure

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

contract GasSponsoredVoting {
    struct Proposal {
        string title;
        string description;
        uint256 yesVotes;
        uint256 noVotes;
        bool isActive;
        mapping(address => bool) hasVoted;
    }

    address public owner;
    Proposal[] public proposals;

    event ProposalCreated(uint256 indexed proposalId, string title);
    event VoteCast(uint256 indexed proposalId, address indexed voter, bool vote);

Key Design Decisions:

  • Struct-based Proposals: Each proposal contains title, description, vote counts, active status, and a mapping to track who has voted
  • Owner Pattern: Only the contract owner can create proposals, ensuring controlled governance
  • Event Emissions: Events are emitted for proposal creation and voting, enabling efficient off-chain tracking

Creating Proposals

function createProposal(string memory _title, string memory _description) public onlyOwner {
    Proposal storage newProposal = proposals.push();
    newProposal.title = _title;
    newProposal.description = _description;
    newProposal.yesVotes = 0;
    newProposal.noVotes = 0;
    newProposal.isActive = true;

    emit ProposalCreated(proposals.length - 1, _title);
}

This function:

  • Can only be called by the contract owner (enforced by onlyOwner modifier)
  • Creates a new proposal with initial vote counts set to zero
  • Automatically activates the proposal
  • Emits an event for frontend synchronization

Voting Mechanism

function vote(uint256 _proposalId, bool _vote) public {
    require(_proposalId < proposals.length, "Invalid proposal ID");
    Proposal storage proposal = proposals[_proposalId];
    require(proposal.isActive, "Proposal is not active");
    require(!proposal.hasVoted[msg.sender], "Already voted");

    proposal.hasVoted[msg.sender] = true;

    if (_vote) {
        proposal.yesVotes++;
    } else {
        proposal.noVotes++;
    }

    emit VoteCast(_proposalId, msg.sender, _vote);
}

Security Features:

  • One Vote Per User: The hasVoted mapping prevents double voting
  • Active Proposal Check: Users can only vote on active proposals
  • Bounds Checking: Validates proposal ID exists before accessing

View Functions

The contract provides several view functions for reading data:

function getProposal(uint256 _proposalId) public view returns (
    string memory title,
    string memory description,
    uint256 yesVotes,
    uint256 noVotes,
    bool isActive
) {
    require(_proposalId < proposals.length, "Invalid proposal ID");
    Proposal storage proposal = proposals[_proposalId];
    return (
        proposal.title,
        proposal.description,
        proposal.yesVotes,
        proposal.noVotes,
        proposal.isActive
    );
}

function hasVoted(uint256 _proposalId, address _voter) public view returns (bool) {
    require(_proposalId < proposals.length, "Invalid proposal ID");
    return proposals[_proposalId].hasVoted[_voter];
}

function getProposalCount() public view returns (uint256) {
    return proposals.length;
}

These functions enable the frontend to:

  • Display all proposal details
  • Check if a user has already voted
  • Get the total number of proposals

Frontend Implementation

The frontend is built with Next.js 14, React, TypeScript, and Tailwind CSS. It uses the js-conflux-sdk to interact with the Conflux blockchain.

Wallet Integration

The dApp integrates with Fluent Wallet, Conflux’s native wallet:

const connectWallet = async () => {
  try {
    if (typeof window === 'undefined') {
      alert('Please open in a browser')
      return
    }

    // Check for Fluent Wallet
    if (window.conflux && window.conflux.enable) {
      try {
        const accounts = await window.conflux.enable()
        if (accounts && accounts.length > 0) {
          setAccount(accounts[0])
          // Update Conflux instance to use wallet provider
          if (cfx) {
            cfx.provider = window.conflux
          }
          alert('Wallet connected successfully!')
        }
      } catch (error: any) {
        console.error('Error enabling wallet:', error)
        alert(`Failed to connect wallet: ${error.message || 'User rejected the request'}`)
      }
    } else {
      alert('Please install Fluent Wallet extension!')
    }
  } catch (error: any) {
    console.error('Error connecting wallet:', error)
    alert(`Failed to connect wallet: ${error.message || 'Unknown error'}`)
  }
}

Contract Loading

The contract is loaded dynamically using the Conflux SDK:

const loadContract = async () => {
  if (!cfx || !contractAddress) {
    alert('Please enter contract address')
    return
  }
  
  let normalizedAddress = contractAddress.trim()
  
  try {
    setLoading(true)
    const contractInstance = cfx.Contract({
      abi: CONTRACT_ABI,
      address: normalizedAddress,
    })
    
    // Test if contract is accessible
    const count = await contractInstance.getProposalCount()
    console.log('Contract loaded successfully. Proposal count:', count.toString())
    setContract(contractInstance)
    await loadProposals(contractInstance)
    alert(`Contract loaded successfully! Found ${count.toString()} proposal(s).`)
  } catch (error: any) {
    console.error('Error loading contract:', error)
    alert(`Failed to load contract: ${error.message || 'Invalid contract address or network error'}`)
  } finally {
    setLoading(false)
  }
}

Creating Proposals

When the owner creates a proposal, the transaction is sent through the wallet:

const createProposal = async () => {
  if (!contract || !newProposal.title || !newProposal.description) {
    alert('Please fill in all fields')
    return
  }

  if (!account || !cfx) {
    alert('Please connect your wallet first')
    return
  }

  try {
    setLoading(true)
    // Use the wallet provider to send transaction
    const tx = contract.createProposal(newProposal.title, newProposal.description)
    const txHash = await tx.sendTransaction({ from: account })
    console.log('Transaction sent:', txHash)
    
    // Wait for transaction to be mined
    let receipt = null
    while (!receipt) {
      receipt = await cfx.getTransactionReceipt(txHash)
      if (!receipt) {
        await new Promise(resolve => setTimeout(resolve, 1000))
      }
    }
    
    console.log('Transaction receipt:', receipt)
    setNewProposal({ title: '', description: '' })
    await loadProposals()
    alert('Proposal created successfully!')
  } catch (error: any) {
    console.error('Error creating proposal:', error)
    alert(error.message || 'Failed to create proposal')
  } finally {
    setLoading(false)
  }
}

Voting Functionality

Voting is straightforward - users click Yes or No, and the transaction is processed:

const vote = async (proposalId: number, voteValue: boolean) => {
  if (!contract || !account || !cfx) {
    alert('Please connect your wallet first')
    return
  }

  try {
    setLoading(true)
    const tx = contract.vote(proposalId, voteValue)
    const txHash = await tx.sendTransaction({ from: account })
    console.log('Transaction sent:', txHash)
    
    // Wait for transaction to be mined
    let receipt = null
    while (!receipt) {
      receipt = await cfx.getTransactionReceipt(txHash)
      if (!receipt) {
        await new Promise(resolve => setTimeout(resolve, 1000))
      }
    }
    
    console.log('Transaction receipt:', receipt)
    await loadProposals()
    alert(`Vote ${voteValue ? 'Yes' : 'No'} recorded!`)
  } catch (error: any) {
    console.error('Error voting:', error)
    alert(error.message || 'Failed to vote')
  } finally {
    setLoading(false)
  }
}

Loading Proposals

The frontend loads all proposals and checks voting status:

const loadProposals = async (contractInstance?: any) => {
  const contractToUse = contractInstance || contract
  if (!contractToUse) return

  try {
    setLoading(true)
    const count = await contractToUse.getProposalCount()
    const proposalCount = parseInt(count.toString())
    
    const proposalsData: Proposal[] = []
    for (let i = 0; i < proposalCount; i++) {
      const proposal = await contractToUse.getProposal(i)
      let voted = false
      if (account) {
        voted = await contractToUse.hasVoted(i, account)
      }
      proposalsData.push({
        id: i,
        title: proposal[0],
        description: proposal[1],
        yesVotes: parseInt(proposal[2].toString()),
        noVotes: parseInt(proposal[3].toString()),
        isActive: proposal[4],
        hasVoted: voted,
      })
    }
    setProposals(proposalsData)
  } catch (error) {
    console.error('Error loading proposals:', error)
  } finally {
    setLoading(false)
  }
}

User Interface

The UI is built with Tailwind CSS and features:

  1. Wallet Connection Section: Connect Fluent Wallet with one click
  2. Contract Loading: Enter contract address and load proposals
  3. Proposal Creation: Owner can create new proposals with title and description
  4. Voting Interface:
    • Visual vote counts (Yes/No)
    • Progress bars showing vote distribution
    • Disabled voting buttons if user already voted
    • Real-time updates after voting

Key UI Components

<div className="mb-4">
  <div className="flex justify-between mb-2">
    <span className="text-green-600 font-semibold">Yes: {proposal.yesVotes}</span>
    <span className="text-red-600 font-semibold">No: {proposal.noVotes}</span>
  </div>
  <div className="w-full bg-gray-200 rounded-full h-2">
    <div
      className="bg-green-500 h-2 rounded-full"
      style={{
        width: proposal.yesVotes + proposal.noVotes > 0
          ? `${(proposal.yesVotes / (proposal.yesVotes + proposal.noVotes)) * 100}%`
          : '0%'
      }}
    />
  </div>
</div>

This creates a visual progress bar showing the percentage of “Yes” votes.

Deployment Process

1. Contract Compilation

npm run compile

This uses Hardhat to compile the Solidity contract and generate artifacts.

2. Deployment Script

The deployment script (scripts/deploy-conflux.js) handles contract deployment:

const { Conflux } = require('js-conflux-sdk');
const fs = require('fs');
const path = require('path');
require('dotenv').config();

async function main() {
  const conflux = new Conflux({
    url: process.env.NEXT_PUBLIC_CONFLUX_RPC_URL || 'https://test.confluxrpc.com',
    networkId: parseInt(process.env.NEXT_PUBLIC_NETWORK_ID || '1'),
  });

  const account = conflux.wallet.addPrivateKey(process.env.PRIVATE_KEY);
  console.log('Deploying from account:', account.address);

  const artifactPath = path.join(__dirname, '../artifacts/contracts/Voting.sol/GasSponsoredVoting.json');
  const artifact = JSON.parse(fs.readFileSync(artifactPath, 'utf8'));
  const bytecode = artifact.bytecode;
  const abi = artifact.abi;

  const contract = conflux.Contract({ abi, bytecode });

  console.log('Deploying contract...');
  const receipt = await contract.constructor().sendTransaction({
    from: account.address,
  }).executed();

  const contractAddress = receipt.contractCreated;
  console.log('\n✅ GasSponsoredVoting deployed successfully!');
  console.log('Contract Address:', contractAddress);
  console.log('\n📝 Add this to your .env file:');
  console.log(`NEXT_PUBLIC_CONTRACT_ADDRESS=${contractAddress}`);
}

3. Deploy to Testnet

npm run deploy:testnet

This deploys the contract and outputs the contract address for use in the frontend.

Gas Sponsorship Setup

The magic of this dApp is that users don’t pay gas fees. This is achieved through Conflux’s SponsorWhitelistControl mechanism.

How Gas Sponsorship Works

  1. Sponsor Storage: The contract owner deposits CFX to sponsor storage costs
  2. Sponsor Gas: The owner sets a gas sponsor balance
  3. Whitelist: The owner adds the zero address (0x0000000000000000000000000000000000000000) to the whitelist, allowing anyone to use the contract without paying gas

Setting Up Sponsorship

  1. Navigate to ConfluxScan Contract Tool
  2. Connect your wallet
  3. Sponsor Storage: Call setSponsorForCollateral with your contract address and deposit CFX (e.g., 50 CFX)
  4. Sponsor Gas: Call setSponsorForGas with your contract address and set the upper bound (e.g., 1000000000000000 Drip = 10^15 Drip)
  5. Add Whitelist: Call addPrivilegeByAdmin with:
    • Contract address
    • Array: ["0x0000000000000000000000000000000000000000"]

After these steps, all users can interact with your contract without paying gas fees!

Key Features

1. Gas-Free Transactions

Users can vote without holding CFX, removing a major barrier to adoption.

2. One Vote Per User

The smart contract enforces that each address can only vote once per proposal, ensuring fair voting.

3. Real-Time Updates

The frontend automatically refreshes after transactions, showing updated vote counts immediately.

4. Owner Controls

Only the contract owner can create proposals, providing governance control.

5. Proposal Management

Proposals can be closed by the owner, preventing further voting on completed proposals.

6. Visual Feedback

Progress bars and color-coded vote counts make it easy to see voting results at a glance.

Technology Stack

  • Frontend: Next.js 14, React 18, TypeScript
  • Styling: Tailwind CSS
  • Blockchain: Conflux Network
  • Smart Contracts: Solidity 0.8.20
  • Development: Hardhat
  • SDK: js-conflux-sdk
  • Wallet: Fluent Wallet

Project Structure

cfxGassponsor/
├── app/
│   ├── globals.css          # Global styles
│   ├── layout.tsx           # Root layout
│   └── page.tsx             # Main voting interface
├── contracts/
│   └── Voting.sol           # Smart contract
├── scripts/
│   └── deploy-conflux.js   # Deployment script
├── hardhat.config.js        # Hardhat configuration
├── next.config.js           # Next.js configuration
├── tailwind.config.js       # Tailwind configuration
└── package.json             # Dependencies

Environment Variables

Create a .env file with:

PRIVATE_KEY=your_private_key_here
NEXT_PUBLIC_CONTRACT_ADDRESS=cfxtest:...
NEXT_PUBLIC_CONFLUX_RPC_URL=https://test.confluxrpc.com
NEXT_PUBLIC_NETWORK_ID=1

Conclusion

This Gas-Sponsored Voting dApp demonstrates the power of Conflux Network’s gas sponsorship feature. By removing gas fees for end users, we’ve created a more accessible and user-friendly decentralized application. The combination of a secure smart contract, modern frontend framework, and seamless wallet integration creates a production-ready voting system.

Resources


Happy Building! :rocket: