Query API

Query 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:-

  • Interchain Defi Applications (fetching quotes from multiple chains)

  • Interchain Voting Applications

We will follow through the blog in the following series:

Introduction:

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, the 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

Vote Main Contract:

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 and interchainGasPaymaster contract which are core Hyperlane contracts as we will be calling them, to reply back 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
address interchainGasPaymaster;

constructor(address _mailbox, address _interchainGasPaymaster) payable {
    mailbox = _mailbox;
    interchainGasPaymaster = _interchainGasPaymaster;
}

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. 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 that we will be implementing here, like, if the proposal exists or not or 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: the handle function. All the remote contracts will be calling this particular function using Hyperlane. 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 message ID. After this, we will pay the interchain gas for this message ID. 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);
        bytes32 messageId = IMailbox(mailbox).dispatch(_origin, _sender, abi.encode(_proposalId, forVotes, againstVotes));
        uint256 quote = IInterchainGasPaymaster(interchainGasPaymaster).quoteGasPayment(_origin, 10000);
        IInterchainGasPaymaster(interchainGasPaymaster).payForGas{value: quote}(
            messageId,
            _origin,
            10000,
            address(this)
        );
    }

}

Vote Router Contract:

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, the interchainGasPaymaster 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;
address interchainGasPaymaster;
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, address _interchainGasPaymaster, uint32 _domainId, address _voteContract){
    mailbox = _mailbox;
    interchainGasPaymaster = _interchainGasPaymaster;
    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 message ID. Using the interchainGasPaymaster, we will pay the gas fees for the message ID we got from the mailbox contract.

// 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));
    bytes32 messageId = IMailbox(mailbox).dispatch(domainId, addressToBytes32(voteContract), data);
    uint256 quote = IInterchainGasPaymaster(interchainGasPaymaster).quoteGasPayment(domainId, 10000);
    IInterchainGasPaymaster(interchainGasPaymaster).payForGas{value: quote}(
        messageId,
        domainId,
        10000,
        msg.sender
    );
}

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 message ID. And, then we pay the interchain gas fees using the interchainGasPaymaster for the message ID.

// 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));
    bytes32 messageId = IMailbox(mailbox).dispatch(domainId, addressToBytes32(voteContract), data);
    uint256 quote = IInterchainGasPaymaster(interchainGasPaymaster).quoteGasPayment(domainId, 10000);
    IInterchainGasPaymaster(interchainGasPaymaster).payForGas{value: quote}(
        messageId,
        domainId,
        10000,
        msg.sender
    );
}

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 two interfaces namely IMailbox and IInterchainGasPaymaster to interact with Hyperlane smart contracts. IMailbox helps us to interact with Hyperlane’s mailbox core contract and the IInterchainGasPaymaster helps us to interact with the interchain gasPaymaster contract using which you can 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. The InterchainGasPaymaster contract helps the users to pay the gas fees needed to deliver the message in the remote chain in terms of native tokens of the origin chain. It works with gas oracles, to track conversion rates and decides the amount to be paid.

First, we will have a look at the usage of the IMailbox interface. Over in the sendVote function, we have used this interface. We are calling the dispatch 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 messageId from the mailbox contract which will be used later to pay the gas fees for your particular message.

bytes32 messageId = IMailbox(mailbox).dispatch(domainId, addressToBytes32(voteContract), abi.encode(_proposalId, msg.sender, _voteType));

After getting the messageId, now we will use the IInterchainGasPaymaster interface to pay the gas fees for your message to be delivered in the remote chain. To execute this functionality, we need to call two functions “quoteGasPayment” and “payForGas”. The quoteGasPayment function returns us the amount of gas fees we need to pay for the delivery of your message on the remote chain based on the approximate gas it will take to execute the handle functions on the recipient side. We pass the domainId and the approximate gas values to this function and it returns how much you need to pay in terms of Wei. After getting this value, now it’s time to pay for your messageId and for this, we call the “payForGas” function. We pass in the received quote as the value of the function call along with the messageId we received from the dispatch function, domainId of the remote chain, approximate gas required in the handle function of the recipient contract, and an address to return the extra gas fees if we paid more than required as function arguments.

uint256 quote = IInterchainGasPaymaster(interchainGasPaymaster).quoteGasPayment(domainId, 10000);
IInterchainGasPaymaster(interchainGasPaymaster).payForGas{value: quote}(
    messageId,
    domainId,
    10000,
    msg.sender
);

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.

Compile and Deploy:

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.

Test:

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 get the proposalID

After this, you can vote for the proposal ID you got in the logs. To do so, go to the voteProposal function 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 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, 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 !!!!!

Conclusion:

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.