Skip to main content

Develop the contract

This guide equips you with the knowledge to construct Solidity contracts for an Equito App that is capable of delivering and receiving cross-chain messages, using the EquitoApp abstract contract. As an example, we will write a smart contract that sends a "ping" message to another contract on a different network, and receives a "pong" message in return. This will demonstrate the basic concepts of sending and receiving messages across different networks using Equito, and how to handle messages securely within your contract. Additionally, this contract showcases an important feature of Equito which we call Atomic Receive-Send: the ability to send a message in response to a received message in the same transaction. This is quite important for many cross-chain applications, particularly for those that require a synchronous response to a message, including lending protocols or identity verification services.

We will start from the basic setup from the previous step, in which we created a smart contract that inherits from the EquitoApp abstract contract. The EquitoApp contract provides the necessary functions and modifiers to facilitate cross-chain communication, ensuring secure and reliable message delivery between contracts on different networks.

Basic concepts

bytes64

bytes64 is a custom data type used to represent addresses that may vary in length across different networks.

struct bytes64 {
bytes32 lower;
bytes32 upper;
}

For EVM Networks, the lower part of the bytes64 structure signifies the actual account, while the upper part is disregarded.

EquitoMessage

EquitoMessage is a struct that contains all the necessary information about a message being sent or received.

struct EquitoMessage {
uint256 blockNumber; // Block number at which the message is emitted.
uint256 sourceChainSelector; // Selector for the source chain, acting as an id.
bytes64 sender; // Address of the sender.
uint256 destinationChainSelector; // Selector for the destination chain, acting as an id.
bytes64 receiver; // Address of the receiver.
bytes32 hashedData; // Hash of the payload of the message to be delivered.
}

Step 1: Define Events and Errors

In your contract, define the events and errors as follows:

/// @notice Event emitted when a ping message is sent.
/// @param destinationChainSelector The identifier of the destination chain.
/// @param messageHash The ping message hash.
event PingSent(
uint256 indexed destinationChainSelector,
bytes32 messageHash
);

/// @notice Event emitted when a ping message is received.
/// @param sourceChainSelector The identifier of the source chain.
/// @param messageHash The ping message hash.
event PingReceived(
uint256 indexed sourceChainSelector,
bytes32 messageHash
);

/// @notice Event emitted when a pong message is sent.
/// @param destinationChainSelector The identifier of the destination chain.
/// @param messageHash The pong message hash.
event PongSent(
uint256 indexed destinationChainSelector,
bytes32 messageHash
);

/// @notice Event emitted when a pong message is received.
/// @param sourceChainSelector The identifier of the source chain.
/// @param messageHash The pong message hash.
event PongReceived(
uint256 indexed sourceChainSelector,
bytes32 messageHash
);

/// @notice Thrown when attempting to set the router, but the router is already set.
error RouterAlreadySet();

/// @notice Thrown when an invalid message type is encountered.
error InvalidMessageType();

Step 2: Initialization

In the constructor, specify the address of the Router Contract for the chain you are deploying your application on. The EquitoApp contract will automatically call Ownable(msg.sender), setting the contract deployer as the owner. For a list of Router Contracts in the supported networks, please refer to the specific documentation.

constructor(address _router) EquitoApp(_router) {}

Step 3: Storing the peers

Developing a cross-chain application requires precise message routing between the various contracts deployed on multiple networks. A correct message exchange between these contracts entails not only ensuring that messages are directed to the right contracts on different networks but also verifying that messages originate from legitimate senders on other networks, for reasons that range from application logic to security.

This process is simplified by maintaining a mapping of contract addresses on various networks, called "peers." Managed by the contract owner, peers act as counterparts for the contract on other chains, ensuring a smooth integration of multiple networks. Despite being a trivial operation, it's a common practice, therefore it's been implemented in the EquitoApp abstract contract.

Each network in Equito is identified by a unique Chain Selector. To set the peers, the owner would call the setPeers function with arrays of Chain Selectors and corresponding addresses in bytes64. For example, if you have a cross-chain application deployed on Ethereum and Polygon, each of these contracts should know the address of its counterpart:

uint256[] memory chainSelectors = new uint256[](2);
chainSelectors[0] = 1; // Ethereum
chainSelectors[1] = 3; // Polygon

bytes64[] memory addresses = new bytes64[](2);
addresses[0] = EquitoMessageLibrary.addressToBytes64(address(ethereumContract));
addresses[1] = EquitoMessageLibrary.addressToBytes64(address(polygonContract));

setPeers(chainSelectors, addresses);

By maintaining this mapping, your contract will be able to send messages to the correct peer contracts on other networks and verify the source of incoming messages.

Step 4: Sending a message

