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:
- Setting Up the Development Environment and LayerZero Configuration
- Interacting with Smart Contracts Using a Blockchain Explorer
- 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:
- Solidity Extension by Juan Blanco for syntax highlighting and code completion.
- Solidity Visual Auditor for code analysis and auditing.
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
tofalse
. - 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
ornoVotes
). - 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.
- Decodes the proposal ID and closes it by setting
- 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!