How to Build a Cross Chain Voting OApp: User Interface with React
In the previous part, we verified the smart contracts on the Sepolia and Amoy test networks, established cross-chain communication using the SetPeer function, and successfully implemented the functionality for adding, voting, and closing proposals across both networks. Now, in this final part, we will focus on building the user interface for the cross-chain voting application using React, enabling users to interact with the smart contracts directly through the front-end.
Generating the React Project
Let’s start by generating a new React project. First, use the command npx create-react-app app-react
, which initializes a React project with all the necessary dependencies. After that, we install ethers by running npm install ethers
. This library is essential for interacting with Ethereum-based blockchain networks directly from the front-end, allowing us to connect to smart contracts, send transactions, and fetch data. In the video below, I demonstrate these steps in action.
Project File Structure
Below is the structure of the project files that you should prepare for building the Voting OApp. Each file serves a specific purpose in the functionality of the app:
src/
artifacts/
VotingOApp.json
components/
CreateProposalForm.js
NetworkStatusBar.js
Proposal.js
ProposalList.js
context/
GlobalStateContext.js
contracts/
networkConfig.js
networkHelpers.js
readContract.js
writeContract.js
The VotingOApp.json file is generated during the deployment of the smart contract for the application using LayerZero. It can be found at the following path in the project where the smart contract was written: /artifacts/contracts/VotingOApp.sol/VotingOApp.json
.
To enable communication between the React application and the deployed smart contract, you need to copy this file to the src/artifacts/
directory of your React app. This JSON file contains crucial ABI (Application Binary Interface) details, allowing the frontend to interact with the smart contract functions seamlessly.
Set up Contract Configuration and Helpers
We will first configure the blockchain networks and helpers that will allow our app to interact with the smart contract.
1. networkConfig.js
In this file, we define the blockchain networks our app can connect to. This includes the chain IDs, RPC URLs, and contract addresses that will allow us to interact with the correct network. Information about various blockchain networks can be found on the website chainlist.org.
export const networkConfig = {
11155111: {
chainId: '0xaa36a7',
chainName: 'Sepolia',
rpcUrls: ['https://rpc2.sepolia.org'],
contractAddress: '0xXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', // contract address
send_eid: 40267,
},
80002: {
chainId: '0x13882',
chainName: 'Amoy',
rpcUrls: ['https://polygon-amoy-bor-rpc.publicnode.com'],
contractAddress: '0xXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', // contract address
send_eid: 40161,
},
};
export const getNetworkConfig = (networkId) => networkConfig[networkId];
Explanation:
- networkConfig – defines configurations for two networks: Sepolia and Amoy. This allows users to easily switch between them.
- getNetworkConfig – retrieves the configuration for the selected network using the network ID.
2. networkHelpers.js
This file contains functions to initialize the blockchain provider (e.g., MetaMask) and to switch between networks.
import { ethers } from 'ethers';
import { getNetworkConfig } from './networkConfig';
import { getContractOwner } from './readContract';
export const initializeProvider = async (setProvider, setSigner, setAccount, setNetwork, setContractAddress, setSendEid, setOwner) => {
if (window.ethereum) {
try {
await window.ethereum.request({ method: 'eth_requestAccounts' });
const newProvider = new ethers.BrowserProvider(window.ethereum);
setProvider(newProvider);
const newSigner = await newProvider.getSigner();
setSigner(newSigner);
const address = await newSigner.getAddress();
setAccount(address);
const network = await newProvider.getNetwork();
setNetwork(network.name);
const networkConfig = getNetworkConfig(network.chainId);
if (networkConfig) {
setContractAddress(networkConfig.contractAddress);
setSendEid(networkConfig.send_eid);
}
const owner = await getContractOwner(newProvider, networkConfig.contractAddress);
setOwner(owner);
} catch (error) {
console.error(error);
}
}
};
export const handleNetworkSwitch = (networkId, provider, setContractAddress, setSendEid) => {
const networkConfig = getNetworkConfig(networkId);
if (networkConfig) {
setContractAddress(networkConfig.contractAddress);
setSendEid(networkConfig.send_eid);
switchNetwork(networkId, provider);
}
};
export const switchNetwork = async (networkId, provider) => {
const networkConfig = getNetworkConfig(networkId);
if (provider && networkConfig) {
try {
await provider.send('wallet_switchEthereumChain', [
{ chainId: networkConfig.chainId },
]);
window.location.reload();
} catch (error) {
if (error.code === 4902) {
try {
await provider.send('wallet_addEthereumChain', networkConfig);
} catch (error) {
console.error('Failed to add new network:', error);
}
} else {
console.error('Error switching networks:', error);
}
}
}
};
Explanation:
- initializeProvider – this function requests account access from the user and sets the provider and signer for blockchain interactions.
- handleNetworkSwitch – helps users switch between blockchain networks.
- switchNetwork – internally switches networks in MetaMask if needed.
3. readContract.js
This file handles reading data from the smart contract. We’ll need functions to fetch proposals and retrieve the contract owner.
import { ethers } from 'ethers';
import VotingOApp from '../artifacts/VotingOApp.json';
export const getContractOwner = async (provider, contractAddress) => {
try {
const contract = new ethers.Contract(contractAddress, VotingOApp.abi, provider);
const owner = await contract.owner();
return owner;
} catch (error) {
console.error(error);
}
}
export const quote = async (contract, eid, messageType, data, options) => {
try {
const quote = await contract.quote(eid, messageType, data, options);
const ether = ethers.formatEther(quote[0]);
return ether;
} catch (error) {
console.error(error);
}
}
export const fetchProposals = async (provider, contractAddress, setProposals, setLoading) => {
setLoading(true);
try {
const contract = new ethers.Contract(contractAddress, VotingOApp.abi, provider);
const proposalCount = await contract.proposalCount();
const fetchedProposals = [];
for (let i = 1; i <= proposalCount; i++) {
const proposal = await contract.proposals(i);
fetchedProposals.push({
id: i,
description: ethers.decodeBytes32String(proposal.description),
yesVotes: proposal.yesVotes.toString(),
noVotes: proposal.noVotes.toString(),
active: proposal.active,
});
}
setProposals(fetchedProposals);
setLoading(false);
} catch (error) {
console.error(error);
}
};
Explanation:
- getContractOwner – retrieves the owner of the contract from the blockchain.
- fetchProposals – fetches the list of proposals from the blockchain to display in the app.
4. writeContract.js
This file contains functions for writing to the blockchain, such as creating proposals and voting.
import { ethers } from 'ethers';
import VotingOApp from '../artifacts/VotingOApp.json';
import { quote } from './readContract';
export const createProposal = async (contractAddress, signer, eid, description) => {
const contract = new ethers.Contract(contractAddress, VotingOApp.abi, signer);
const data = ethers.encodeBytes32String(description);
const options = await contract.generateOptions(200000, 0);
const payableAmount = await quote(contract, eid, 1, data, options);
console.log(payableAmount);
if (!payableAmount) throw new Error('Invalid payable amount.');
const tx = await contract.addProposal(
eid,
ethers.encodeBytes32String(description),
options,
{ value: ethers.parseEther(payableAmount) }
);
await tx.wait();
window.location.reload();
};
export const voteProposal = async (contractAddress, signer, eid, proposalId, vote) => {
const contract = new ethers.Contract(contractAddress, VotingOApp.abi, signer);
const coder = ethers.AbiCoder.defaultAbiCoder();
const data = coder.encode(['uint', 'bool'], [proposalId, vote]);
const options = await contract.generateOptions(200000, 0);
const payableAmount = await quote(contract, eid, 2, data, options);
if (!payableAmount) throw new Error('Invalid payable amount.');
const tx = await contract.voteProposal(
eid,
proposalId,
vote,
options,
{ value: ethers.parseEther(payableAmount) }
);
await tx.wait();
window.location.reload();
};
export const closeProposal = async (contractAddress, signer, eid, proposalId) => {
const contract = new ethers.Contract(contractAddress, VotingOApp.abi, signer);
const coder = ethers.AbiCoder.defaultAbiCoder();
const data = coder.encode(['uint'], [proposalId]);
const options = await contract.generateOptions(200000, 0);
const payableAmount = await quote(contract, eid, 3, data, options);
if (!payableAmount) throw new Error('Invalid payable amount.');
const tx = await contract.closeProposal(
eid,
proposalId,
options,
{ value: ethers.parseEther(payableAmount) }
);
await tx.wait();
window.location.reload();
};
Explanation:
- createProposal – allows the contract owner to create new proposals on the blockchain.
- voteProposal – allows users to vote on proposals.
- closeProposal – allows the contract owner to close proposals.
Global State Context in React
In this section, we will prepare a global state context in our application. We utilize the React Context API to manage the application state that will be accessible across different components, significantly simplifying the interaction with blockchain contracts.
import React, { createContext, useContext, useState, useEffect } from 'react';
import { initializeProvider } from '../contracts/networkHelpers';
const GlobalStateContext = createContext();
export const GlobalStateProvider = ({ children }) => {
const [provider, setProvider] = useState(null);
const [signer, setSigner] = useState(null);
const [account, setAccount] = useState('');
const [network, setNetwork] = useState('');
const [contractAddress, setContractAddress] = useState('');
const [sendEid, setSendEid] = useState(null);
const [owner, setOwner] = useState(null);
useEffect(() => {
initializeProvider(setProvider, setSigner, setAccount, setNetwork, setContractAddress, setSendEid, setOwner);
}, []);
return (
<GlobalStateContext.Provider value={{
provider, setProvider,
signer, setSigner,
account, setAccount,
network, setNetwork,
contractAddress, setContractAddress,
sendEid, setSendEid,
owner, setOwner
}}>
{children}
</GlobalStateContext.Provider>
);
};
export const useGlobalState = () => useContext(GlobalStateContext);
Explanation:
- provider – holds the Web3 provider instance (e.g., MetaMask).
- signer – represents the wallet used to sign transactions.
- account – stores the connected user’s wallet address.
- network – current blockchain network the user is connected.
- contractAddress – holds the address of the deployed smart contract.
- sendEid – stores the network identifier used to synchronization.
- owner – stores the address of the smart contract’s owner to manage permissions.
Build the UI Components
In this section, we will create the components responsible for user interaction. We need to install Bootstrap to style our components easily. Run the following command: npm install react-bootstrap bootstrap
.
1. NetworkStatusBar.js
This component manages the display of the current network status and allows the user to switch between networks.
import Container from 'react-bootstrap/Container';
import Navbar from 'react-bootstrap/Navbar';
import Dropdown from 'react-bootstrap/Dropdown';
import { handleNetworkSwitch } from '../contracts/networkHelpers';
import { useGlobalState } from '../context/GlobalStateContext';
const formatAccount = (address) => {
if (!address) return '';
return `${address.slice(0, 5)}...${address.slice(-5)}`;
};
const NetworkStatusBar = () => {
const { account, provider, network, setContractAddress, setSendEid } = useGlobalState();
return (
<Navbar className="bg-body-tertiary">
<Container>
<Navbar.Brand href="#home">Voting OApp</Navbar.Brand>
<Navbar.Toggle />
<Navbar.Collapse className="justify-content-end">
<Dropdown onSelect={(networkId) => handleNetworkSwitch(networkId, provider, setContractAddress, setSendEid)}>
{network ? network : ''}
<Dropdown.Toggle variant="success" id="dropdown-basic">
{account ? formatAccount(account) : 'Connect'}
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item eventKey="11155111">Sepolia</Dropdown.Item>
<Dropdown.Item eventKey="80002">Amoy</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</Navbar.Collapse>
</Container>
</Navbar>
);
};
export default NetworkStatusBar;
Explanation:
- account – refers to the user’s wallet address. The
formatAccount
function formats the address so that only the first and last 5 characters are visible (e.g., 0x123…45678). - Dropdown.Toggle – displays either the user’s wallet address or a “Connect” button if not connected.
2. Proposal.js
This component allows the user to vote on existing proposals in the voting system.
import React from 'react';
import Button from 'react-bootstrap/Button';
import Card from 'react-bootstrap/Card';
import { voteProposal, closeProposal } from '../contracts/writeContract';
import { useGlobalState } from '../context/GlobalStateContext';
const Proposal = ({ proposal }) => {
const { contractAddress, signer, owner, sendEid } = useGlobalState();
return (
<Card className="mt-3">
<Card.Header as="h5" className='text-center'>{proposal.description}</Card.Header>
<Card.Body className='d-grid gap-2'>
<Card.Text>Yes Votes: {proposal.yesVotes}</Card.Text>
<Card.Text>No Votes: {proposal.noVotes}</Card.Text>
<Button
variant="success"
disabled={!proposal.active}
onClick={() => voteProposal(contractAddress, signer, sendEid, proposal.id, true)}>
Yes
</Button>
<Button
variant="danger"
disabled={!proposal.active}
onClick={() => voteProposal(contractAddress, signer, sendEid, proposal.id, false)}>
No
</Button>
{(signer !== null && (signer.address === owner)) && (
<Button
variant="primary"
disabled={!proposal.active}
onClick={() => closeProposal(contractAddress, signer, sendEid, proposal.id)}>
Close
</Button>
)}
</Card.Body>
</Card>
);
};
export default Proposal;
Explanation:
- signer – refers to the user who has connected their wallet and has permission to sign transactions (e.g., voting).
- owner – the owner is the account that has control over the proposal system (e.g., can close proposals).
3. ProposalList.js
This component displays a list of all proposals that the user can view and vote on.
import React, { useState, useEffect } from 'react';
import Container from 'react-bootstrap/Container';
import Proposal from './Proposal';
import { useGlobalState } from '../context/GlobalStateContext';
import { Col, Row } from 'react-bootstrap';
import { fetchProposals } from '../contracts/readContract';
const ProposalList = () => {
const { provider, contractAddress } = useGlobalState();
const [proposals, setProposals] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadProposals = async () => {
await fetchProposals(provider, contractAddress, setProposals, setLoading);
};
if (provider && contractAddress) {
loadProposals();
}
}, [provider, contractAddress]);
if (loading) return <p>Loading proposals...</p>;
return (
<Container>
<h2 className='mt-3 text-center'>Proposal List</h2>
<Row>
{proposals.map(proposal => (
<Col key={proposal.id} md={6}>
<Proposal key={proposal.id} proposal={proposal} />
</Col>
))}
</Row>
</Container>
);
};
export default ProposalList;
Explanation:
- useEffect – a React hook that runs the
loadProposals
function when the component mounts (when the page loads) and wheneverprovider
orcontractAddress
changes. - fetchProposals – this function fetches the list of proposals from the smart contract.
4. CreateProposalForm.js
This component allows the user (who must be the contract owner) to create new proposals in the voting system.
import React, { useState } from 'react';
import { createProposal } from '../contracts/writeContract';
import { Button, Container, Form } from 'react-bootstrap';
import { useGlobalState } from '../context/GlobalStateContext';
const CreateProposalForm = () => {
const { contractAddress, signer, owner, sendEid } = useGlobalState();
const [description, setDescription] = useState('');
if (signer === null || owner !== signer.address) return '';
const handleSubmit = (e) => {
e.preventDefault();
if (description.trim()) {
createProposal(contractAddress, signer, sendEid, description);
}
};
return (
<Container>
<h2 className='mt-4 text-center'>Add Proposal</h2>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3" controlId="formBasicEmail">
<Form.Label>Propsal description</Form.Label>
<Form.Control
type="text"
placeholder="Enter propsal description"
onChange={(desc) => setDescription(desc.target.value)}
/>
</Form.Group>
<Button variant="primary" type="submit">Create Proposal</Button>
</Form>
</Container>
);
};
export default CreateProposalForm;
Explanation:
- signer – the wallet connected to the OApp. The signer is required to create a proposal.
- owner – only the owner of the contract can create proposals.
- createProposal – this function sends a new proposal to the smart contract when the form is submitted.
Let’s Create App.js
At this stage, we have built the frontend components that handle user interactions. Now we will connect everything together by creating the main application component.
import React from 'react';
import Container from 'react-bootstrap/Container';
import 'bootstrap/dist/css/bootstrap.min.css';
import NetworkStatusBar from './components/NetworkStatusBar';
import ProposalList from './components/ProposalList';
import CreateProposalForm from './components/CreateProposalForm';
import { GlobalStateProvider } from './context/GlobalStateContext';
function App() {
return (
<GlobalStateProvider>
<Container>
<NetworkStatusBar />
<ProposalList />
<CreateProposalForm />
</Container>
</GlobalStateProvider>
);
}
export default App;
Running the Application
You can start the application by running: npm start
. This command will start the development server. Usually, it will open automatically in your web browser at http://localhost:3000
.
The project is available on GitHub at the following link: [GitHub Repository Link]. Feel free to explore the code, contribute, or raise any issues you find!
In this series, we developed a cross-chain voting application using LayerZero and React, enabling user interaction with the blockchain. We verified our smart contracts on the Sepolia and Amoy test networks and implemented cross-chain communication for managing proposals. This project serves as an excellent starting point for anyone looking to dive into the world of Web3 development.