Once you've established your application logic, you're ready to initiate cross-chain communication by sending a message to another contract on a different network using the sendMessage function. In this example, we'll demonstrate sending a "ping" message to a peer contract on the destination network.

/// @notice Sends a ping message to the specified address on another chain.
/// @param destinationChainSelector The identifier of the destination chain.
/// @param message The ping message.
function sendPing(
uint256 destinationChainSelector,
string calldata message
) external payable {
bytes memory data = abi.encode("ping", message);

bytes32 messageHash = router.sendMessage{value: msg.value}(
getPeer(destinationChainSelector),
destinationChainSelector,
data
);
emit PingSent(destinationChainSelector, messageHash);
}

Under the hood, this calls the sendMessage function on the Router Contract, which will create a new message and emit a MessageSent event. The signature of the function is as follows:

/// @notice Sends a cross-chain message using Equito.
/// @param receiver The address of the receiver.
/// @param destinationChainSelector The chain selector of the destination chain.
/// @param data The message data.
/// @return The hash of the message.
function sendMessage(
bytes64 calldata receiver,
uint256 destinationChainSelector,
bytes calldata data
) external payable returns (bytes32);

Upon successful execution, sendMessage returns a unique message hash, acting as an identifier for the message within the Equito protocol. It's essential to store this hash, as it will be required for tracking and processing the message during delivery.

To determine fees, the caller can request fee information from either the ECDSAVerifier contract, which implements the IEquitoFees interface, or directly from the Router contract. The getFee function retrieves the fee amount required to send a message, and the caller can then proceed to pay the fee using the payFee function.

interface IEquitoFees {
/// @notice Gets the current fee amount required to send a Message.
/// @param sender The address of the Message Sender, usually an Equito App.
/// @return The current fee amount in wei.
function getFee(address sender) external view returns (uint256);

/// @notice Pays the fee. This function should be called with the fee amount sent as msg.value.
/// @param sender The address of the Message Sender, usually an Equito App.
function payFee(address sender) external payable;
}

Step 5: Receiving a message

Upon sending a message from your Equito App, the Router contract facilitates the delivery of the message to the designated recipient contract. Handling incoming messages securely is crucial for the reliability and integrity of your cross-chain communication. Equito provides mechanisms to receive and process messages, distinguishing between messages from trusted peer contracts and those from non-peer contracts.

Handling messages from peers

Messages received from trusted peer contracts are processed through the _receiveMessageFromPeer function in your Equito App. This function is specifically designed to handle messages from known and trusted sources. By overriding this function, you can implement custom logic to process incoming messages effectively. This function is where the Atomic Receive-Send feature is implemented, allowing your contract to send a response message in the same transaction as the received message. When executing such messages, you should always remembers to pay the fees to request a new message send.

/// @notice The logic for receiving a cross-chain message from a peer.
/// @param message The Equito message received.
/// @param messageData The data of the message received.
function _receiveMessageFromPeer(
EquitoMessage calldata message,
bytes calldata messageData
) internal override {
(string memory messageType, string memory payload) = abi.decode(
messageData,
(string, string)
);

if (keccak256(bytes(messageType)) == keccak256(bytes("ping"))) {
emit PingReceived(
message.sourceChainSelector,
keccak256(abi.encode(message))
);

// send pong
bytes memory data = abi.encode("pong", payload);
bytes32 messageHash = router.sendMessage{value: msg.value}(
getPeer(message.sourceChainSelector),
message.sourceChainSelector,
data
);
emit PongSent(message.sourceChainSelector, messageHash);
} else if (keccak256(bytes(messageType)) == keccak256(bytes("pong"))) {
emit PongReceived(
message.sourceChainSelector,
keccak256(abi.encode(message))
);
} else {
revert InvalidMessageType();
}
}

Within this function, you can implement logic to validate the received message, update contract state, emit events, or send response messages. Handling messages from peers ensures that your Equito App interacts securely with known counterparts on other networks.

The messageData parameter contains the payload data of the message. You can use the sourceChainSelector and sender address to verify the origin of the message. Finally, you can decode the messageData payload to extract the message content.

Handling messages from non-peers

Equito also allows handling messages from non-peer contracts, by providing the _receiveMessageFromNonPeer function.

In this example, we only need to override the _receiveMessageFromPeer function because we are dealing with a trusted peer-to-peer interaction. However, if your application requires handling messages from non-peers, you would override the _receiveMessageFromNonPeer function as well.

/// @notice The logic for receiving a cross-chain message from a non-peer.
/// The default implementation reverts the transaction.
/// @param message The Equito message received.
/// @param messageData The data of the message received.
function _receiveMessageFromNonPeer(EquitoMessage calldata message, bytes calldata messageData) internal override {
revert("Unexpected message from non-peer contract");
}