Skip to main content

Ping Pong Example

This tutorial demonstrates how to send a cross-chain message via Hyperlane Protocol.

You'll learn how to:

  • Deploy Hyperlane Mailbox-compatible contracts
  • Deploy trusted relayer ISM(Interchain Security Module)
  • Run a simple relayer
  • Send cross-chain messages

We recommend using Hardhat for an easy-to-follow experience.

Example Code

You can find the contracts and hardhat tasks for deployment and execution in our demo-opl repositiory.

Overview Ping Pong

In this example, you'll deploy a similar contract on two different chains. The contract on Chain A will send a ping message to Chain B using Hyperlane Protocol. The contract on Chain B will process this message and respond with a ping back to Chain A.

Ping Pong Flow

Setup

  1. Create and navigate to a new directory:

    mkdir hyperlane-pingpong && cd hyperlane-pingpong
  2. Initialize a Hardhat project and install dependencies:

    npx hardhat init
  3. Add @hyperlane-xyz/core as dependency:

    npm install -D @hyperlane-xyz/core
info

There can be some problems with dependencies, be sure to have ethers@^6 and openzeppelin@^4.9.3.

Test Token

Make sure you have enough test token on Arbitrum Sepolia and Sapphire Testnet

Get more:

  • TEST tokens for Sapphire Testnet from the Oasis Faucet.
  • ETH tokens for Arbitrum Sepolia from Alchemy's Faucet.

Add Networks to Hardhat

Open up your hardhat.config.ts and add Arbitrum Sepolia and Sapphire Testnet.

hardhat.config.ts
 import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

const accounts = process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [];

const config: HardhatUserConfig = {
solidity: "0.8.24",
networks: {
'arbitrum-sepolia': {
url: 'https://arbitrum-sepolia-rpc.publicnode.com',
chainId: 421614,
accounts,
},
'sapphire-testnet': {
url: "https://testnet.sapphire.oasis.io",
accounts,
chainId: 23295, // 0x5aff
},
},
};

export default config;
info

Sapphire only supports evmVersion paris, which is the current default for Hardhat. Should Hardhat change this, you need to add evmVersion: "paris" to the solidity config.

Ping Pong Contract

For this example we leverage the Router wrapper from Hyperlane. This results in following advantages:

  • Contracts are compatible with Hyperlane's MailboxClient and IMessageRecipient interfaces.
  • Supports enrolling Routers of other chains.
  • Supports setting up a custom ISM.
  1. Create a new file called Ping.sol for Arbitrum Sepolia
  2. Paste the following contract into it:
Ping.sol Contract
Ping.sol
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.13;

// ============ External Imports ============
import {Router} from "@hyperlane-xyz/core/contracts/client/Router.sol";

/*
* @title Ping
* @dev You can use this simple app as a starting point for your own application.
*/
contract Ping is Router {
// A generous upper bound on the amount of gas to use in the handle
// function when a message is processed. Used for paying for gas.
uint256 public constant HANDLE_GAS_AMOUNT = 50_000;

// A counter of how many messages have been sent from this contract.
uint256 public sent;
// A counter of how many messages have been received by this contract.
uint256 public received;

// Keyed by domain, a counter of how many messages that have been sent
// from this contract to the domain.
mapping(uint32 => uint256) public sentTo;
// Keyed by domain, a counter of how many messages that have been received
// by this contract from the domain.
mapping(uint32 => uint256) public receivedFrom;

// ============ Events ============
event SentPing(
uint32 indexed origin,
uint32 indexed destination,
string message
);
event ReceivedPing(
uint32 indexed origin,
uint32 indexed destination,
bytes32 sender,
string message
);
event HandleGasAmountSet(
uint32 indexed destination,
uint256 handleGasAmount
);

constructor(address _mailbox) Router(_mailbox) {
// Transfer ownership of the contract to deployer
_transferOwnership(msg.sender);
setHook(address(0));
}

// ============ External functions ============

/**
* @notice Sends a message to the _destinationDomain. Any msg.value is
* used as interchain gas payment.
* @param _destinationDomain The destination domain to send the message to.
* @param _message The message to send.
*/
function sendPing(
uint32 _destinationDomain,
string calldata _message
) public payable {
sent += 1;
sentTo[_destinationDomain] += 1;
_dispatch(_destinationDomain, bytes(_message));
emit SentPing(
mailbox.localDomain(),
_destinationDomain,
_message
);
}

/**
* @notice Fetches the amount of gas that will be used when a message is
* dispatched to the given domain.
*/
function quoteDispatch(
uint32 _destinationDomain,
bytes calldata _message
) external view returns (uint256) {
return _quoteDispatch(_destinationDomain, _message);
}

// ============ Internal functions ============
/**
* @notice Handles a message from a remote router.
* @dev Only called for messages sent from a remote router, as enforced by Router.sol.
* @param _origin The domain of the origin of the message.
* @param _sender The sender of the message.
* @param _message The message body.
*/
function _handle(
uint32 _origin,
bytes32 _sender,
bytes calldata _message
) internal override {
received += 1;
receivedFrom[_origin] += 1;
emit ReceivedPing(
_origin,
mailbox.localDomain(),
_sender,
string(_message)
);
}
}
  1. Create a new file called Pong.sol for Sapphire Testnet
  2. Paste the following contract into it:
