Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our exploration of the multi-chain ecosystem.
In our previous lesson, we successfully implemented the first half of a cross-chain communication flow: the sender contract. We learned how to package a message, pay the necessary fees, and dispatch it from a source chain using the on-chain interfaces of both LayerZero and Chainlink CCIP. Our message is now in transit, traveling through the protocol's off-chain infrastructure.
Today, we will complete the circuit. Our goal is to implement the corresponding contract on the destination chain that receives, verifies, and processes that incoming message. You will learn the specific patterns and security checks required to build a robust and secure receiver contract.
The Receiver's Role: A Passive Listener
A common misconception is that a receiver contract actively "listens" for incoming messages. In reality, the architecture is inverted: the messaging protocol's off-chain infrastructure (a relayer for LayerZero, an executor for CCIP) is responsible for delivering the message by making a transaction that calls a specific function on your receiver contract.
This makes the receiver's job more passive, but its responsibilities are critical:
- Implement the Protocol's Interface: Your contract must include a specific, pre-defined function that the protocol's relayer knows to call (e.g.,
_ccipReceiveorlzReceive). - Verify the Caller: The very first check should be to ensure that the entity calling this special function is the official, on-chain contract of the messaging protocol (its Router or Endpoint). This prevents anyone from faking a cross-chain message by calling your contract directly.
- Verify the Source: Your contract must check the metadata of the incoming message to confirm it originated from the expected contract address on the expected source chain.
- Decode and Process: Once all checks pass, your contract can safely decode the message payload and execute its own business logic.
Let's see how this is done in practice with our two protocol examples.
Implementing a Receiver with Chainlink CCIP
In our last lesson, we reviewed a Sender.sol contract from the Base documentation. Now, let's look at its counterpart, Receiver.sol. This example provides a very clean implementation of a dedicated receiver.
Sending messages and tokens from Base to other chains ...
This official documentation from Base provides the code for a Receiver.sol contract designed for CCIP. It clearly illustrates the core components of a CCIP receiver.
Please read the section 'Creating a Receiver contract' and its 'Code walkthrough'. Focus on how the contract inherits from CCIPReceiver and implements the _ccipReceive function to handle the incoming message.
Code Deconstruction: The CCIP Receiver
Let's dissect the key elements from the Receiver.sol example:
-
contract Receiver is CCIPReceiver: The contract inherits fromCCIPReceiver, an application-level contract provided by Chainlink. This inheritance is crucial as it brings in foundational logic, including a modifier that automatically verifies themsg.senderis the legitimate CCIP Router on the current chain. This handles our "Verify the Caller" responsibility for us. -
constructor(address router) CCIPReceiver(router) {}: Just like the sender, the receiver is initialized with the address of the CCIP Router, but this time it's the router on the destination chain. -
function _ccipReceive(Client.Any2EVMMessage memory message) internal override: This is the heart of the receiver. It's the specific function that theCCIPReceiverbase contract expects you to override. The CCIP Router will call your contract, and this function will be executed.- The
messageparameter is a struct of typeClient.Any2EVMMessage. It contains all the information about the cross-chain message. message.messageId: A unique identifier for the CCIP message.message.sourceChainSelector: Theuint64selector of the chain the message came from.message.sender: The address of the sender contract on the source chain, encoded asbytes.message.data: The actual payload we sent, encoded asbytes.
- The
-
_text = abi.decode(message.data, (string));: Here, the contract decodes thebytespayload back into its originalstringformat and stores it in a state variable. This is where you would implement your custom logic based on the received data.
Implementing a Receiver with LayerZero
Now let's revisit the LayerZero example. In the previous lesson, we focused on its sendMsg function. Now, we'll analyze the lzReceive function from the same contract.
LayerZero Tutorial for Beginners
Let's refer back to the LayerZeroDemo1.sol contract from the 'LayerZero Tutorial for Beginners' by Tim4l1f3. This time, we'll focus on the receiving logic.
Review the LayerZeroDemo1.sol contract code again, but this time, pay close attention to the lzReceive function and the require(msg.sender == address(endpoint)); check within it. This is the core of the LayerZero receiver.
Code Deconstruction: The LayerZero Receiver
The LayerZero receiver pattern is slightly different but achieves the same goals.
-
contract LayerZeroDemo1 is ILayerZeroReceiver: The contract implements theILayerZeroReceiverinterface, which mandates the inclusion of thelzReceivefunction. -
function lzReceive(uint16 _srcChainId, bytes memory _from, uint64, bytes memory _payload) external override: This is LayerZero's designated handler function. The LayerZero Relayer will call this function on your contract._srcChainId: The LayerZero-specific ID of the source chain._from: The address of the sender contract on the source chain, packed asbytes._payload: The message payload.
-
require(msg.sender == address(endpoint));: This is a critical and mandatory security check. The contract explicitly verifies that themsg.senderof this call is the official LayerZero Endpoint address on the current chain. Without this, anyone could calllzReceiveand fake an incoming message. -
Decoding and Processing: The contract then processes the
_payloadand updates its state. The_fromaddress can also be used for source verification, as we'll see next.
The Trust Relationship: Connecting Sender and Receiver
A message has arrived, and we've confirmed it came from the protocol's endpoint. But did it come from our sender contract, or some other random contract on the source chain? This is where we must establish a "trust relationship."
Both contracts must know about each other. The sender needs the receiver's address to send the message. The receiver, in turn, needs to whitelist the sender's address to accept messages only from it. This is typically done with an access-controlled function on the receiver contract.
How To Send Cross-Chain Messages With LayerZero | Cross-Chain Tutorial
To visualize this critical configuration step, watch this short segment from a LayerZero tutorial by James Bachini. It clearly shows how two contracts on different chains are configured to trust each other.
Watch the section titled 'Deploying and Trusting Contracts' (from 05:49 to 07:30). Notice how after deploying the contract on two chains (Optimism and Goerli), the developer copies the address from each contract and calls a 'trust' function on the other. This establishes the two-way connection needed for secure communication.
This concept of setting a "trusted remote" is fundamental. Your _ccipReceive or lzReceive function should not just verify the protocol is the caller, but also that the message's origin (message.sender in CCIP, _from in LayerZero) matches the trusted remote address you have configured for that specific source chain.
Test your understanding!
A developer implements a lzReceive function but forgets to include the require(msg.sender == address(endpoint)); check. What is the potential vulnerability?
Show answer
The vulnerability is that any user or contract on the destination chain can call the lzReceive function directly. This allows them to bypass the entire cross-chain messaging protocol and fake an incoming message from any source chain and address they want. If the lzReceive function triggers a privileged action (like minting tokens or transferring ownership), this vulnerability could be catastrophic.
End-to-End Walkthrough
We've now covered all the pieces: the sender, the receiver, and the trust configuration that links them. Let's watch a complete end-to-end demonstration using Chainlink CCIP that ties everything together.
How To Transfer Tokens and Send Data Cross-Chain With Chainlink CCIP
To see the full flow in action, let's watch a detailed walkthrough from Chainlink. This video demonstrates deploying both a sender and a receiver, configuring them to trust each other, and then sending a message from the Sepolia testnet to the Mumbai testnet.
Watch the sections on deploying the messaging contract (from 17:25) and then sending and receiving the data (from 23:54). Pay close attention to this sequence: Deploying the contract on the source chain (Sepolia). On the source contract, whitelisting the destination chain. Deploying the same contract on the destination chain (Mumbai). On the destination contract, whitelisting the source chain and the source contract address. Funding the source contract and sending the message. Finally, calling a view function on the destination contract to see the received message.
Note that for convenience, the video uses a single, large contract that can act as both a sender and a receiver. In many real-world applications, you would separate these into two distinct, specialized contracts as we saw in the Base documentation, which improves clarity and reduces contract size.
Conclusion
We have now successfully completed the round trip of a cross-chain message. By implementing a secure receiver contract, we can confidently process data and trigger actions based on events from another blockchain.
Key Takeaways:
- A receiver contract is activated when a protocol's relayer calls a specific handler function (
_ccipReceive,lzReceive). - It is critical to implement security checks to verify both the
msg.sender(the protocol's on-chain endpoint) and the original source of the message (your sender contract's address). - A "trust relationship" must be configured (e.g., via
setTrustedRemote) on the receiver to whitelist legitimate sender addresses from specific source chains. - Once validated, the
bytespayload can be decoded and used to execute the receiver contract's business logic.
Preview of the Next Lesson:
Manually deploying contracts to two different networks, copying addresses, and calling configuration functions is tedious and error-prone. Now that we understand the on-chain logic, our next step is to automate this entire process. We will write a script to deploy and test the end-to-end cross-chain communication flow, a crucial skill for building robust, production-ready dApps.