How to Build a Cross Chain Voting OApp: LayerZero Setup

If you’re new to the world of blockchain and want to build your first omnichain application using LayerZero for cross-chain communication, you’re in the right place! In this guide, we’ll walk you through the process of creating a cross-chain voting application step by step. Don’t worry if you don’t have any experience with smart contracts I’ll explain everything in simple terms.

Before We Begin

Our goal is to create an application that allows users to add proposals, vote on them, and close them, all synchronized across two different blockchain networks: Sepolia and Amoy. We’ll divide this guide into three parts:

  1. Setting Up the Development Environment and LayerZero Configuration
  2. Interacting with Smart Contracts Using a Blockchain Explorer
  3. Building the User Interface with React

Before we begin, I recommend familiarizing yourself with how LayerZero works by checking out my previous article: First Step with LayerZero: Basics for Developers.

Required Tools and Environment Setup

  • Node.js: A JavaScript runtime used for running development tools, compiling contracts, and managing dependencies.
  • pnpm: A fast, disk space-efficient package manager. I recommend using pnpm when generating your project, as it’s more reliable, and I’ve encountered fewer errors compared to other package managers like npm.
  • Visual Studio Code: A code editor that’s perfect for Solidity development.

Additionally, I recommend installing the following Visual Studio Code extensions:

LayerZero – Project Generation

Let’s get started! The first step in building our omnichain application is generating the project. We’ll use npx create-lz-oapp@latest to create the development environment. In the video below, I’ll show you how to quickly generate the project and set everything up for the next stages of development.

Setting Up the Environment and Defining Networks

In this step, we configure the networks our application will operate on, as well as the smart contracts to be deployed. We will define these settings in two configuration files: hardhat.config.ts and layerzero.config.ts. These files specify the supported networks Sepolia and Amoy and the cross-chain communication enabled by LayerZero.

In layerzero.config.ts, we outline the smart contracts for each network:

import { EndpointId } from '@layerzerolabs/lz-definitions'

import type { OAppOmniGraphHardhat, OmniPointHardhat } from '@layerzerolabs/toolbox-hardhat'

const sepoliaContract: OmniPointHardhat = {
    eid: EndpointId.SEPOLIA_V2_TESTNET,
    contractName: 'VotingOApp', // <--- SmartContract Name
}

const amoyContract: OmniPointHardhat = {
    eid: EndpointId.AMOY_V2_TESTNET,
    contractName: 'VotingOApp', // <--- SmartContract Name
}

const config: OAppOmniGraphHardhat = {
    contracts: [
        {
            contract: sepoliaContract,
        },
        {
            contract: amoyContract,
        },
    ],
    connections: [
        {
            from: sepoliaContract,
            to: amoyContract,
        },
        {
            from: amoyContract,
            to: sepoliaContract,
        },
    ],
}

export default config

In hardhat.config.ts, we configure the environment settings for these networks, including RPC URLs and authentication methods:

.
.
.
const config: HardhatUserConfig = {
    .
    .
    .
    networks: {
        'sepolia-testnet': {
            eid: EndpointId.SEPOLIA_V2_TESTNET,
            url: process.env.RPC_URL_SEPOLIA || 'https://rpc2.sepolia.org',
            accounts,
        },
        'amoy-testnet': {
            eid: EndpointId.AMOY_V2_TESTNET,
            url: process.env.RPC_URL_AMOY || 'https://polygon-amoy-bor-rpc.publicnode.com',
            accounts,
        },
    },
    .
    .
    .
}

export default config

With these configurations in place, your application will be ready to interact with both Sepolia and Amoy test networks for cross-chain communication using LayerZero.

The last step is to set up your private key, which you can retrieve from MetaMask, and save it in the .env file. To do this, I recommend copying the .env.example file and then renaming the copy by removing the .example extension. This will create your .env file, where you can securely store environment variables such as your private key.

PRIVATE_KEY=dde12XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Creating Our Smart Contract: VotingOApp

