Gnosis cross-chain messaging by using Arbitrary Message Bridge (AMB)
Gnosis cross-chain messaging by using Arbitrary Message Bridge (AMB)
Smart contracts on different blockchains often need to communicate, especially when transferring assets such as ERC20 tokens between chains. However, this need extends beyond asset transfers to any state synchronization. My journey with the Arbitrary Message Bridge (AMB) began out of curiosity about enabling WorldID on the Gnosis chain. Upon registration, a user’s identifier is added to a Merkle tree in the World network. Supporting WorldID requires synchronizing the Merkle tree’s root between chains, a process that demands high reliability.
Cross-chain messaging is typically facilitated through bridges, with each blockchain offering its own approach. Gnosis addresses this need with its solution, the Arbitrary Message Bridge (AMB).
The Arbitrary Message Bridge (AMB) is a system that combines smart contracts with external validators. To send a message from an external chain, such as Ethereum, to Gnosis, we use the AMB contract deployed on the Ethereum blockchain.
Using the AMB contract’s requireToPassMessage
method, we specify the address of the target contract on Gnosis, the method to be called, and its corresponding parameters.
When the requireToPassMessage
method is called, the AMB contract emits a UserRequestForAffirmation
event, which is monitored by the bridge validators. Once 50% or more of the validators approve, the data is forwarded to the AMB contract on Gnosis. The contract then executes the specified method on the target contract. This process ensures the integrity of messages, as forging them would require at least half of the validators to act maliciously.
The diagram below illustrates this process, with the white arrows showing the flow of the message from the Ethereum chain to the Gnosis chain.
Now that we’ve covered the theory, let’s see it in action. We’ll set up a bridge to send messages from the Sepolia testnet (Ethereum) to the Chiado testnet (Gnosis). The source code for the project is available on GitHub.
Let’s start by examining the MessageSender
contract, which operates on the Ethereum (Sepolia) side of the bridge and handles message transmission.
// 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 takes the address of the AMB contract as a parameter in its constructor. For the Sepolia testnet, this address is 0xf2546D6648BD2af6a008A7e7C1542BB240329E11
. You can find the addresses for other AMB contracts in the Gnosis documentation.
The setReceiverContract
method is used to specify the address of the target contract on the Gnosis side of the bridge.
The sendMessage
method is responsible for transmitting messages. It takes the message as a parameter, packages it with the target method's signature using encodeWithSelector
, and calls the AMB's requireToPassMessage
method. From there, the AMB contract and its validators handle the data forwarding process.
Next, let’s examine the Gnosis side of the bridge, 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 accepts two parameters: the address of the AMB contract and the Sepolia address of the MessageSender
contract.
The receiveMessage
method handles incoming messages and can only be called by the AMB. Upon being triggered, it verifies that the message sender is the MessageSender
contract, representing the Sepolia side of the bridge. If all validations succeed, the method emits a MessageReceived
event.
The following TypeScript code deploys the MessageSender
and MessageReceiver
contracts to the Sepolia and Chiado testnets. You can run the script 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 section of the script deploys the MessageSender
contract to Sepolia. Next, the MessageReceiver
contract is deployed to Chiado, with the Ethereum address of the sender contract passed as a parameter. Finally, the setReceiverContract
method is called to configure the sender contract with the address of the receiver contract.
After the deployment is complete, the functionality can be tested 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 invokes the sendMessage
method of the MessageSender
contract on Sepolia and then waits for the MessageReceived
event to be triggered by the receiver contract on Gnosis.
That’s it! Thanks to Gnosis’s AMB solution, transferring messages between any external chain and Gnosis becomes relatively straightforward. This approach can handle both simple state transfers and more complex implementations, such as token transfers. In fact, Gnosis’s own OmniBridge token bridge is built on the AMB.