Cross-Chain Governance with OpenZeppelin Governor and Axelar

Idris OlubisiIdris Olubisi
28 min read

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

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

Interchain Governance Orchestrator is a system that simplifies cross-chain governance for Web3 applications. It consists of two primary contracts:

  1. InterchainProposalSender (on source chain): Encodes and sends proposals to other chains.

  2. 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:

  1. Call a contract on chain A and interact with a contract on chain B.

  2. All in one gas payment for cross-chain calls

  3. 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 and Call

  • InterchainCall defines the overall cross-chain message structure, including destination and multiple calls

  • Call 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:

  1. To interface with Axelar's Gateway and Gas Service for cross-chain communication.

  2. To provide a method for sending proposals to other chains, including the necessary gas payments.

  3. 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 messaging

    • IAxelarGasService for handling gas payments

    • Our custom InterchainCalls library for structuring call data

  • Defined the InterchainProposalSender contract to send proposals across different blockchain networks via the Axelar network

  • Initialized 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
  • 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:

  1. Provide voting power to token holders.

  2. Implement the ERC20 standard with additional voting and permit capabilities.

  3. 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 tokens

  • Overrode the _update function to ensure proper functionality with ERC20Votes

  • Overrode 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:

  1. Store a threshold value that can be updated through governance.

  2. Provide a function to update the threshold.

  3. 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

10
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