Let’s write our smart contract, which enables cross-chain voting functionality. I’ve divided the contract into two parts: the main contract (VotingOApp) and a separate library (MsgCodec). Separating the message encoding and decoding logic into a library is a good practice because it keeps the contract cleaner and more organized, ensuring that command handling logic is not all in one place.

MsgCodec Library

The MsgCodec library is responsible for encoding and decoding the messages sent between chains. This approach allows us to handle different types of commands (like adding proposals, closing them, and voting) in a clean and maintainable way.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.22;

library MsgCodec {
    uint8 internal constant PROPOSAL_ADDED_TYPE = 1;
    uint8 internal constant PROPOSAL_CLOSED_TYPE = 2;
    uint8 internal constant VOTED_TYPE = 3;

    function encodeProposalAdded(bytes32 _description) internal pure returns (bytes memory) {
        return abi.encode(PROPOSAL_ADDED_TYPE, _description);
    }

    function encodeProposalClosed(uint256 _proposalId) internal pure returns (bytes memory) {
        return abi.encode(PROPOSAL_CLOSED_TYPE, _proposalId);
    }

    function encodeVoted(address _voter, uint256 _proposalId, bool _vote) internal pure returns (bytes memory) {
        return abi.encode(VOTED_TYPE, _voter, _proposalId, _vote);
    }

    function decodeMessageType(bytes calldata _message) internal pure returns (uint8) {
        return abi.decode(_message, (uint8));
    }

    function decodeProposalAdded(bytes calldata _message) internal pure returns (bytes32) {
        return abi.decode(_message[32:], (bytes32));
    }

    function decodeProposalClosed(bytes calldata _message) internal pure returns (uint256) {
        return abi.decode(_message[32:], (uint256));
    }

    function decodeVoted(bytes calldata _message) internal pure returns (address, uint256, bool) {
        return abi.decode(_message[32:], (address, uint256, bool));
    }
}

VotingOApp Contract

The VotingOApp contract is the core of our cross-chain voting application. It uses LayerZero for cross-chain communication and handles various voting-related functionalities. Let’s break down the contract into smaller sections and describe each part in detail.

1. Contract Properties

The contract starts with several key properties that define the proposal structure, track the number of proposals, and manage voter participation.

struct Proposal {
    bytes32 description;
    uint256 yesVotes;
    uint256 noVotes;
    bool active;
}

uint256 public proposalCount;
mapping(uint256 => Proposal) public proposals;
mapping(uint256 => mapping(address => bool)) public hasVoted;
  • Proposal Struct: This structure holds the data for each proposal:
    • description: A short description of the proposal.
    • yesVotes: The number of “Yes” votes.
    • noVotes: The number of “No” votes.
    • active: A boolean to track whether the proposal is still active or has been closed.
  • proposalCount: This keeps track of the total number of proposals created.
  • proposals: A mapping that stores all proposals by their unique IDs (uint256).
  • hasVoted: A nested mapping that tracks whether an address (voter) has voted on a specific proposal.

2. AddProposal Function

This function allows the owner to add new proposals and communicate them across chains.

function addProposal(uint32 _eid, bytes32 description, bytes calldata _options) external payable onlyOwner {
    proposalCount++;
    proposals[proposalCount] = Proposal(description, 0, 0, true);

    bytes memory payload = MsgCodec.encodeProposalAdded(description);
    _lzSend(_eid, payload, _options, MessagingFee(msg.value, 0), payable(msg.sender));

    emit ProposalAdded(proposalCount, description);
}
  • Increments the proposalCount.
  • Creates a new proposal and adds it to the proposals mapping.
  • Encodes the proposal description using the MsgCodec.encodeProposalAdded function and sends it to another chain using LayerZero’s _lzSend function.
  • Emits the ProposalAdded event.

3. CloseProposal Function

This function allows the owner to close active proposals and communicate this across chains.

