Query API -V3

muskbustermuskbuster
12 min read

After exploring the messaging API in this article we will explore more about the Query API of Hyperlane. With this article, you can deploy your own contracts which can query the state of any smart contract on any other blockchain using Hyperlane.

Hyperlane makes interchain communication straightforward by providing a simple on-chain API for sending and receiving messages. Query API is very useful and can be used in many cases including:-

Hyperlane makes interchain communication straightforward by providing a simple on-chain API for sending and receiving messages. Query API is very useful and can be used in many cases including:-

  • Interchain Defi Applications (fetching quotes from multiple chains)

  • Interchain Voting Applications

Contracts

In this tutorial, we will be building an interchain voting application that lets you vote from any chain and you can fetch the vote count from any remote chain. There are two contracts involved in this process, main contract will be deployed to an origin chain where you can create proposals and vote on them. Coming to the Router contract, you can deploy it onto many remote chains and you can vote on the proposals from this contract as well as you can get the vote count for a particular proposal from the main contract. Hyperlane will be used for all these interchain querying between the contracts.

This is usually the architecture that is followed for Hyperlane integration where we have a set of contracts on other chains and they interact with the contract on the main chain and vice versa based on architecture, through Hyperlane.

Below is the Github repo link, where you can find all the important contracts needed for this tutorial. We will be explaining every snippet of code of all these contracts in this tutorial. You can find all the important contracts in the “src” folder in this repo.

https://github.com/HyperlaneIndia/Query-API

As we have discussed earlier in this tutorial, there are two contracts VoteMain.sol and VoteRouter.sol. The VoteMain is the parent contract deployed to a single chain and implements the main voting logic. The VoteRouter contract will be deployed onto multiple remote chains, from where you can vote and query on the main contract using Hyperlane. Let’s understand the code of the VoteMain contract. First, we will create an enum to denote two types of votes FOR and AGAINST. Also, we need to store data for each proposal, thus we will need a struct for the proposal.

enum Vote{ FOR, AGAINST } // Creating enums to denote two types of vote 

// Structure of the proposal
struct Proposal{
    string title;
    string description;
    uint256 forVotes;   
    uint256 againstVotes;
    uint256 createdTimestamp;
    uint256 votingPeriod;
}

We need to create two mappings, one to store the proposal mapped to their proposal ID and the other one to prevent double voting. Also, we need to store the address of the mailbox contract which is core Hyperlane contracts as we will be calling them, to reply to our query made from remote chains. We will be initializing these contract addresses in the constructor.

mapping (uint256 => Proposal) public proposals; // Mapping to store the proposals
mapping (address => mapping(uint256 => bool)) public votes; // Mapping to store the votes in order to prevent double voting

address mailbox; // address of mailbox contract

constructor(address _mailbox) payable {
    mailbox = _mailbox;
}

For a security check, we would like to only receive queries from the mailbox contract. Thus, we need a modifier that will check the msg.sender is the mailbox contract. And before we move into the important functions, we need some events which we can later use to index.

// Modifier so that only mailbox can call particular functions
modifier onlyMailbox(){
    require(msg.sender == mailbox, "Only mailbox can call this function !!!");
    _;
}

// Events to track out proposals and votes
event ProposalCreated(uint256 indexed _proposalId, string _title, string _description, uint256 _createdTimestamp, uint256 _votingPeriod);
event VoteCasted(uint256 indexed _proposalId, address indexed voter, Vote _voteType);

Coming to the createProposal function, as per the name we will be creating new proposals using this function. You need to pass a name, description, and a voting period to create a new proposal. Over in the function, we generate the proposal ID by hashing all these inputs and storing the proposal in a mapping.

// Function to create a new proposal
function createProposal(string memory _title, string memory _description, uint256 _votingPeriod) external returns(uint256 proposalId){
    proposalId = uint256(keccak256(abi.encode(_title, _description, _votingPeriod)));
    require(proposals[proposalId].createdTimestamp == 0, "Proposal already created !!!");
    proposals[proposalId] = Proposal(_title, _description, 0, 0, block.timestamp, _votingPeriod);
    emit ProposalCreated(proposalId, _title, _description, block.timestamp, _votingPeriod);
}

You can find the main voting logic in the internal _vote function. There are multiple checks, we will be implementing over here like, if the proposal exists or not and if the voter has already voted. After all the checks are passed, we register the vote for the proposal and emit the event.