Pong.sol Contract
Pong.sol
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.13;

// ============ External Imports ============
import {Router} from "@hyperlane-xyz/core/contracts/client/Router.sol";

/*
* @title Pong
* @dev You can use this simple app as a starting point for your own application.
*/
contract Pong is Router {
// A generous upper bound on the amount of gas to use in the handle
// function when a message is processed. Used for paying for gas.
uint256 public constant HANDLE_GAS_AMOUNT = 50_000;

// A counter of how many messages have been sent from this contract.
uint256 public sent;
// A counter of how many messages have been received by this contract.
uint256 public received;

// Keyed by domain, a counter of how many messages that have been sent
// from this contract to the domain.
mapping(uint32 => uint256) public sentTo;
// Keyed by domain, a counter of how many messages that have been received
// by this contract from the domain.
mapping(uint32 => uint256) public receivedFrom;

// ============ Events ============
event SentPing(
uint32 indexed origin,
uint32 indexed destination,
string message
);
event ReceivedPing(
uint32 indexed origin,
uint32 indexed destination,
bytes32 sender,
string message
);
event HandleGasAmountSet(
uint32 indexed destination,
uint256 handleGasAmount
);

constructor(address _mailbox) Router(_mailbox) {
// Transfer ownership of the contract to deployer
_transferOwnership(msg.sender);
setHook(address(0));
}

// ============ External functions ============

/**
* @notice Sends a message to the _destinationDomain. Any msg.value is
* used as interchain gas payment.
* @param _destinationDomain The destination domain to send the message to.
* @param _message The message to send.
*/
function sendPing(
uint32 _destinationDomain,
string calldata _message
) public payable {
sent += 1;
sentTo[_destinationDomain] += 1;
_dispatch(_destinationDomain, bytes(_message));
emit SentPing(
mailbox.localDomain(),
_destinationDomain,
_message
);
}

/**
* @notice Fetches the amount of gas that will be used when a message is
* dispatched to the given domain.
*/
function quoteDispatch(
uint32 _destinationDomain,
bytes calldata _message
) external view returns (uint256) {
return _quoteDispatch(_destinationDomain, _message);
}

// ============ Internal functions ============
/**
* @notice Handles a message from a remote router.
* @dev Only called for messages sent from a remote router, as enforced by Router.sol.
* @param _origin The domain of the origin of the message.
* @param _sender The sender of the message.
* @param _message The message body.
*/
function _handle(
uint32 _origin,
bytes32 _sender,
bytes calldata _message
) internal override {
received += 1;
receivedFrom[_origin] += 1;
emit ReceivedPing(
_origin,
mailbox.localDomain(),
_sender,
string(_message)
);
// send return message
sendPing(
_origin,
string(_message)
);
}
}

ISM Contract

In the current state the default ISM of the Arbitrum Sepolia Mailbox won't accept a message if you send a message from Sapphire Testnet to Arbitrum Sepolia. You can deploy and register a custom ISM on the Arbitrum Sepolia contract to make it work.

A simple default ISM from Hyperlane is a TrustedRelayerISM, which checks the relayer address before delivering the message.

  1. Create a new file called TrustedRelayerIsm.sol
  2. Paste the following contract into it:
TrustedRelayerIsm.sol Contract
TrustedRelayerIsm.sol
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0;

// ============ Internal Imports ============
import {IInterchainSecurityModule} from "@hyperlane-xyz/core/contracts/interfaces/IInterchainSecurityModule.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {Message} from "@hyperlane-xyz/core/contracts/libs/Message.sol";
import {Mailbox} from "@hyperlane-xyz/core/contracts/Mailbox.sol";
import {PackageVersioned} from "@hyperlane-xyz/core/contracts/PackageVersioned.sol";

