Cross-Chain Governance with OpenZeppelin Governor and Axelar
Multichain applications are becoming the new norm, but managing governance across these networks remains a significant challenge. For decentralized applications (dApps) with deployments on multiple blockchains, ensuring an easy governance processes while maintaining security, liveness, and resistance to censorship is a complex task. This challenge is particularly evident in projects like Uniswap, which has extensively assessed cross-chain platforms to secure its governance across diverse networks. Through this, they discovered Axelar's robust infrastructure as the key solution.
To address this, developers have long relied on OpenZeppelin Governor, a widely trusted framework for on-chain governance. However, when paired with cross-chain functionality, the complexity increases. This is where Axelar's Interchain Governance Orchestrator steps in โ a framework for governance actions across multiple chains.
To quickly get started and test the implementation directly, you can find the full code on GitHub here.
In this tutorial, you will learn how to:
Build and deploy the following EVM smart contracts
GovernanceToken
InterchainProposalSender
ThresholdContract
CrossChainGovernor
Send a governance proposal from a source chain to update a
Threshold
contract on a destination.Write test cases to test all the contract's functionality
Prerequisites
A basic understanding of Solidity and JavaScript
Familiarity with the OpenZeppelin Governor contract
What is the OpenZeppelin Governor Contract?
The OpenZeppelin Governor contract is an on-chain governance system designed for decentralized protocols, compatible with Compound's GovernorAlpha and GovernorBravo. It allows governance through token-based voting, where users delegate tokens to gain voting power. Proposals represent executable code changes; only users with sufficient voting power can submit them. Each protocol customizes voting periods and quorum thresholds.
The system includes optional features like proposal delays and timelocks, providing flexibility in governance processes. Unlike Compound's Governor, the OpenZeppelin Governor can function with or without a timelock.
Interchain Governance Orchestrator
Interchain Governance Orchestrator is a system that simplifies cross-chain governance for Web3 applications. It consists of two primary contracts:
InterchainProposalSender (on source chain): Encodes and sends proposals to other chains.
InterchainProposalExecutor (on destination chain): Receives and executes proposals on target contracts.
This system allows developers to manage governance across multiple chains more efficiently, reducing complexity and risk.
Getting started with Axelar General Message Passing
Axelar General Message Passing (GMP) empowers developers by enabling them to call any function on interconnected chains seamlessly.
With GMP, developers gain the ability to:
Call a contract on chain A and interact with a contract on chain B.
All in one gas payment for cross-chain calls
Execute cross-chain transactions by calling a contract on chain A and sending tokens to chain B.
Project setup and installation
Create and initialize a project
Open up your terminal and navigate to any directory of your choice. Run the following commands to create and initiate a project:
mkdir interchain-governance-with-openzeppelin-governor-example && cd interchain-governance-with-openzeppelin-governor-example
npm init -y
Install Hardhat and the AxelarJS SDK
Install Hardhat, OpenZeppelin, hardhat toolbox, and the axelar-gmp-sdk-solidity with the following commands:
npm install --save-dev hardhat@2.14.0 @openzeppelin/contracts@4.9.0 @axelar-network/axelar-gmp-sdk-solidity@5.10.0 @nomicfoundation/hardhat-toolbox@2.0.2
Initialize a Hardhat project
npx hardhat init
Choose "Create a JavaScript project" when prompted.
888 888 888 888 888
888 888 888 888 888
888 888 888 888 888
8888888888 8888b. 888d888 .d88888 88888b. 8888b. 888888
888 888 "88b 888P" d88" 888 888 "88b "88b 888
888 888 .d888888 888 888 888 888 888 .d888888 888
888 888 888 888 888 Y88b 888 888 888 888 888 Y88b.
888 888 "Y888888 888 "Y88888 888 888 "Y888888 "Y888
๐ท Welcome to Hardhat v2.14.0 ๐ทโ
โ What do you want to do? ยท Create a JavaScript project
โ Hardhat project root: ยท /interchain-governance-with-openzeppelin-governor-example
โ Do you want to add a .gitignore? (Y/n) ยท y
โจ Project created โจ
See the README.md file for some example tasks you can run
Give Hardhat a star on Github if you're enjoying it! โญ๏ธโจ
<https://github.com/NomicFoundation/hardhat>
Build the smart contracts
In this section, you will build the InterchainCalls
library to help define structures for cross-chain calls and the contracts GovernanceToken
, InterchainProposalSender
, ThresholdContract
, and CrossChainGovernor
for multichain governance.
Build the InterchainCalls
library
Create a file InterchainCalls.sol
in the contracts
directory and add the following code snippet to create a library to be reused while implementing the interchain proposal contract and cross-chain governance contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/// @title InterchainCalls Library
/// @notice This library defines structures for cross-chain calls using Axelar Network
library InterchainCalls {
/// @dev Represents a complete interchain call
struct InterchainCall {
string destinationChain; // The name of the destination chain
string destinationContract; // The address of the contract on the destination chain
uint256 gas; // The amount of gas to be paid for the call
Call[] calls; // An array of calls to be executed on the destination chain
}
/// @dev Represents a single call to be executed on the destination chain
struct Call {
address target; // The address of the contract to call on the destination chain
uint256 value; // The amount of native tokens to send with the call
bytes callData; // The encoded function call data
}
}
In the code snippet above, you:
Created an
InterchainCalls
library with two structs:InterchainCall
andCall
InterchainCall
defines the overall cross-chain message structure, including destination and multiple callsCall
represents individual function calls on the target chain
In the next section, you will build the InterchainProposalSender
, where you will be utilizing the InterchainCalls
library.
Build the InterchainProposalSender
contract
Create the InterchainProposalSender
contract, a component of your cross-chain governance system. This contract will send proposals from one blockchain to another using the Axelar General Message Passing feature.
The main objectives of this contract are:
To interface with Axelar's Gateway and Gas Service for cross-chain communication.
To provide a method for sending proposals to other chains, including the necessary gas payments.
To encode the proposal data in a format that can be understood and executed on the destination chain.
Now, let's proceed to create the InterchainProposalSender.sol
file in the contracts
directory and add the following code snippet to implement the sendProposal
function.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IAxelarGateway} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGateway.sol";
import {IAxelarGasService} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGasService.sol";
import "./InterchainCalls.sol";
/// @title InterchainProposalSender
/// @notice This contract sends cross-chain proposals using the Axelar network
contract InterchainProposalSender {
IAxelarGateway public immutable gateway;
IAxelarGasService public immutable gasService;
error InvalidAddress();
error InvalidFee();
/// @notice Initializes the contract with Axelar Gateway and Gas Service addresses
/// @param _gateway Address of the Axelar Gateway
/// @param _gasService Address of the Axelar Gas Service
constructor(address _gateway, address _gasService) {
if (_gateway == address(0) || _gasService == address(0))
revert InvalidAddress();
gateway = IAxelarGateway(_gateway);
gasService = IAxelarGasService(_gasService);
}
/// @notice Sends a proposal to another chain
/// @param destinationChain The name of the destination chain
/// @param destinationContract The address of the contract on the destination chain
/// @param calls An array of calls to be executed on the destination chain
function sendProposal(
string memory destinationChain,
string memory destinationContract,
InterchainCalls.Call[] calldata calls
) external payable {
require(msg.value > 0, "Gas payment is required");
bytes memory payload = abi.encode(abi.encodePacked(msg.sender), calls);
gasService.payNativeGasForContractCall{value: msg.value}(
address(this),
destinationChain,
destinationContract,
payload,
msg.sender
);
gateway.callContract(destinationChain, destinationContract, payload);
}
}
In the code snippet above, you:
Imported the necessary Axelar interfaces:
IAxelarGateway
for cross-chain messagingIAxelarGasService
for handling gas paymentsOur custom
InterchainCalls
library for structuring call data
Defined the
InterchainProposalSender
contract to send proposals across different blockchain networks via the Axelar networkInitialized the contract with immutable references to the Axelar Gateway and Gas Service in the constructor, ensuring they are valid addresses:
- Checked for invalid addresses and reverted with
InvalidAddress()
if necessary
- Checked for invalid addresses and reverted with
Implemented the
sendProposal
function, which:Requires a non-zero
msg.value
to ensure gas fees are provided (require(msg.value > 0, "Gas payment is required");
)Encodes the sender's address and the array of calls into a payload using
abi.encode
Pays the gas fees for the cross-chain transaction using the Axelar Gas Service
Sends the proposal to the specified contract on the destination chain via the Axelar Gateway
Build the GovernanceToken
contract
To vote on a proposal requires having voting power, and allowing a user to have this voting power, you need to create the GovernanceToken
contract. This token will:
Provide voting power to token holders.
Implement the ERC20 standard with additional voting and permit capabilities.
Allow the owner to mint new tokens.
This contract combines features from OpenZeppelin's ERC20, ERC20Permit, ERC20Votes, and Ownable contracts to create a governance token suitable for your cross-chain governance system.
Let's implement this contract by creating a new file in our contracts
directory and adding the following code snippet.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/// @title GovernanceToken
/// @notice ERC20 token with voting and permit capabilities for governance
contract GovernanceToken is ERC20, ERC20Permit, ERC20Votes, Ownable {
uint256 public constant MAX_SUPPLY = 1000000 * 10 ** 18; // 1 million tokens
/// @notice Initializes the token and mints the entire supply to the initial owner
/// @param initialOwner Address of the initial token owner
constructor(
address initialOwner
)
ERC20("GovernanceToken", "MGT")
ERC20Permit("GovernanceToken")
Ownable(initialOwner)
{
_mint(initialOwner, MAX_SUPPLY);
}
/// @notice Mints new tokens (only callable by owner)
/// @param to Address to receive the minted tokens
/// @param amount Amount of tokens to mint
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
// The following functions are overrides required by Solidity
function _update(
address from,
address to,
uint256 amount
) internal override(ERC20, ERC20Votes) {
super._update(from, to, amount);
}
function nonces(
address owner
) public view override(ERC20Permit, Nonces) returns (uint256) {
return super.nonces(owner);
}
}
In the code snippet above, you:
Set a maximum supply of 1 million tokens
Implemented a constructor that mints the entire supply to the initial owner
Added a
mint
function that allows the owner to create new tokensOverrode the
_update
function to ensure proper functionality with ERC20VotesOverrode the
nonces
function to resolve conflicts between ERC20Permit and Nonces
Build the ThresholdContract
contract
Now, you'll create the ThresholdContract
, which will be the target of your cross-chain governance proposals. This contract will:
Store a threshold value that can be updated through governance.
Provide a function to update the threshold.
Emit an event when the threshold is changed.
This simple contract will serve as an example of how cross-chain governance can be used to manage parameters on different blockchains.
Let's implement this contract in our contracts
directory. Create a file ThresholdContract.sol
inside the contracts
directory, then add the following code snippet.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/// @title ThresholdContract
/// @notice A simple contract to manage and update a threshold value
contract ThresholdContract {
uint256 public threshold;
event ThresholdUpdated(uint256 newThreshold);
/// @notice Updates the threshold to a new value
/// @param newThreshold The new threshold value to set
function updateThreshold(uint256 newThreshold) external {
threshold = newThreshold;
emit ThresholdUpdated(newThreshold);
}
}
Build the CrossChainGovernor
contract
The CrossChainGovernor
contract is the most important in this tutorial. It combines standard on-chain governance with cross-chain capabilities. This contract will allow token holders to propose and vote on actions that can be executed across different blockchain networks.
Contract declaration and imports
Before adding the code, you want to set up the basic structure of your CrossChainGovernor
contract. This includes importing necessary OpenZeppelin and Axelar contracts, as well as your custom InterchainCalls
and InterchainProposalSender
contracts. You'll also declare your contract and its inheritance.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// Import necessary OpenZeppelin and Axelar contracts
import "@openzeppelin/contracts/governance/Governor.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import "@axelar-network/axelar-gmp-sdk-solidity/contracts/executable/AxelarExecutable.sol";
import "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGasService.sol";
import "./InterchainCalls.sol";
import "./InterchainProposalSender.sol";
/**
* @title CrossChainGovernor
* @dev A governance contract that enables cross-chain proposal creation and execution using Axelar Network.
* @notice This contract allows for creating and executing threshold update proposals across different blockchain networks.
*/
contract CrossChainGovernor is
Governor,
GovernorCountingSimple,
GovernorVotes,
AxelarExecutable
{
// Contract body will be added in subsequent steps
}
State variables and events
Add the state variables and events for your contract. This includes variables for the Axelar gas service, proposal sender, threshold address, and mappings for whitelisted callers and senders. You'll also define the CrossChainProposal
struct and relevant events.
//...
contract CrossChainGovernor is Governor, GovernorSettings, GovernorCountingSimple, GovernorVotes, AxelarExecutable {
//...
// Axelar gas service for cross-chain transactions
IAxelarGasService public immutable gasService;
// Contract for sending interchain proposals
InterchainProposalSender public immutable proposalSender;
// Mappings to store whitelisted senders and callers for cross-chain governance
mapping(string => mapping(string => bool)) public whitelistedSenders;
mapping(string => mapping(bytes => bool)) public whitelistedCallers;
// Structure to store threshold proposal details
struct ThresholdProposal {
string destinationChain;
string destinationContract;
address thresholdContract;
uint256 newThreshold;
}
// Mapping to store threshold proposals
mapping(uint256 => ThresholdProposal) public thresholdProposals;
/**
* @dev Emitted when a threshold proposal is created.
* @param proposalId The ID of the created proposal.
* @param destinationChain The target blockchain for the proposal.
* @param destinationContract The address of the contract on the destination chain.
* @param thresholdContract The address of the threshold contract.
* @param newThreshold The proposed new threshold value.
*/
event ThresholdProposalCreated(
uint256 proposalId,
string destinationChain,
string destinationContract,
address thresholdContract,
uint256 newThreshold
);
/**
* @dev Emitted when a whitelisted caller is set.
* @param sourceChain The source blockchain of the caller.
* @param sourceCaller The address of the caller.
* @param whitelisted The whitelist status of the caller.
*/
event WhitelistedCallerSet(
string sourceChain,
bytes sourceCaller,
bool whitelisted
);
/**
* @dev Emitted when a cross-chain proposal is executed.
* @param proposalHash The unique hash of the executed proposal.
*/
event CrossChainProposalExecuted(bytes32 indexed proposalHash);
/**
* @dev Emitted when a threshold proposal is executed.
* @param proposalId The ID of the executed proposal.
*/
event ThresholdProposalExecuted(uint256 indexed proposalId);
/**
* @dev Emitted when a whitelisted sender is set.
* @param sourceChain The source blockchain of the sender.
* @param sourceSender The address of the sender.
* @param whitelisted The whitelist status of the sender.
*/
event WhitelistedSenderSet(
string sourceChain,
string sourceSender,
bool whitelisted
);
// Constructor here
}
Add a constructor
Add the constructor for your CrossChainGovernor
contract. This will initialize the contract with the necessary parameters, including the governance token, Axelar gateway, gas service, proposal sender, and threshold address.
//...
contract CrossChainGovernor is Governor, GovernorSettings, GovernorCountingSimple, GovernorVotes, AxelarExecutable {
//...
/**
* @dev Constructor to initialize the CrossChainGovernor contract.
* @param _name The name of the governor.
* @param _token The voting token address.
* @param _gateway The Axelar gateway address.
* @param _gasService The Axelar gas service address.
* @param _proposalSender The address of the InterchainProposalSender contract.
*/
constructor(
string memory _name,
IVotes _token,
address _gateway,
address _gasService,
address _proposalSender
) Governor(_name) GovernorVotes(_token) AxelarExecutable(_gateway) {
gasService = IAxelarGasService(_gasService);
proposalSender = InterchainProposalSender(_proposalSender);
}
}
Add whitelisting and proposal threshold update
In this step, you will add whitelisting and proposal threshold functions. These functions will be used on the source chain to whitelist and propose thresholds to be updated on the destination chain.
//...
contract CrossChainGovernor is Governor, GovernorSettings, GovernorCountingSimple, GovernorVotes, AxelarExecutable {
//...
/**
* @dev Sets a whitelisted proposal sender.
* @param sourceChain The source blockchain of the sender.
* @param sourceSender The address of the sender to be whitelisted.
* @param whitelisted The whitelist status to be set.
*/
function setWhitelistedProposalSender(
string calldata sourceChain,
string calldata sourceSender,
bool whitelisted
) external onlyGovernance {
whitelistedSenders[sourceChain][sourceSender] = whitelisted;
emit WhitelistedSenderSet(sourceChain, sourceSender, whitelisted);
}
/**
* @dev Sets a whitelisted proposal caller.
* @param sourceChain The source blockchain of the caller.
* @param sourceCaller The address of the caller to be whitelisted.
* @param whitelisted The whitelist status to be set.
*/
function setWhitelistedProposalCaller(
string calldata sourceChain,
bytes memory sourceCaller,
bool whitelisted
) external onlyGovernance {
whitelistedCallers[sourceChain][sourceCaller] = whitelisted;
emit WhitelistedCallerSet(sourceChain, sourceCaller, whitelisted);
}
/**
* @dev Proposes a threshold update.
* @param destinationChain The target blockchain for the proposal.
* @param destinationContract The address of the contract on the destination chain.
* @param thresholdContract The address of the threshold contract.
* @param newThreshold The proposed new threshold value.
* @return proposalId The ID of the created proposal.
*/
function proposeThresholdUpdate(
string memory destinationChain,
string memory destinationContract,
address thresholdContract,
uint256 newThreshold
) public returns (uint256 proposalId) {
// Create proposal parameters
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
targets[0] = thresholdContract;
values[0] = 0;
calldatas[0] = abi.encodeWithSignature(
"updateThreshold(uint256)",
newThreshold
);
// Create the proposal
proposalId = propose(
targets,
values,
calldatas,
"Proposal to update the threshold contract"
);
// Store the threshold proposal details
thresholdProposals[proposalId] = ThresholdProposal({
destinationChain: destinationChain,
destinationContract: destinationContract,
thresholdContract: thresholdContract,
newThreshold: newThreshold
});
// Emit event for threshold proposal creation
emit ThresholdProposalCreated(
proposalId,
destinationChain,
destinationContract,
thresholdContract,
newThreshold
);
return proposalId;
}
}
Add execute threshold proposal function
Next, add a function to execute threshold proposals using the proposal ID and other helper functions like _getProposalTargets
, _getProposalValues
, and _getProposalCalldatas
.
//...
contract CrossChainGovernor is Governor, GovernorSettings, GovernorCountingSimple, GovernorVotes, AxelarExecutable {
//...
/**
* @dev Executes a threshold proposal.
* @param proposalId The ID of the proposal to be executed.
*/
function executeThresholdProposal(uint256 proposalId) public payable {
require(
state(proposalId) == ProposalState.Succeeded,
"Proposal must be succeeded"
);
ThresholdProposal memory proposal = thresholdProposals[proposalId];
InterchainCalls.Call[] memory calls = new InterchainCalls.Call[](1);
calls[0] = InterchainCalls.Call({
target: proposal.thresholdContract,
value: 0,
callData: abi.encodeWithSignature(
"updateThreshold(uint256)",
proposal.newThreshold
)
});
// Send the proposal to the destination chain
proposalSender.sendProposal{value: msg.value}(
proposal.destinationChain,
proposal.destinationContract,
calls
);
// Execute the proposal on the current chain
super.execute(
_getProposalTargets(proposalId),
_getProposalValues(),
_getProposalCalldatas(proposalId),
keccak256(bytes("Proposal to update the threshold contract"))
);
// Emit event for threshold proposal execution
emit ThresholdProposalExecuted(proposalId);
}
/**
* @dev Internal function to get proposal targets.
* @param proposalId The ID of the proposal.
* @return An array of target addresses for the proposal.
*/
function _getProposalTargets(
uint256 proposalId
) internal view returns (address[] memory) {
address[] memory targets = new address[](1);
targets[0] = thresholdProposals[proposalId].thresholdContract;
return targets;
}
/**
* @dev Internal function to get proposal values.
* @return An array of values for the proposal (always 0 in this case).
*/
function _getProposalValues() internal pure returns (uint256[] memory) {
uint256[] memory values = new uint256[](1);
values[0] = 0;
return values;
}
/**
* @dev Internal function to get proposal calldatas.
* @param proposalId The ID of the proposal.
* @return An array of calldata for the proposal.
*/
function _getProposalCalldatas(
uint256 proposalId
) internal view returns (bytes[] memory) {
bytes[] memory calldatas = new bytes[](1);
calldatas[0] = abi.encodeWithSignature(
"updateThreshold(uint256)",
thresholdProposals[proposalId].newThreshold
);
return calldatas;
}
}
Add _execute
function
To execute a cross-chain call received from another chain, you need to implement the _execute
function that receives and processes cross-chain calls on the destination chain.
//...
contract CrossChainGovernor is Governor, GovernorSettings, GovernorCountingSimple, GovernorVotes, AxelarExecutable {
//...
/// @notice Executes a cross-chain call received from another chain
/// @param sourceChain The name of the source chain
/// @param sourceAddress The address of the sender on the source chain
/// @param payload The encoded payload containing the calls to be executed
function _execute(
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload
) internal override {
if (!whitelistedSenders[sourceChain][sourceAddress]) {
revert NotWhitelistedSourceAddress();
}
(bytes memory sourceCaller, InterchainCalls.Call[] memory calls) = abi
.decode(payload, (bytes, InterchainCalls.Call[]));
if (!whitelistedCallers[sourceChain][sourceCaller]) {
revert NotWhitelistedCaller();
}
_executeProposal(calls);
emit CrossChainProposalExecuted(sourceChain, sourceAddress, payload);
}
/// @notice Executes the calls in a cross-chain proposal
/// @param calls An array of calls to be executed
function _executeProposal(InterchainCalls.Call[] memory calls) internal {
uint256 length = calls.length;
for (uint256 i = 0; i < length; i++) {
InterchainCalls.Call memory call = calls[i];
(bool success, bytes memory result) = call.target.call{
value: call.value
}(call.callData);
if (!success) {
_onTargetExecutionFailed(call, result);
}
}
}
/// @notice Handles the failure of a call execution
/// @param call The call that failed
/// @param result The result of the failed call
function _onTargetExecutionFailed(
InterchainCalls.Call memory call,
bytes memory result
) internal pure {
if (result.length > 0) {
assembly {
revert(add(32, result), mload(result))
}
} else {
revert ProposalExecuteFailed();
}
}
}
OpenZeppelin Governor Functions
Finally, add the required overrides for OpenZeppelin Governor functions. These include votingDelay
, votingPeriod
, and quorum
.
//...
contract CrossChainGovernor is Governor, GovernorSettings, GovernorCountingSimple, GovernorVotes, AxelarExecutable {
//...
/**
* @dev Returns the voting delay.
* @return The number of blocks between proposal creation and voting start.
*/
function votingDelay() public pure override returns (uint256) {
return 1;
}
/**
* @dev Returns the voting period.
* @return The number of blocks for the voting period.
*/
function votingPeriod() public pure override returns (uint256) {
return 50400;
}
/**
* @dev Returns the quorum required for a proposal to pass.
* @return The minimum number of votes required for a quorum.
*/
function quorum(uint256) public pure override returns (uint256) {
return 1e18;
}
}
Test the contracts
You have successfully implemented all the contracts required to establish cross-chain governance with the OpenZeppelin governor. Now, you need to write a unit test to ensure that all the functions implemented in your contracts work correctly.
You will create test cases for each prominent system feature, verifying deployment, governance settings, whitelisting, proposal lifecycle, voting, and execution. Follow along as we walk through each test case.
Create MockAxelarGateway and MockAxelarGasService files
Before proceeding to test the CrossChainGovernor
contract, you need to create mock contracts for AxelarGateway
and AxelarGasService
. These mock contracts simulate the behavior of the real Axelar contracts and will allow you to run the tests effectively.
You should place these mock contracts in a folder called mock
inside the contracts
directory.
Create MockAxelarGateway.sol
Navigate to the contracts/mock
directory and create a file named MockAxelarGateway.sol
. Add the following code to mock the basic functionality of Axelar's gateway.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/// @title MockAxelarGateway
/// @dev This is a mock contract for testing purposes that simulates the behavior of AxelarGateway.
contract MockAxelarGateway {
/// @notice Simulates the cross-chain contract call
/// @param destinationChain The chain the contract call is destined for
/// @param destinationContract The contract address on the destination chain
/// @param payload The data payload sent to the destination contract
function callContract(
string memory,
string memory,
bytes memory
) external pure {}
}
Create MockAxelarGasService.sol
Similarly, create another file in the same folder named MockAxelarGasService.sol
and add the following code to simulate the Axelar gas service.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/// @title MockAxelarGasService
/// @dev This is a mock contract for testing purposes that simulates the behavior of AxelarGasService.
contract MockAxelarGasService {
/// @notice Simulates the payment of gas fees for cross-chain calls
function payNativeGasForContractCall(
address,
string memory,
string memory,
bytes memory,
address
) external payable {}
}
Your folder structure should now look like this:
/contracts
โโโ /mock
โโโ MockAxelarGateway.sol
โโโ MockAxelarGasService.sol
Create the CrossChainGovernor.test.js
file
Inside the test
folder, create a file named CrossChainGovernor.test.js
. This file will contain all your unit tests for the CrossChainGovernor
contract.
Import dependencies
Start by importing the necessary modules and tools. In your CrossChainGovernor.test.js
file, add the following code:
const { ethers } = require("hardhat");
const { expect } = require("chai");
const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs");
The code snippet above will import ethers
from Hardhat, expect
from Chai for assertions, and anyValue
for an event matching with arbitrary values.
Setting up the contract instances with beforeEach
Before you dive into individual test cases, deploy the contracts to set up the environment. The beforeEach
hook ensures that you have fresh instances of your contracts before every test case.
//...
describe("CrossChainGovernor", function () {
let governanceToken,
crossChainGovernor,
mockAxelarGateway,
mockAxelarGasService,
interchainProposalSender,
thresholdContract;
let owner, addr1, addr2;
beforeEach(async function () {
[owner, addr1, addr2] = await ethers.getSigners();
const GovernanceToken = await ethers.getContractFactory("GovernanceToken");
governanceToken = await GovernanceToken.deploy(owner.address);
const MockAxelarGateway = await ethers.getContractFactory("MockAxelarGateway");
mockAxelarGateway = await MockAxelarGateway.deploy();
const MockAxelarGasService = await ethers.getContractFactory("MockAxelarGasService");
mockAxelarGasService = await MockAxelarGasService.deploy();
const InterchainProposalSender = await ethers.getContractFactory("InterchainProposalSender");
interchainProposalSender = await InterchainProposalSender.deploy(mockAxelarGateway.address, mockAxelarGasService.address);
const ThresholdContract = await ethers.getContractFactory("ThresholdContract");
thresholdContract = await ThresholdContract.deploy();
const CrossChainGovernor = await ethers.getContractFactory("CrossChainGovernor");
crossChainGovernor = await CrossChainGovernor.deploy(
"CrossChainGovernor",
governanceToken.address,
mockAxelarGateway.address,
mockAxelarGasService.address,
interchainProposalSender.address
);
await governanceToken.delegate(owner.address);
await governanceToken.transfer(addr1.address, ethers.utils.parseEther("100"));
await governanceToken.connect(addr1).delegate(addr1.address);
await governanceToken.mint(owner.address, ethers.utils.parseEther("1000000"));
await ethers.provider.send("evm_mine", []);
});
});
This beforeEach
function deploys new instances of the contracts, sets up the necessary addresses, and mints tokens for testing.
Run the deployment tests
Let's start by testing if the contracts are deployed correctly and with the correct parameters.
//...
describe("CrossChainGovernor", function () {
//...
describe("Deployment", function () {
it("Should set the right token", async function () {
expect(await crossChainGovernor.token()).to.equal(governanceToken.address);
});
it("Should set the right gas service", async function () {
expect(await crossChainGovernor.gasService()).to.equal(mockAxelarGasService.address);
});
it("Should set the right proposal sender", async function () {
expect(await crossChainGovernor.proposalSender()).to.equal(interchainProposalSender.address);
});
});
});
To test the deployment, run the following command:
npx hardhat test
CrossChainGovernor
Deployment
โ Should set the right token
โ Should set the right gas service
โ Should set the right proposal sender
3 passing (2s)
Testing governance settings
Test the governance settings, including voting delay, voting period, and quorum.
//...
describe("CrossChainGovernor", function () {
//...
describe("Governance settings", function () {
it("Should have the correct voting delay", async function () {
expect(await crossChainGovernor.votingDelay()).to.equal(1);
});
it("Should have the correct voting period", async function () {
expect(await crossChainGovernor.votingPeriod()).to.equal(50400);
});
it("Should have the correct quorum", async function () {
expect(await crossChainGovernor.quorum(0)).to.equal(ethers.utils.parseEther("1"));
});
});
});
Run this test with the following command:
npx hardhat test
CrossChainGovernor
Deployment
โ Should set the right token
โ Should set the right gas service
โ Should set the right proposal sender
Governance settings
โ Should have the correct voting delay
โ Should have the correct voting period
โ Should have the correct quorum
6 passing (2s)
Test whitelisting functionality
Test whether you can whitelist a proposal sender.
//...
describe("CrossChainGovernor", function () {
//...
describe("Whitelisting", function () {
it("Should allow whitelisting a proposal sender", async function () {
const destinationChain = "destinationChain";
const destinationContract = thresholdContract.address;
const newThreshold = 1000;
const proposeTx = await crossChainGovernor.proposeThresholdUpdate(
destinationChain,
destinationContract,
thresholdContract.address,
newThreshold
);
const proposeReceipt = await proposeTx.wait();
await expect(proposeTx)
.to.emit(crossChainGovernor, "ThresholdProposalCreated")
.withArgs(anyValue, destinationChain, destinationContract, thresholdContract.address, newThreshold);
});
});
});
To test the whitelisting functionality, run the following command:
npx hardhat test
CrossChainGovernor
Deployment
โ Should set the right token
โ Should set the right gas service
โ Should set the right proposal sender
Governance settings
โ Should have the correct voting delay
โ Should have the correct voting period
โ Should have the correct quorum
Whitelisting
โ Should allow whitelisting a proposal sender
7 passing (2s)
Testing Threshold proposals
You can now test the creation and voting on threshold proposals.
//...
describe("CrossChainGovernor", function () {
//...
describe("Threshold Proposal", function () {
it("Should allow creating a threshold proposal", async function () {
const destinationChain = "destinationChain";
const destinationContract = thresholdContract.address;
const newThreshold = 1000;
await expect(
crossChainGovernor.proposeThresholdUpdate(
destinationChain,
destinationContract,
thresholdContract.address,
newThreshold
)
)
.to.emit(crossChainGovernor, "ThresholdProposalCreated")
.withArgs(anyValue, destinationChain, destinationContract, anyValue, newThreshold);
});
it("Should allow voting on a threshold proposal", async function () {
const destinationChain = "destinationChain";
const destinationContract = thresholdContract.address;
const newThreshold = 1000;
const proposeTx = await crossChainGovernor.proposeThresholdUpdate(
destinationChain,
destinationContract,
thresholdContract.address,
newThreshold
);
const proposeReceipt = await proposeTx.wait();
const proposalId = proposeReceipt.events.find(e => e.event === "ThresholdProposalCreated").args.proposalId;
await ethers.provider.send("evm_mine", []);
await expect(crossChainGovernor.castVote(proposalId, 1)).to.emit(crossChainGovernor, "VoteCast");
});
});
});
Test the Threshold proposals by running the command below:
npx hardhat test
CrossChainGovernor
Deployment
โ Should set the right token
โ Should set the right gas service
โ Should set the right proposal sender
Governance settings
โ Should have the correct voting delay
โ Should have the correct voting period
โ Should have the correct quorum
Whitelisting
โ Should allow whitelisting a proposal sender
Threshold Proposal
โ Should allow creating a threshold proposal
โ Should allow voting on a threshold proposal (13069ms)
9 passing (16s)
Testing proposal lifecycle
Test the full lifecycle of a proposal, including its state transitions.
//...
describe("CrossChainGovernor", function () {
//...
describe("Proposal lifecycle", function () {
let proposalId;
beforeEach(async function () {
const destinationChain = "destinationChain";
const destinationContract = thresholdContract.address;
const newThreshold = 1000;
const proposeTx = await crossChainGovernor.proposeThresholdUpdate(
destinationChain,
destinationContract,
thresholdContract.address,
newThreshold
);
const proposeReceipt = await proposeTx.wait();
proposalId = proposeReceipt.events.find(e => e.event === "ThresholdProposalCreated").args.proposalId;
await ethers.provider.send("evm_mine", []);
});
it("Should start in the Pending state", async function () {
const proposalState = await crossChainGovernor.state(proposalId);
expect(proposalState).to.equal(0); // Pending
});
it("Should move to Active state after votingDelay", async function () {
await ethers.provider.send("evm_mine", []);
const proposalState = await crossChainGovernor.state(proposalId);
expect(proposalState).to.equal(1); // Active
});
it("Should allow voting when Active", async function () {
await expect(crossChainGovernor.castVote(proposalId, 1)).to.emit(crossChainGovernor, "VoteCast");
});
});
});
Run:
npx hardhat test
Testing proposal execution
Lastly, ensure that proposals are only executed when successful.
//...
describe("CrossChainGovernor", function () {
//...
describe("Proposal Execution", function () {
it("Should allow execution of a succeeded proposal", async function () {
const proposalId = anyValue;
const gasFee = ethers.utils.parseEther("0.1");
await expect(
crossChainGovernor.executeThresholdProposal(proposalId, {
value: gasFee,
})
).to.emit(crossChainGovernor, "ThresholdProposalExecuted");
});
it("Should not allow execution of an already executed proposal", async function () {
const proposalId = anyValue;
const gasFee = ethers.utils.parseEther("0.1");
await crossChainGovernor.executeThresholdProposal(proposalId, {
value: gasFee,
});
await expect(
crossChainGovernor.executeThresholdProposal(proposalId, {
value: gasFee,
})
).to.be.revertedWith("Proposal must be succeeded");
});
});
});
Run the proposal execution test using the following command:
npx hardhat test
CrossChainGovernor
Deployment
โ Should set the right token
โ Should set the right gas service
โ Should set the right proposal sender
Governance settings
โ Should have the correct voting delay
โ Should have the correct voting period
โ Should have the correct quorum
Whitelisting
โ Should allow whitelisting a proposal sender
Threshold Proposal
โ Should allow creating a threshold proposal
โ Should allow voting on a threshold proposal (13203ms)
Proposal lifecycle
โ Should start in the Pending state
โ Should move to Active state after votingDelay
โ Should allow voting when Active
Proposal Execution
โ Should not allow execution of a proposal that hasn't succeeded (42ms)
โ Should allow execution of a succeeded proposal (13341ms)
โ Should not allow execution of an already executed proposal (13399ms)
15 passing (44s)
Proposal creation and voting
This section covers how to test the creation of proposals by token and non-token holders, ensure proper voting behavior, and prevent double voting.
Test for non-token holders creating proposals
In this test, we check if non-token holders can create proposals. However, the proposal should fail due to a lack of quorum.
//...
describe("CrossChainGovernor", function () {
//...
describe("Proposal Creation and Voting", function () {
it("Should allow non-token holders to create proposals, but the proposal should fail", async function () {
const [, nonHolder] = await ethers.getSigners();
// Create a proposal
const tx = await crossChainGovernor
.connect(nonHolder)
.proposeThresholdUpdate(
"destinationChain",
crossChainGovernor.address,
thresholdContract.address,
1000
);
const receipt = await tx.wait();
const event = receipt.events.find(
(e) => e.event === "ThresholdProposalCreated"
);
expect(event).to.not.be.undefined;
const proposalId = event.args.proposalId;
// Advance the block to move past the voting delay
const votingDelay = await crossChainGovernor.votingDelay();
for (let i = 0; i <= votingDelay.toNumber(); i++) {
await ethers.provider.send("evm_mine", []); // Mine blocks
}
// Let the proposal become active and fail due to insufficient quorum
const votingPeriod = await crossChainGovernor.votingPeriod();
for (let i = 0; i <= votingPeriod.toNumber(); i++) {
await ethers.provider.send("evm_mine", []); // Advance to the end of the voting period
}
// Check if the proposal state is defeated (3) due to lack of quorum
const newProposalState = await crossChainGovernor.state(proposalId);
expect(newProposalState).to.equal(3); // State 3 is Defeated
});
});
});
Test voting before voting delay has passed
This test ensures that voting cannot occur before the voting delay set by the governance process.
//...
it("Should not allow voting before the voting delay has passed", async function () {
// First, create a proposal to get a valid proposalId
const tx = await crossChainGovernor.proposeThresholdUpdate(
"destinationChain",
crossChainGovernor.address,
thresholdContract.address,
1000
);
// Wait for the transaction to be mined and get the event logs
const receipt = await tx.wait();
const event = receipt.events.find(
(e) => e.event === "ThresholdProposalCreated"
);
// Ensure the event exists and extract the proposalId
if (!event) {
throw new Error("ThresholdProposalCreated event not found");
}
const proposalId = event.args.proposalId;
// Now try to cast a vote before the voting delay has passed
await expect(
crossChainGovernor.castVote(proposalId, 1)
).to.be.revertedWith("Governor: vote not currently active");
});
//...
Test voting after voting period has ended
This test ensures no voting can happen after the designated voting period ends.
//...
it("Should not allow voting after the voting period has ended", async function () {
// First, create a proposal to get a valid proposalId
const tx = await crossChainGovernor.proposeThresholdUpdate(
"destinationChain",
crossChainGovernor.address,
thresholdContract.address,
1000
);
const receipt = await tx.wait();
const event = receipt.events.find(
(e) => e.event === "ThresholdProposalCreated"
);
if (!event) {
throw new Error("ThresholdProposalCreated event not found");
}
const proposalId = event.args.proposalId;
// Wait for the voting period to pass
const votingPeriod = await crossChainGovernor.votingPeriod();
for (let i = 0; i <= votingPeriod.toNumber(); i++) {
await ethers.provider.send("evm_mine", []);
}
// Now try to vote after the voting period has ended
await expect(
crossChainGovernor.castVote(proposalId, 1)
).to.be.revertedWith("Governor: vote not currently active");
});
Test double voting prevention
This test ensures that voters cannot cast more than one vote on the same proposal.
//...
it("Should not allow double voting", async function () {
// First, create a proposal to get a valid proposalId
const tx = await crossChainGovernor.proposeThresholdUpdate(
"destinationChain",
crossChainGovernor.address,
thresholdContract.address,
1000
);
const receipt = await tx.wait();
const event = receipt.events.find(
(e) => e.event === "ThresholdProposalCreated"
);
if (!event) {
throw new Error("ThresholdProposalCreated event not found");
}
const proposalId = event.args.proposalId;
// Move forward to the voting period
const votingDelay = await crossChainGovernor.votingDelay();
for (let i = 0; i <= votingDelay.toNumber(); i++) {
await ethers.provider.send("evm_mine", []);
}
// Cast the first vote
await crossChainGovernor.castVote(proposalId, 1);
// Now attempt to cast a second vote, which should fail
await expect(
crossChainGovernor.castVote(proposalId, 1)
).to.be.revertedWith("GovernorVotingSimple: vote already cast");
})
Running all the tests
After adding the above tests, you can run all tests in the CrossChainGovernor.test.js
file by executing the following command:
npx hardhat test
CrossChainGovernor
Deployment
โ Should set the right token
โ Should set the right gas service
โ Should set the right proposal sender
Governance settings
โ Should have the correct voting delay
โ Should have the correct voting period
โ Should have the correct quorum
Whitelisting
โ Should allow whitelisting a proposal sender
Threshold Proposal
โ Should allow creating a threshold proposal
โ Should allow voting on a threshold proposal (113ms)
Proposal lifecycle
โ Should start in the Pending state
โ Should move to Active state after votingDelay
โ Should allow voting when Active
Proposal Creation and Voting
โ Should allow non-token holders to create proposals, but the proposal should fail (13098ms)
โ Should not allow voting before the voting delay has passed (46ms)
โ Should not allow voting after the voting period has ended (166ms)
โ Should not allow double voting (39ms)
Proposal Execution
โ Should not allow execution of a proposal that hasn't succeeded
โ Should allow execution of a succeeded proposal (139ms)
โ Should not allow execution of an already executed proposal (189ms)
19 passing (1m)
Congratulations ๐ฅณ all the tests passed.
Next Steps
This example builds directly on Axelar's Interchain Governance Orchestrator, making it straightforward to integrate with the OpenZeppelin Governor for cross-chain governance. With these tools, you can easily manage governance across multiple blockchains.
To move forward, try deploying your contracts on testnets supported by Axelar. This will help you test the system in a real-world environment. You can also explore more advanced features like cross-chain token transfers and contract calls through Axelar's General Message Passing.
Conclusion
In this tutorial, you learned how to build and test the CrossChainGovernor
contract using OpenZepplin and Axelar General Message Passing. From testing deployments, governance settings, and whitelisting to proposal lifecycles and execution, you've covered all the essential aspects.
References
Subscribe to my newsletter
Read articles from Idris Olubisi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Idris Olubisi
Idris Olubisi
Software Engineer | Developer Advocate | Technical Writer | Content Creator