// Internal voting function which holds the voting logic
function _vote(uint256 _proposalId, address _voter, Vote _voteType) internal{
    require(proposals[_proposalId].createdTimestamp != 0, "Proposal doesn't exist !!!");
    require(proposals[_proposalId].createdTimestamp + proposals[_proposalId].votingPeriod >= block.timestamp, "Voting period already ended !!!");
    require(!votes[_voter][_proposalId], "Voter already voted !!!");
    if(_voteType == Vote.FOR){
        proposals[_proposalId].forVotes += 1;
    }else if(_voteType == Vote.AGAINST){
        proposals[_proposalId].againstVotes += 1;
    }
    votes[_voter][_proposalId] = true;
    emit VoteCasted(_proposalId, _voter, _voteType);
}

Now comes, the heart of this contract which is the handle function. All the remote contracts will be calling this particular function using Hyperlane. Over in this implementation, we are expecting two cases either the remote contract will be voting for a proposal or it will query the votes for a proposal. Thus, we will decode the data based on it. If the call type is 1 which means the remote contract wants to vote, we will call the internal _vote function or if the call type is 2 which means the remote contract wants to fetch the vote count for a proposal, we will send back the vote count using Hyperlane. To send it back, we will call the mailbox contract with the data, and it will return us the quote for interchain gas to be paid. After this, we will pay the interchain gas for this interchain message. Also, using the onlyMailbox modifier we are checking that only the mailbox can call this function.

// handle function which is called by the mailbox to bridge votes from other chains
function handle(uint32 _origin, bytes32 _sender, bytes memory _body) external onlyMailbox{
    (uint256 callType, bytes memory _data) = abi.decode(_body, (uint256, bytes));
    if(callType == 1){
        (uint256 _proposalId, address _voter, Vote _voteType) = abi.decode(_data, (uint256, address, Vote));
        _vote(_proposalId, _voter, _voteType);
    }else if(callType == 2){
        (uint256 _proposalId) = abi.decode(_data, (uint256));
        (uint256 forVotes, uint256 againstVotes) = getVotes(_proposalId);
        bytes memory data = abi.encode(_proposalId, forVotes, againstVotes);
        uint256 quote = IMailbox(mailbox).quoteDispatch(_origin, _sender, data);
        IMailbox(mailbox).dispatch{value: quote}(_origin, _sender, data);
    }

}

Now, let’s move to the VoteRouter.sol contract. Similar to the VoteMain contract, over here also we declare an enum to denote two types of votes. And also a struct to store the votes for proposals along with a mapping of proposal ID mapped to the proposal. Moreover, we need some details like the mailbox contract address(You can find all these contract addresses here), the origin chain ID(You can find it here), and the VoteMain contract address on the origin chain. As per normal workflow, we will initialize all these addresses using the constructor.

enum Vote{ FOR, AGAINST } // Creating enums to denote two types of vote 

struct VoteCount{
    uint256 forVotes;
    uint256 againstVotes;
}

mapping(uint256 => VoteCount) public votes;

// variables to store important contract addresses and domain identifiers
address mailbox;
uint32 domainId;
address voteContract;

// Modifier so that only mailbox can call particular functions
modifier onlyMailbox(){
    require(msg.sender == mailbox, "Only mailbox can call this function !!!");
    _;
}

constructor(address _mailbox, uint32 _domainId, address _voteContract){
    mailbox = _mailbox;
    domainId = _domainId;
    voteContract = _voteContract;
}

Over in the sendVote function, we will be able to send votes to the origin chain using Hyperlane. You need to pass in the proposal ID and the vote type as function arguments. Then, we will pass in the data to the mailbox contract and get the quote for interchain gas to be paid. In the next function call we will call the dispatch function and pay the interchain gas fees.

// By calling this function you can cast your vote on other chain
function sendVote(uint256 _proposalId, Vote _voteType) payable external {
    bytes memory data = abi.encode(1,abi.encode(_proposalId,msg.sender,_voteType));
        uint256 quote = IMailbox(mailbox).quoteDispatch(domainId, addressToBytes32(voteContract), data);
    IMailbox(mailbox).dispatch{value: quote}(domainId, addressToBytes32(voteContract), data);
}

Similar to the sendVote function, we will make a call to the origin chain to fetch votes for a proposal ID. This functionality is implemented in the fetchVotes function. You need to pass in the proposal ID to this function as an argument. Then, this proposal ID is encoded and sent to the mailbox contract and gets back the quote for interchain gas. And, then we pay the interchain gas fees using the dispatch function on the mailbox contract.

// By calling this function you can fetch votes from the main contract
function fetchVotes(uint256 _proposalId) payable external{
    bytes memory data = abi.encode(2,abi.encode(_proposalId));
    uint256 quote = IMailbox(mailbox).quoteDispatch(domainId, addressToBytes32(voteContract), data);
    IMailbox(mailbox).dispatch{value: quote}(domainId, addressToBytes32(voteContract), data);
}

Coming to the handle function, the origin contract will call this function to pass in the vote count which will be then saved into the mapping we created before. This handle function can only be called by the mailbox contract. We decode the body that we received and then store it in the votes mapping.