contract TrustedRelayerIsm is IInterchainSecurityModule, PackageVersioned {
using Message for bytes;

uint8 public immutable moduleType = uint8(Types.NULL);
Mailbox public immutable mailbox;
address public immutable trustedRelayer;

constructor(address _mailbox, address _trustedRelayer) {
require(
_trustedRelayer != address(0),
"TrustedRelayerIsm: invalid relayer"
);
require(
Address.isContract(_mailbox),
"TrustedRelayerIsm: invalid mailbox"
);
mailbox = Mailbox(_mailbox);
trustedRelayer = _trustedRelayer;
}

function verify(
bytes calldata,
bytes calldata message
) external view returns (bool) {
return mailbox.processor(message.id()) == trustedRelayer;
}
}
tip

If you want to read more about Hyperlane's Interchain Security Modules, visit the Hyperlane docs

Key Contract Functions

  • sendPing: Initiates the cross-chain message by calling Hyperlane's IMailbox.dispatch.
  • enrollRemoteRouter: In the inherited Router contract, to register the contract from the other chain.
  • setInterchainSecurityModule: Set the ISM for the contract.
  • _handle: To handle incoming messages from the Mailbox (internal function called by the inherited Router contract handle function).

Deploying the Contracts

Deploy the Ping and Pong contracts on two different chains: Sapphire Testnet and Arbitrum Sepolia.

Use for deploying either the provided deploy-pingpong and deploy-ism tasks or use the scripts below.

Deploying Pong on Sapphire Testnet

  1. Create a deployment script deploypong.ts under scripts/:

    deploy.ts
    import { ethers } from "hardhat";

    async function main() {
    // deployed mailbox on Sapphire Testnet
    const mailbox = "0x79d3ECb26619B968A68CE9337DfE016aeA471435";

    const PongFactory = await hre.ethers.getContractFactory("Pong");
    const pong = await PongFactory.deploy(mailbox);
    const pongAddr = await pong.waitForDeployment();
    console.log(`Pong deployed at: ${pongAddr.target}`);
    }
    main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
    });
  2. Run the deployment:

    pnpm hardhat run scripts/deploypong.ts --network sapphire-testnet

Deploying Ping on Arbitrum Sepolia

  1. Create a deployment script deployping.ts under scripts/:

    deploy.ts
    import { ethers } from "hardhat";

    async function main() {
    // default mailbox on Arbitrum Sepolia
    const mailbox = "0x598facE78a4302f11E3de0bee1894Da0b2Cb71F8";

    const PingFactory = await ethers.getContractFactory("Ping");
    const ping = await PingFactory.deploy(mailbox);
    const pingAddr = await ping.waitForDeployment();
    console.log(`Ping deployed at: ${pingAddr.target}`);
    }
    main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
    });
  2. Run the deployment:

    pnpm hardhat run scripts/deployping.ts --network arbitrum-sepolia

Deploying ISM on Arbitrum Sepolia

  1. Create a deployment script deployISM.ts under scripts/:

    deployISM.ts
    import { ethers } from "hardhat";

    async function main() {
    // default mailbox on Arbitrum Sepolia
    const mailbox = "0x598facE78a4302f11E3de0bee1894Da0b2Cb71F8";
    const trustedRelayer = "0x<your relayer address>";

    const trustedRelayerISM = await ethers.deployContract(
    "TrustedRelayerIsm",
    [mailbox, trustedRelayer]
    );
    await trustedRelayerISM.waitForDeployment();
    console.log(`TrustedRelayerISM deployed to ${trustedRelayerISM.target}`);
    }
    main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
    });
  2. Run the deployment:

    pnpm hardhat run scripts/deployISM.ts --network arbitrum-sepolia

Contracts setup

Enroll Routers

As we use the Router wrapper for our Ping Pong contracts, we need to enroll the contract addresses of the opposite contract.

Enroll Router on Sapphire Testnet

  1. Create a file named enroll.tsin the folder /scripts

    enroll.ts
    import { ethers } from "hardhat";

    async function main() {
    let pingpongArbitrum = "0x<your contract deployed before>";
    let pingpongSapphire = "0x<your contract deployed before>";
    let arbId = "421614";

    const signer = await ethers.provider.getSigner();
    const contract = await ethers.getContractAt("Pong", sapphireAddr, signer);
    await contract.enrollRemoteRouter(arbId, ethers.zeroPadValue(pingpongArbitrum, 32));
    const arbRouter = await contract.routers(arbId);
    console.log(`remote router adr for ${arbId}: ${arbRouter}`)
    }
    main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
    });
  2. Run the deployment

    pnpm hardhat run scripts/enroll.ts --network sapphire-testnet

