Create Cross-Chain Contracts
Introduction
Wormhole's cross-chain messaging allows smart contracts to interact seamlessly across multiple blockchains. This enables developers to build decentralized applications that leverage the strengths of different networks, whether it's Avalanche, Celo, Ethereum, or beyond. In this tutorial, we'll explore using Wormhole's Solidity SDK to create cross-chain contracts to send and receive messages across chains.
Wormhole's messaging infrastructure simplifies data transmission, event triggering, and transaction initiation across blockchains. In this tutorial, we'll guide you through a simple yet powerful hands-on demonstration that showcases this practical capability. We'll deploy contracts on two Testnets—Avalanche Fuji and Celo Alfajores—and send messages from one chain to another. This tutorial is perfect for those new to cross-chain development and seeking hands-on experience with Wormhole's powerful toolkit.
By the end of this tutorial, you will have not only built a fully functioning cross-chain message sender and receiver using Solidity but also developed a comprehensive understanding of how to interact with the Wormhole relayer, manage cross-chain costs, and ensure your smart contracts are configured correctly on both source and target chains.
This tutorial assumes a basic understanding of Solidity and smart contract development. Before diving in, it may be helpful to review the basics of Wormhole to familiarize yourself with the protocol.
Wormhole Overview
We'll interact with two key Wormhole components: the Wormhole relayer and the Wormhole Core Contracts. The relayer handles cross-chain message delivery and ensures the message is accurately received on the target chain. This allows smart contracts to communicate across blockchains without developers worrying about the underlying complexity.
Additionally, we'll rely on the Wormhole relayer to automatically determine cross-chain transaction costs and facilitate payments. This feature simplifies cross-chain development by allowing you to specify only the target chain and the message. The relayer handles the rest, ensuring that the message is transmitted with the appropriate fee.
Prerequisites
Before starting this tutorial, ensure you have the following:
- Node.js and npm installed on your machine
- Foundry for deploying contracts
- Testnet tokens for Avalanche-Fuji and Celo-Alfajores to cover gas fees
- Wallet private key
Build Cross-Chain Messaging Contracts
In this section, we'll deploy two smart contracts: one to send a message from Avalanche Fuji and another to receive it on Celo Alfajores. The contracts interact with the Wormhole relayer to transmit messages across chains.
At a high level, our contracts will:
- Send a message from Avalanche to Celo using the Wormhole relayer
- Receive and process the message on Celo, logging the content of the message
Before diving into the deployment steps, let's first break down key parts of the contracts.
Sender Contract: MessageSender
The MessageSender
contract is responsible for quoting the cost of sending a message cross-chain and then sending that message.
Key functions include:
quoteCrossChainCost
- calculates the cost of delivering a message to the target chain using the Wormhole relayersendMessage
- encodes the message and sends it to the target chain and contract address using the Wormhole relayer
Here's the core of the contract:
function sendMessage(
uint16 targetChain,
address targetAddress,
string memory message
) external payable {
uint256 cost = quoteCrossChainCost(targetChain);
require(
msg.value >= cost,
"Insufficient funds for cross-chain delivery"
);
wormholeRelayer.sendPayloadToEvm{value: cost}(
targetChain,
targetAddress,
abi.encode(message, msg.sender),
0,
GAS_LIMIT
);
}
You can find the full code for the MessageSender.sol
below.
MessageSender.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import "lib/wormhole-solidity-sdk/src/interfaces/IWormholeRelayer.sol";
contract MessageSender {
IWormholeRelayer public wormholeRelayer;
uint256 constant GAS_LIMIT = 50000;
constructor(address _wormholeRelayer) {
wormholeRelayer = IWormholeRelayer(_wormholeRelayer);
}
function quoteCrossChainCost(
uint16 targetChain
) public view returns (uint256 cost) {
(cost, ) = wormholeRelayer.quoteEVMDeliveryPrice(
targetChain,
0,
GAS_LIMIT
);
}
function sendMessage(
uint16 targetChain,
address targetAddress,
string memory message
) external payable {
uint256 cost = quoteCrossChainCost(targetChain);
require(
msg.value >= cost,
"Insufficient funds for cross-chain delivery"
);
wormholeRelayer.sendPayloadToEvm{value: cost}(
targetChain,
targetAddress,
abi.encode(message, msg.sender),
0,
GAS_LIMIT
);
}
}
Receiver Contract: MessageReceiver
The MessageReceiver
contract handles incoming cross-chain messages. When a message arrives, it decodes the payload and logs the message content. It ensures that only authorized contracts can send and process messages, adding an extra layer of security in cross-chain communication.
Emitter Validation and Registration
In cross-chain messaging, validating the sender is essential to prevent unauthorized contracts from sending messages. The isRegisteredSender
modifier ensures that messages can only be processed if they come from the registered contract on the source chain. This guards against malicious messages and enhances security.
Key implementation details include:
registeredSender
- stores the address of the registered sender contractsetRegisteredSender
- registers the sender's contract address on the source chain. It ensures that only registered contracts can send messages, preventing unauthorized sendersisRegisteredSender
- restricts the processing of messages to only those from registered senders, preventing unauthorized cross-chain communication
mapping(uint16 => bytes32) public registeredSenders;
modifier isRegisteredSender(uint16 sourceChain, bytes32 sourceAddress) {
require(
registeredSenders[sourceChain] == sourceAddress,
"Not registered sender"
);
_;
}
function setRegisteredSender(
uint16 sourceChain,
bytes32 sourceAddress
) public {
require(
msg.sender == registrationOwner,
"Not allowed to set registered sender"
);
registeredSenders[sourceChain] = sourceAddress;
}
Message Processing
The receiveWormholeMessages
is the core function that processes the received message. It checks that the Wormhole relayer sent the message, decodes the payload, and emits an event with the message content. It is essential to verify the message sender to prevent unauthorized messages.
function receiveWormholeMessages(
bytes memory payload,
bytes[] memory,
bytes32 sourceAddress,
uint16 sourceChain,
bytes32
) public payable override isRegisteredSender(sourceChain, sourceAddress) {
require(
msg.sender == address(wormholeRelayer),
"Only the Wormhole relayer can call this function"
);
// Decode the payload to extract the message
string memory message = abi.decode(payload, (string));
// Example use of sourceChain for logging
if (sourceChain != 0) {
emit SourceChainLogged(sourceChain);
}
// Emit an event with the received message
emit MessageReceived(message);
}
You can find the full code for the MessageReceiver.sol
below.
MessageReceiver.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import "lib/wormhole-solidity-sdk/src/interfaces/IWormholeRelayer.sol";
import "lib/wormhole-solidity-sdk/src/interfaces/IWormholeReceiver.sol";
contract MessageReceiver is IWormholeReceiver {
IWormholeRelayer public wormholeRelayer;
address public registrationOwner;
// Mapping to store registered senders for each chain
mapping(uint16 => bytes32) public registeredSenders;
event MessageReceived(string message);
event SourceChainLogged(uint16 sourceChain);
constructor(address _wormholeRelayer) {
wormholeRelayer = IWormholeRelayer(_wormholeRelayer);
registrationOwner = msg.sender; // Set contract deployer as the owner
}
modifier isRegisteredSender(uint16 sourceChain, bytes32 sourceAddress) {
require(
registeredSenders[sourceChain] == sourceAddress,
"Not registered sender"
);
_;
}
function setRegisteredSender(
uint16 sourceChain,
bytes32 sourceAddress
) public {
require(
msg.sender == registrationOwner,
"Not allowed to set registered sender"
);
registeredSenders[sourceChain] = sourceAddress;
}
// Update receiveWormholeMessages to include the source address check
function receiveWormholeMessages(
bytes memory payload,
bytes[] memory,
bytes32 sourceAddress,
uint16 sourceChain,
bytes32
) public payable override isRegisteredSender(sourceChain, sourceAddress) {
require(
msg.sender == address(wormholeRelayer),
"Only the Wormhole relayer can call this function"
);
// Decode the payload to extract the message
string memory message = abi.decode(payload, (string));
// Example use of sourceChain for logging
if (sourceChain != 0) {
emit SourceChainLogged(sourceChain);
}
// Emit an event with the received message
emit MessageReceived(message);
}
}
Deploy Contracts
This section will guide you through deploying the cross-chain messaging contracts on the Avalanche Fuji and Celo Alfajores Testnets. Follow these steps to get your contracts up and running.
Deployment Tools
We use Foundry to deploy our smart contracts. However, you can use any tool you're comfortable with, such as:
- Remix for a browser-based IDE
- Hardhat for a more extensive JavaScript/TypeScript workflow
- Foundry for a CLI-focused experience with built-in scripting and testing features
The contracts and deployment steps remain the same regardless of your preferred tool. The key is to ensure you have the necessary Testnet funds and are deploying to the right networks.
Repository Setup
To get started with cross-chain messaging using Wormhole, first clone the GitHub repository. This repository includes everything you need to deploy, interact, and test the message flow between chains.
This demo focuses on using the scripts, so it's best to take a look at them, starting with deploySender.js
, deployReceiver.js
, and sendMessage.js
.
To configure the dependencies properly, run the following command:
The repository includes:
-
Two Solidity contracts:
MessageSender.sol
- contract that sends the cross-chain message from AvalancheMessageReceiver.sol
- contract that receives the cross-chain message on Celo
-
Deployment scripts located in the
script
directory:deploySender.js
- deploys the MessageSender contract to AvalanchedeployReceiver.js
- deploys the MessageReceiver contract to CelosendMessage.js
- sends a message from Avalanche to Celo
-
Configuration files and ABI JSON files for easy deployment and interaction:
chains.json
- configuration file that stores key information for the supported Testnets, including the Wormhole relayer addresses, RPC URLs, and chain IDs. You likely won't need to modify this file unless you're working with different networks
Important Setup Steps
-
Add your private key - create a
.env
file in the root of the project and add your private key:Inside
.env
, add your private key in the following format: -
Compile the contracts - ensure everything is set up correctly by compiling the contracts:
The expected output should be similar to this:
Deployment Process
Both deployment scripts, deploySender.js
and deployReceiver.js
, perform the following key tasks:
-
Load configuration and contract details - each script begins by loading the necessary configuration details, such as the network's RPC URL and the contract's ABI and bytecode. This information is essential for deploying the contract to the correct blockchain network
{ "chains": [ { "description": "Avalanche testnet fuji", "chainId": 6, "rpc": "https://api.avax-test.network/ext/bc/C/rpc", "tokenBridge": "0x61E44E506Ca5659E6c0bba9b678586fA2d729756", "wormholeRelayer": "0xA3cF45939bD6260bcFe3D66bc73d60f19e49a8BB", "wormhole": "0x7bbcE28e64B3F8b84d876Ab298393c38ad7aac4C" }, { "description": "Celo Testnet", "chainId": 14, "rpc": "https://alfajores-forno.celo-testnet.org", "tokenBridge": "0x05ca6037eC51F8b712eD2E6Fa72219FEaE74E153", "wormholeRelayer": "0x306B68267Deb7c5DfCDa3619E22E9Ca39C374f84", "wormhole": "0x88505117CA88e7dd2eC6EA1E13f0948db2D50D56" } ] }
Note
The
chains.json
file contains the configuration details for the Avalanche Fuji and Celo Alfajores Testnets. You can modify this file to add more networks if needed. For a complete list of contract addresses, visit the reference page. -
Set up provider and wallet - the scripts establish a connection to the blockchain using a provider and create a wallet instance using a private key. This wallet is responsible for signing the deployment transaction
-
Deploy the contract - the contract is deployed to the network specified in the configuration. Upon successful deployment, the contract address is returned, which is crucial for interacting with the contract later on
-
Register the
MessageSender
on the target chain - after you deploy theMessageReceiver
contract on the Celo Alfajores network, the sender contract address from Avalanche Fuji needs to be registered. This ensures that only messages from the registeredMessageSender
contract are processedThis additional step is essential to enforce emitter validation, preventing unauthorized senders from delivering messages to the
MessageReceiver
contract// Retrieve the address of the MessageSender from the deployedContracts.json file const avalancheSenderAddress = deployedContracts.avalanche.MessageSender; // Define the source chain ID for Avalanche Fuji const sourceChainId = 6; // Call setRegisteredSender on the MessageReceiver contract const tx = await receiverContract.setRegisteredSender( sourceChainId, ethers.zeroPadValue(avalancheSenderAddress, 32) ); await tx.wait();
You can find the full code for the deploySender.js
and deployReceiver.js
below.
deploySender.js
const { ethers } = require('ethers');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
async function main() {
// Load the chain configuration from JSON
const chains = JSON.parse(
fs.readFileSync(path.resolve(__dirname, '../deploy-config/chains.json'))
);
// Get the Avalanche Fuji configuration
const avalancheChain = chains.chains.find((chain) =>
chain.description.includes('Avalanche testnet')
);
// Set up the provider and wallet
const provider = new ethers.JsonRpcProvider(avalancheChain.rpc);
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
// Load the ABI and bytecode of the MessageSender contract
const messageSenderJson = JSON.parse(
fs.readFileSync(
path.resolve(__dirname, '../out/MessageSender.sol/MessageSender.json'),
'utf8'
)
);
const abi = messageSenderJson.abi;
const bytecode = messageSenderJson.bytecode;
// Create a ContractFactory for MessageSender
const MessageSender = new ethers.ContractFactory(abi, bytecode, wallet);
// Deploy the contract using the Wormhole Relayer address for Avalanche Fuji
const senderContract = await MessageSender.deploy(
avalancheChain.wormholeRelayer
);
await senderContract.waitForDeployment();
console.log('MessageSender deployed to:', senderContract.target);
// Update the deployedContracts.json file
const deployedContractsPath = path.resolve(
__dirname,
'../deploy-config/deployedContracts.json'
);
const deployedContracts = JSON.parse(
fs.readFileSync(deployedContractsPath, 'utf8')
);
deployedContracts.avalanche = {
MessageSender: senderContract.target,
deployedAt: new Date().toISOString(),
};
fs.writeFileSync(
deployedContractsPath,
JSON.stringify(deployedContracts, null, 2)
);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
deployReceiver.js
const { ethers } = require('ethers');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
async function main() {
// Load the chain configuration from the JSON file
const chains = JSON.parse(
fs.readFileSync(path.resolve(__dirname, '../deploy-config/chains.json'))
);
// Get the Celo Testnet configuration
const celoChain = chains.chains.find((chain) =>
chain.description.includes('Celo Testnet')
);
// Set up the provider and wallet
const provider = new ethers.JsonRpcProvider(celoChain.rpc);
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
// Load the ABI and bytecode of the MessageReceiver contract
const messageReceiverJson = JSON.parse(
fs.readFileSync(
path.resolve(
__dirname,
'../out/MessageReceiver.sol/MessageReceiver.json'
),
'utf8'
)
);
const abi = messageReceiverJson.abi;
const bytecode = messageReceiverJson.bytecode;
// Create a ContractFactory for MessageReceiver
const MessageReceiver = new ethers.ContractFactory(abi, bytecode, wallet);
// Deploy the contract using the Wormhole Relayer address for Celo Testnet
const receiverContract = await MessageReceiver.deploy(
celoChain.wormholeRelayer
);
await receiverContract.waitForDeployment();
console.log('MessageReceiver deployed to:', receiverContract.target); // `target` is the contract address in ethers.js v6
// Update the deployedContracts.json file
const deployedContractsPath = path.resolve(
__dirname,
'../deploy-config/deployedContracts.json'
);
const deployedContracts = JSON.parse(
fs.readFileSync(deployedContractsPath, 'utf8')
);
// Retrieve the address of the MessageSender from the deployedContracts.json file
const avalancheSenderAddress = deployedContracts.avalanche.MessageSender;
// Define the source chain ID for Avalanche Fuji
const sourceChainId = 6;
// Call setRegisteredSender on the MessageReceiver contract
const tx = await receiverContract.setRegisteredSender(
sourceChainId,
ethers.zeroPadValue(avalancheSenderAddress, 32)
);
await tx.wait();
console.log(
`Registered MessageSender (${avalancheSenderAddress}) for Avalanche chain (${sourceChainId})`
);
deployedContracts.celo = {
MessageReceiver: receiverContract.target,
deployedAt: new Date().toISOString(),
};
fs.writeFileSync(
deployedContractsPath,
JSON.stringify(deployedContracts, null, 2)
);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
Deploy the Sender Contract
The sender contract will handle quoting and sending messages cross-chain.
-
Run the following command to deploy the sender contract:
-
Once deployed, the contract address will be displayed. You may check the contract on the Avalanche Fuji Explorer
Deploy the Receiver Contract
The receiver contract listens for cross-chain messages and logs them when received.
-
Deploy the receiver contract with this command:
-
After deployment, note down the contract address. You may check the contract on the Celo Alfajores Explorer.
Send a Cross-Chain Message
Now that both the sender and receiver contracts are deployed, let's move on to the next exciting step: sending a cross-chain message from Avalanche Fuji to Celo Alfajores.
In this example, we will use the sendMessage.js
script to transmit a message from the sender contract on Avalanche to the receiver contract on Celo. The script uses Ethers.js to interact with the deployed contracts, calculate the cross-chain cost dynamically, and handle the transaction.
Let's break down the script step by step.
-
Load configuration files
chains.json
- contains details about the supported Testnet chains, such as RPC URLs and relayer addressesdeployedContracts.json
- stores the addresses of the deployed sender and receiver contracts. This file is dynamically updated when contracts are deployed, but users can also manually add their own deployed contract addresses if needed
-
Configure the provider and signer - the script first reads the chain configurations and extracts the contract addresses. One essential step in interacting with a blockchain is setting up a provider. A provider is your connection to the blockchain network. It allows your script to interact with the blockchain, retrieve data, and send transactions. In this case, we're using a JSON-RPC provider
Next, we configure the wallet, which will be used to sign transactions. The wallet is created using the private key and the provider. This ensures that all transactions sent from this wallet are broadcast to the Avalanche Fuji network:
const provider = new ethers.JsonRpcProvider(avalancheChain.rpc); const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
After setting up the wallet, the script loads the ABI for the
MessageSender.sol
contract and creates an instance of it: -
Set up the message details - the next part of the script defines the target chain (Celo) and the target address (the receiver contract on Celo):
const targetChain = 14; // Wormhole chain ID for Celo Alfajores const targetAddress = deployedContracts.celo.MessageReceiver;
You can customize the message that will be sent across chains:
-
Estimate cross-chain cost - before sending the message, we dynamically calculate the cross-chain cost using the
quoteCrossChainCost
function:This ensures that the transaction includes enough funds to cover the gas fees for the cross-chain message.
-
Send a message - with everything set up, the message is sent using the
sendMessage
function:const tx = await MessageSender.sendMessage( targetChain, targetAddress, message, { value: txCost, } );
After sending, the script waits for the transaction to be confirmed:
-
Run the script - to send the message, run the following command:
If everything is set up correctly, the message will be sent from the Avalanche Fuji Testnet to the Celo Alfajores Testnet. You can monitor the transaction and verify that the message was received on Celo using the Wormhole Explorer.
The console should output something similar to this:
You can find the full code for the sendMessage.js
below.
sendMessage.js
const { ethers } = require('ethers');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
async function main() {
// Load the chain configuration and deployed contract addresses
const chains = JSON.parse(
fs.readFileSync(path.resolve(__dirname, '../deploy-config/chains.json'))
);
const deployedContracts = JSON.parse(
fs.readFileSync(
path.resolve(__dirname, '../deploy-config/deployedContracts.json')
)
);
console.log(
'Sender Contract Address: ',
deployedContracts.avalanche.MessageSender
);
console.log(
'Receiver Contract Address: ',
deployedContracts.celo.MessageReceiver
);
console.log('...');
// Get the Avalanche Fuji configuration
const avalancheChain = chains.chains.find((chain) =>
chain.description.includes('Avalanche testnet')
);
// Set up the provider and wallet
const provider = new ethers.JsonRpcProvider(avalancheChain.rpc);
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
// Load the ABI of the MessageSender contract
const messageSenderJson = JSON.parse(
fs.readFileSync(
path.resolve(__dirname, '../out/MessageSender.sol/MessageSender.json'),
'utf8'
)
);
const abi = messageSenderJson.abi;
// Create a contract instance for MessageSender
const MessageSender = new ethers.Contract(
deployedContracts.avalanche.MessageSender,
abi,
wallet
);
// Define the target chain and target address (the Celo receiver contract)
const targetChain = 14; // Wormhole chain ID for Celo Alfajores
const targetAddress = deployedContracts.celo.MessageReceiver;
// The message you want to send
const message = 'Hello from Avalanche to Celo!';
// Dynamically quote the cross-chain cost
const txCost = await MessageSender.quoteCrossChainCost(targetChain);
// Send the message (make sure to send enough gas in the transaction)
const tx = await MessageSender.sendMessage(
targetChain,
targetAddress,
message,
{
value: txCost,
}
);
console.log('Transaction sent, waiting for confirmation...');
await tx.wait();
console.log('...');
console.log('Message sent! Transaction hash:', tx.hash);
console.log(
`You may see the transaction status on the Wormhole Explorer: https://wormholescan.io/#/tx/${tx.hash}?network=TESTNET`
);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
Conclusion
You're now fully equipped to build cross-chain contracts using the Wormhole protocol! With this tutorial, you've learned how to:
- Deploy sender and receiver contracts on different Testnets
- Send a cross-chain message from one blockchain to another
- Monitor the status of your cross-chain transactions using the Wormhole Explorer and Wormhole-Solidity-SDK