// handle function which is called by the mailbox to bridge votes from other chains
function handle(uint32 _origin, bytes32 _sender, bytes memory _body) external onlyMailbox{
    (uint256 proposalId, uint256 forVotes, uint256 againstVotes) = abi.decode(_body, (uint256, uint256, uint256));
    votes[proposalId].forVotes = forVotes;
    votes[proposalId].againstVotes = againstVotes;
}

In this example, we have used the IMailbox to interact with Hyperlane smart contracts. IMailbox helps us to interact with Hyperlane’s mailbox core contract using which you can get the quote of the interchain gas you need to pay as well as dispatch and pay the gas fees required for your message to be delivered on the remote chain. The mailbox smart contract is an on-chain API for sending and receiving interchain messages. Validators and relayers index the mailbox contract for the interchain messages to validate and deliver to remote chains respectively.

Let’s have a look at the usage of the IMailbox interface. Over in the sendVote function, we have used this interface. We are calling the quoteDispatch function of the mailbox contract using this interface. We are passing the domainId of the remote chain, the contract address of the recipient, and the data. As a result, we get a quote for the interchain gas fee needed from the mailbox contract which will be used later to pay the gas fees for your particular message.

uint256 quote = IMailbox(mailbox).quoteDispatch(domainId, addressToBytes32(voteContract), abi.encode(_proposalId, msg.sender, _voteType));

After getting the quote for the interchain gas fees needed to be paid we will move ahead to initiate the interchain message. To do so, we will call the dispatch function of the mailbox along with the same function arguments you passed for the quoteDispatch function previously. The most important thing to notice is that we will be passing the required interchain gas fees as the value in the function call as per the quote we got previously.

IMailbox(mailbox).dispatch{value: quote}(domainId, addressToBytes32(voteContract), abi.encode(_proposalId, msg.sender, _voteType));

Now let’s move to the remix implementation of this project and test our query API. Copy the code from the GitHub repo and let’s move on.

Let’s first deploy the VoteMain contract. After copying the code, click on the compile tab and compile the contract.

After compiling this contract, you can move ahead to deploy it. Over in the deployment section, choose Metamask as the injected provider and choose your desired origin chain(We have chosen Sepolia for this tutorial). Move on to the constructor argument section and fill up it with the addresses(You can get it from here). Also in the value section, give 10 Finney as we need to pay for the gas fees when we reply back to the query. And then click on deploy.

After deploying the contract, you can create a proposal in the VoteMain contract and then we can vote on the same proposal. To do so, click on the deployed contract section and choose your VoteMain contract. Over there, fill in the required details and create a proposal.

After that, you can check the logs of the createProposal transaction and can get the proposalID

After this, you can vote for the proposal ID you got in the logs. To do so, to the voteProposal function and fill up the proposal ID and fill up your vote type either 0 or 1. And then click on the transact button.

Now, let's compile and deploy the VoteRouter contract. Go on to the compile section and compile the VoteRouter contract.

Let’s deploy the VoteRouter contract, but before doing so change the network to the remote chain. (In this case, we are using the Mumbai network). In the constructor argument section fill in up details like mailbox contract, interchainGasPaymaster contract, the parent chain ID, and the address of the VoteMain contract on the origin chain. Click on transact to deploy.

Now let’s try to fetch the vote count from the parent chain for the proposal ID we voted for. To do so over in the deployed contract section, choose the VoteRouter contract and fill up the proposal ID in the fetchVote function and click transact. (Don’t forget to change the value to 10 Finney while making this call as we need to pay the interchain gas fees).

Over in the logs you can find the dispatch ID of the interchain message. Let’s search for this message ID on the Hyperlane Explorer.

As soon as the message we sent from Mumbai is received in Sepolia, a new Hyperlane interchain message is generated from Mumbai to Sepolia to send back the vote count. You can check that too over in Hyperlane Explorer.

After that, we can come back to Remix, and call the getVotes function over in the VoteRouter contract and can see the fetched vote count from the origin chain.

You can see here that the against vote count is 1 which is the vote we recorded over in the origin chain. Thus successfully, we have tested the Query API with Hyperlane !!!!!

In conclusion, Hyperlane's interchain communication technology provides an easy way to bridge communication across multiple chains. With the ability to deploy smart contracts on multiple chains and bridge them together, developers can create new and innovative decentralized applications that were previously impossible. By using Hyperlane's tools, developers can leverage the power of multiple chains to create a more robust and efficient blockchain ecosystem. The code snippets provided in this article serve as an introduction to Hyperlane and its capabilities. With further exploration and experimentation, developers can create even more complex and powerful decentralized applications.

1
Subscribe to my newsletter

Read articles from muskbuster directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

muskbuster
muskbuster