There are numerous scenarios where it might be necessary for smart contracts on two different blockchains to communicate with each other. A typical example is when we want to move assets, such as ERC20 tokens, from one chain to another. However, this applies to any kind of state transfer as well. Personally, I started working with the AMB because I was curious about how WorldID could be made available on the Gnosis chain. When someone registers in the World network, their identifier is added to a Merkle tree. To support WorldID, it’s sufficient to synchronize the root of this tree between the chains, but this must be done in a highly reliable manner.
Anyone interested in learning more about how WorldID works can read my article on the topic.
Such cross-chain messaging can be implemented through bridges, and each blockchain has its own solution for this. Gnosis's solution is the Arbitrary Message Bridge, or AMB for short.
The AMB is a system composed of smart contracts and external validators. When we want to send a message from an external chain (e.g., Ethereum) to Gnosis, we can do so through the AMB contract deployed on Ethereum.
In the AMB contract's requireToPassMessage
method, we provide the address of the target contract on Gnosis, along with the method we want to invoke and its parameters.
The AMB contract emits a UserRequestForAffirmation
event when the method is called, which is monitored by the bridge validators. If 50% of the validators agree, they forward the data to the AMB contract on Gnosis, which then invokes the specified method on the target contract. This solution ensures that messages cannot be forged, as it would require at least half of the validators to act maliciously.
This process is illustrated in the diagram below, with the white arrows indicating the movement of the message from the Ethereum chain towards the Gnosis chain.
After the theory, let’s see how this works in practice. We’ll create a bridge to send messages from the Sepolia testnet (Ethereum) to the Chiado testnet (Gnosis). The project’s source code is available on GitHub.
First, let’s take a look at the MessageSender
contract, which represents the Ethereum (Sepolia) side of the bridge and is used for sending messages.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
interface IAMB {
function requireToPassMessage(
address _contract,
bytes calldata _data,
uint256 _gas
) external returns (bytes32);
}
contract MessageSender {
IAMB public amb;
address public receiverContract; // Address of the contract on Gnosis Chain
address public owner; // Address of the contract owner
constructor(address _amb) {
amb = IAMB(_amb);
owner = msg.sender; // Set the contract deployer as the owner
}
modifier onlyOwner() {
require(msg.sender == owner, "Caller is not the owner");
_;
}
function setReceiverContract(address _receiverContract) public onlyOwner {
receiverContract = _receiverContract;
}
function sendMessage(string memory _message) public {
require(receiverContract != address(0), "Receiver contract not set");
bytes4 methodSelector = bytes4(keccak256("receiveMessage(string)"));
bytes memory data = abi.encodeWithSelector(methodSelector, _message);
uint256 gasLimit = 200000; // Adjust based on the complexity of receiveMessage on Gnosis Chain
amb.requireToPassMessage(receiverContract, data, gasLimit);
}
}
The contract receives the address of the AMB contract in its constructor. For Sepolia, this address is 0xf2546D6648BD2af6a008A7e7C1542BB240329E11
. The addresses of the various AMB contracts can be found in the Gnosis documentation.
The setReceiverContract
method allows setting the address of the target contract, i.e., the address of the bridge's Gnosis side.
The sendMessage
method is used to send the message. The method's parameter is the message, which is packaged with the signature of the target method using encodeWithSelector
, and then the requireToPassMessage
method of the AMB is called. From this point, the data forwarding is handled by the AMB contract and the validators.
Now, let’s look at the Gnosis side of the bridge, which is implemented by the MessageReceiver
contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
interface IAMB {
function messageSender() external view returns (address);
}
contract MessageReceiver {
IAMB public amb;
address public trustedSender; // Address of the MessageSender contract on Ethereum
event MessageReceived(string message);
constructor(address _amb, address _trustedSender) {
amb = IAMB(_amb);
trustedSender = _trustedSender;
}
function receiveMessage(string memory _message) public {
require(msg.sender == address(amb), "Caller is not the AMB");
require(amb.messageSender() == trustedSender, "Invalid message sender");
emit MessageReceived(_message);
// Implement additional logic to process the received message
}
}
The contract's constructor has two parameters: one is the address of the AMB contract, and the other is the Sepolia address of the MessageSender
contract.
The message is received by the receiveMessage
method. This method can only be called by the AMB. After the call, the method verifies that the message sender is the MessageSender
contract, i.e., the Sepolia side of the bridge. If all checks pass, a MessageReceived
event is emitted.
The following TypeScript code deploys the two contracts mentioned above to the Sepolia and Chiado testnets. The script can be executed using the command npx hardhat run scripts/deploy.ts
.
import { ethers } from "hardhat";
async function main() {
// Deploy MessageSender on Sepolia
const sepoliaProvider = new ethers.providers.JsonRpcProvider(process.env.SEPOLIA_RPC_URL);
const sepoliaWallet = new ethers.Wallet(process.env.PRIVATE_KEY || "", sepoliaProvider);
const MessageSender = await ethers.getContractFactory("MessageSender", sepoliaWallet);
const sender = await MessageSender.deploy("0xf2546D6648BD2af6a008A7e7C1542BB240329E11");
await sender.deployed();
console.log(`MessageSender deployed to Sepolia at: ${sender.address}`);
// Deploy MessageReceiver on Chiado
const chiadoProvider = new ethers.providers.JsonRpcProvider(process.env.CHIADO_RPC_URL);
const chiadoWallet = new ethers.Wallet(process.env.PRIVATE_KEY || "", chiadoProvider);
const MessageReceiver = await ethers.getContractFactory("MessageReceiver", chiadoWallet);
const receiver = await MessageReceiver.deploy("0x8448E15d0e706C0298dECA99F0b4744030e59d7d", sender.address);
await receiver.deployed();
console.log(`MessageReceiver deployed to Chiado at: ${receiver.address}`);
// Update MessageSender with the receiver's address
const tx = await sender.setReceiverContract(receiver.address);
await tx.wait();
console.log(`MessageSender's receiver contract set to: ${receiver.address}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
The first block deploys the MessageSender
to Sepolia. The next block deploys the MessageReceiver
to Chiado, passing the Ethereum address of the sender contract as a parameter. Finally, the setReceiverContract
method is used to set the receiver contract's address in the sender contract.
Once the deployment is complete, we can test it using the sendMessage.ts
script.
import { ethers } from "ethers";
import * as dotenv from "dotenv";
import MessageSenderABI from "../artifacts/contracts/MessageSender.sol/MessageSender.json";
import MessageReceiverABI from "../artifacts/contracts/MessageReceiver.sol/MessageReceiver.json";
dotenv.config();
async function main() {
// Replace with your deployed contract addresses
const messageSenderAddress = process.env.SENDER_CONTRACT as string;
const messageReceiverAddress = process.env.RECEIVER_CONTRACT as string;
// Connect to the Sepolia network
const sepoliaProvider = new ethers.providers.JsonRpcProvider(process.env.SEPOLIA_RPC_URL);
const sepoliaWallet = new ethers.Wallet(process.env.PRIVATE_KEY || "", sepoliaProvider);
// Connect to the deployed MessageSender contract
const messageSender = new ethers.Contract(
messageSenderAddress,
MessageSenderABI.abi,
sepoliaWallet
);
// Send a message
const message = "Hello, Gnosis Chain!";
const tx = await messageSender.sendMessage(message);
await tx.wait();
console.log(`Message sent: ${message}`);
// Connect to the Chiado network
const chiadoProvider = new ethers.providers.JsonRpcProvider(process.env.CHIADO_RPC_URL);
// Connect to the deployed MessageReceiver contract
const messageReceiver = new ethers.Contract(
messageReceiverAddress,
MessageReceiverABI.abi,
chiadoProvider
);
// Listen for the MessageReceived event
messageReceiver.once("MessageReceived", (receivedMessage: string) => {
console.log(`Message received on Chiado: ${receivedMessage}`);
});
console.log("Waiting for the message to be received on Chiado...");
// Keep the script running to listen for the event
await new Promise((resolve) => setTimeout(resolve, 60000 * 20)); // Wait for 20 mins
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
The script calls the sendMessage
method of the MessageSender
on Sepolia, then waits until the MessageReceived
event of the receiver contract on Gnosis is triggered.
That's all. Thanks to Gnosis's AMB solution, we can relatively easily transfer messages between any external chain and Gnosis. This solution can be used for simple state transfers as well as more complex token transfer implementations. Gnosis's own OmniBridge token bridge solution is also built on the AMB.
Happy coding!