Enroll Router on Arbitrum Sepolia

  1. Create a file named enroll.tsin the folder /scripts

    enroll.ts
    import { ethers } from "hardhat";

    async function main() {
    let pingpongArbitrum = "0x<your contract deployed before>";
    let pingpongSapphire = "0x<your contract deployed before>";
    let sapphireId = "23295";

    const signer = await ethers.provider.getSigner();
    const contract = await ethers.getContractAt("Ping", pingpongArbitrum, signer);
    await contract.enrollRemoteRouter(sapphireId, ethers.zeroPadValue(pingpongSapphire, 32));
    const arbRouter = await contract.routers(sapphireId);
    console.log(`remote router adr for ${sapphireId}: ${arbRouter}`)
    }
    main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
    });
  2. Run the deployment

    pnpm hardhat run scripts/enroll.ts --network arbitrum-sepolia

Register ISM on Arbitrum Sepolia

  1. Create a file named registerIsm.tsin the folder /scripts

    registerIsm.ts
    import { ethers } from "hardhat";

    async function main() {
    let pingpongArbitrum = "0x<your contract deployed before>";
    let ismAddr = "0x<your ISM contract deployed before>";

    const signer = await ethers.provider.getSigner();
    const contract = await ethers.getContractAt("Ping", pingpongArbitrum, signer);
    await contract.setInterchainSecurityModule(ismAddr);
    }
    main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
    });
  2. Run the deployment

    pnpm hardhat run scripts/registerIsm.ts --network arbitrum-sepolia

Run Relayer

Before starting to test the Ping Pong, make sure to run a Relayer for Arbitrum Sepolia and Sapphire Testnet.

For information about how to run a Relayer, visit our Relayer page.

Executing Ping Pong

To execute you will call the sendPing function on the Ping.sol contract.

In the demo-opl example you can use the provided send-ping task or you can use the following script:

  1. Create a file named sendping.tsin the folder /scripts

    sendping.ts
    import { ethers } from "hardhat";

    async function main() {
    const destChainId = "23295";
    const message = "Hello OPL"
    const pingpongArbitrum = "0x<your pingpong contract deployed on arbitrum sepolia"

    const signer = await ethers.provider.getSigner();
    const contract = await ethers.getContractAt("Ping", pingpongArbitrum, signer);

    console.log("Calculating fee...");
    let fee = await contract.quoteDispatch(
    destChainId,
    hre.ethers.toUtf8Bytes(message));
    console.log(`Fee: ${hre.ethers.formatEther(fee)} ETH`);
    console.log("Sending message...");
    const tx = await contract.sendPing(destChainId, message, {value: fee});
    await tx.wait();
    console.log("Message sent");
    }
    main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
    });
  2. Run the script

    pnpm hardhat run scripts/sendping.ts --network arbitrum-sepolia

Verify Ping Pong

The most simple way to verify the cross-chain messaging is by checking the blockchain explorer and check the transaction on your deployed Ping contract.

If you want to monitor the events directly you can use this script:

  1. Create a file named verifyping.tsin the folder /scripts

    verifyping.ts
    import { ethers } from "hardhat";

    async function main() {
    const contractAddr = "0x<your deployed ping contract on arbitrum sepolia>"
    const signer = await ethers.provider.getSigner();
    const contract = await ethers.getContractAt("Ping", contractAddr, signer);

    const spinner = ['-', '\\', '|', '/'];
    let spinnerIndex = 0;
    const interval = setInterval(() => {
    process.stdout.write(`\rListing for event... ${spinner[spinnerIndex]}`);
    current = (spinnerIndex + 1) % spinner.length;
    }, 150);

    let events;
    do {
    const block = await ethers.provider.getBlockNumber();
    events = await contract.queryFilter('ReceivedPing', block - 10, 'latest');
    if (events.length === 0) {
    await new Promise(resolve => setTimeout(resolve, 60 * 1000));
    }
    } while (events.length === 0);

    clearInterval(interval);
    process.stdout.write(`\r`);
    process.stdout.clearLine(0);

    const parsedEvent = contract.interface.parseLog(events[0]);
    const message = parsedEvent?.args?.message;
    console.log(`Message received with: ${message}`);
    }
    main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
    });
  2. Run the script

    pnpm hardhat run scripts/verifyping.ts --network sapphire-testnet

Troubleshooting

Relayer doesn't relay message

Check:

  • You have enrolled the opposite Router contract with enrollRemoteRouter by calling ping.routers(oppositeChainId)
  • You have set the custom ISM on the Arbitrum Sepolia Ping contract by calling ping.interchainSecurityModule
  • The relayer address matches the trusted ISM configuration.

Can't verify messages

Check:

  • Your Hardhat RPC allow historical event queries.
  • Use a dedicated RPC provider (e.g., Alchemy) if necessary.