function closeProposal(uint32 _eid, uint256 proposalId, bytes calldata _options) external payable onlyOwner {
    require(proposals[proposalId].active, "Proposal does not exist or is already closed");

    proposals[proposalId].active = false;

    bytes memory payload = MsgCodec.encodeProposalClosed(proposalId);
    _lzSend(_eid, payload, _options, MessagingFee(msg.value, 0), payable(msg.sender));

    emit ProposalClosed(proposalId);
}
  • Checks if the proposal is still active; if not, it reverts.
  • Marks the proposal as closed by setting active to false.
  • Encodes the closure request using MsgCodec.encodeProposalClosed and sends it to another chain.
  • Emits the ProposalClosed event.

4. VoteProposal Function

This function allows users to vote on an active proposal and communicates the vote across chains.

function voteProposal(uint32 _eid, uint256 proposalId, bool vote, bytes calldata _options) external payable {
    require(proposals[proposalId].active, "Proposal does not exist or voting for this proposal has ended");
    require(!hasVoted[proposalId][msg.sender], "Already voted");

    hasVoted[proposalId][msg.sender] = true;

    if (vote) {
        proposals[proposalId].yesVotes++;
    } else {
        proposals[proposalId].noVotes++;
    }

    bytes memory payload = MsgCodec.encodeVoted(msg.sender, proposalId, vote);
    _lzSend(_eid, payload, _options, MessagingFee(msg.value, 0), payable(msg.sender));

    emit Voted(proposalId, msg.sender, vote);
}
  • Checks if the proposal is active and if the user has already voted.
  • Records the vote (incrementing either yesVotes or noVotes).
  • Marks the user as having voted.
  • Encodes the vote using MsgCodec.encodeVoted and sends it to another chain.
  • Emits the Voted event.

5. Message Handling Functions

These functions handle the specific actions when messages about adding a proposal, closing a proposal, or voting are received from another chain.

function handleProposalAdded(bytes calldata payload) internal {
    (bytes32 description) = MsgCodec.decodeProposalAdded(payload);
    proposalCount++;
    proposals[proposalCount] = Proposal(description, 0, 0, true);

    emit ProposalAdded(proposalCount, description);
}

function handleProposalClosed(bytes calldata payload) internal {
    uint256 proposalId = MsgCodec.decodeProposalClosed(payload);
    require(proposals[proposalId].active, "Proposal does not exist or is already closed");

    proposals[proposalId].active = false;

    emit ProposalClosed(proposalId);
}

function handleVote(bytes calldata payload) internal {
    (address sender, uint256 proposalId, bool vote) = MsgCodec.decodeVoted(payload);
    require(!hasVoted[proposalId][sender], "Already voted on another chain");
    require(proposals[proposalId].active, "Voting for this proposal has ended");

    hasVoted[proposalId][sender] = true;

    if (vote) {
        proposals[proposalId].yesVotes++;
    } else {
        proposals[proposalId].noVotes++;
    }

    emit Voted(proposalId, sender, vote);
}
  • handleProposalAdded:
    • Decodes the proposal description and adds the proposal to the contract.
    • Emits the ProposalAdded event.
  • handleProposalClosed:
    • Decodes the proposal ID and closes it by setting active to false.
    • Emits the ProposalClosed event.
  • handleVote:
    • Decodes the vote and records it.
    • Emits the Voted event.

6. GenerateOptions Function

This utility function is used to create options for LayerZero messaging.

function generateOptions(
    uint128 gas,
    uint128 value
) public pure returns (bytes memory) {
    bytes memory options = OptionsBuilder.newOptions();
    options = OptionsBuilder.addExecutorLzReceiveOption(options, gas, value);

    return options;
}
  • Uses OptionsBuilder to generate options for LayerZero message execution (gas).

The smart contract is available on GitHub at the following link: [GitHub Repository Link]. Feel free to explore the code, contribute, or raise any issues you find!

Deploying the Smart Contract

Now it’s time to deploy the smart contract! We can easily do this using the command npx hardhat lz:deploy

Watch the video to see the steps in action and how the contract is deployed across the specified networks.

Great job! You’ve successfully created and deployed your smart contract. In the next parts of this guide, I’ll show you how to interact with your smart contract using a blockchain explorer, and we’ll dive into building the frontend for your cross-chain voting application. Keep up the great work you’re well on your way to mastering omnichain development!


Web3 Tutorials
guest


0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments