Exploring the Openzeppelin CrossChain Functionality
What is the new CrossChain support and how can you use it.
For the first time Openzeppelin Contracts have added CrossChain Support. In particular the following chains are currently supported:
- Polygon: One of the most popular sidechains right now. We've discussed it previously here.
- Optimism: A Layer 2 chain based on optimistic rollups. We discussed the tech previously here.
- Arbitrum: A different Layer 2 chain also based on optimistic rollups.
- AMB: The Arbitrary Message Bridge is a general tool one can use to relay any data between two chains.
What are the Difficulties with CrossChain?
So what exactly are the things to consider in CrossChain communication?
- Why even send data to begin with? Well you might have governance-owned contract on Ethereum mainnet for example controlled by a native ERC-20. And you want this contract to be able to change fundamental things, for example upgrade another contract on the child chain. Then you need a way to allow the contract from the mainnet to send data to the child in a secure way.
- The sender problem: One difficulty of sending cross-chain messages is determining the sender address, because from the contract's perspective msg.sender will actually be a child system address. What exactly msg.sender will be of course depends on the child chain, but it won't be the actual sender from the root chain.
- The access control problem: Another issue with CrossChain is the potential for an address existing twice, so be aware that a contract with identical address can exist on child and root chain.
- Signature Double-Use: And if you allow for any signatures in your contract, they might be double used. This is why you should always double check the chain id or better use EIP-712 which handles all the complexities with secure signatures.
How the OZ CrossChain Support works
Openzeppelin has added contracts to support the usage in Polygon, Optimism, Arbitrum and AMB. There is a master interface CrossChainEnabled.sol which all four implementations make use of. And there is a new AccessControlCrossChain.sol to allow for secure access control via roles but with CrossChain support. An overview of it all can be seen here.
Each chain implementation contains a different mechanism to retrieve the original CrossChain sender which roughly looks like this:
function processMessageFromRoot(
uint256, /* stateId */
address rootMessageSender,
bytes calldata data
)
AMB_Bridge(bridge).messageSender()
LibArbitrumL2.crossChainSender(LibArbitrumL2.ARBSYS)
Optimism_Bridge(messenger).xDomainMessageSender()
For the full details, check out the contract code here.
How to use it - Polygon Example
Let's take a deeper dive in how you would actually use this with the example of Polygon.
We'll create contracts on the root and child chain with secure access control.
1. Creating the Root Contract
So first let's create a contract that we will deploy on the root blockchain. In the normal case this would be the Ethereum mainnet. We can inherit from the FxBaseRootTunnel.sol contract and pass check point and root addresses depending on the network:
- GOERLI_CHECKPOINT_MANAGER = 0x2890bA17EfE978480615e330ecB65333b880928e
- GOERLI_FX_ROOT = 0x3d1d3E34f7fB6D26245E6640E1c50710eFFf15bA
- MAINNET_CHECKPOINT_MANAGER = 0x86E4Dc95c7FBdBf52e33D563BbDB00823894C287
- MAINNET_FX_ROOT = 0xfe5e5D361b2ad62c541bAb87C45a0B9B018389a2
It will give you two internal functions to work with
_processMessageFromChild
: Override this to respond to messages sent from the child contract._sendMessageToChild
: Call this to send a message to the child.
import {FxBaseRootTunnel} from
"fx-portal/contracts/tunnel/FxBaseRootTunnel.sol";
// see left for full addresses
address constant GOERLI_CP_MANAGER = 0x2890bA17EfE978480615e...;
address constant GOERLI_FX_ROOT = 0x3d1d3E34f7fB6D26245E6640...;
contract PolygonRoot is FxBaseRootTunnel {
bytes public latestData;
constructor()
FxBaseRootTunnel(GOERLI_CP_MANAGER, GOERLI_FX_ROOT) {
}
function _processMessageFromChild(
bytes memory data
) internal override {
latestData = data;
}
function sendMessageToChild(bytes memory message) public {
_sendMessageToChild(message);
}
}
import {CrossChainEnabledPolygonChild} from
"oz/contracts/crosschain/polygon/CrossChainEnabledPolygonChild.sol";
import {AccessControlCrossChain} from
"oz/contracts/access/AccessControlCrossChain.sol";
address constant MUMBAI_FX_CHILD = 0xCf73231F...; // see right
contract PolygonChild is
CrossChainEnabledPolygonChild,
AccessControlCrossChain
{
event MessageSent(bytes message);
uint256 public myNumber = 12;
constructor(
address rootParent
) CrossChainEnabledPolygonChild(MUMBAI_FX_CHILD) {
_grantRole(
_crossChainRoleAlias(DEFAULT_ADMIN_ROLE),
rootParent
);
}
function setNumberForParentChain(
uint256 newNumber
) external onlyRole(DEFAULT_ADMIN_ROLE) {
myNumber = newNumber;
}
function _sendMessageToRoot(
bytes memory message
) internal {
emit MessageSent(message);
}
}
2. Creating the Child Contract
And then we can create the child contract that we will deploy on the child blockchain. In our case this will be the Polygon network. We can inherit from the Openzeppelin CrossChainEnabledPolygonChild.sol contract and pass FX Portal Child contract depending on the network:
- MUMBAI_FX_CHILD = 0xCf73231F28B7331BBe3124B907840A94851f9f11
- MAINNET_FX_CHILD = 0x8397259c983751DAf40400790063935a11afa28a
And now we can also make use of the AccessControlCrossChain.sol from Openzeppelin. Just inherit from it in the contract and we'll get the usual access control functions along with a new _crossChainRoleAlias
function.
In our example upon deployment we will pass the previously deployed root contract address here and immediately grant it the admin role, but since this is actually a crosschain communication, it works a little differently:
- Of course the onlyRole modifier cannot just check the msg.sender, so instead it uses the CrossChainEnabled.sol interface to determine the actual CrossChain sender.
- And to further prevent access from contracts in the child chain with the same address as in the root chain, we need to distinguish between senders from msg.sender directly or from CrossChain. For that we can grant a specific role using
_crossChainRoleAlias
.
And then let's add a test function setNumberForParentChain
which only the CrossChain root is allowed to call.
And just for completeness, if you wanted to send a message back to the root, in Polygon you could do so by emitting the MessageSent event.
3. Get Encoded Data Helper
This is completely optional, but for our testing you could add an extra function like this:
function getEncodedSetNumberData(uint256 newNumber) external pure returns (bytes memory) {
return abi.encodeWithSelector(PolygonChild.setNumberForParentChain.selector, newNumber);
}
Basically it will return the encoded data if you wanted to call setNumberForParentChain. This data is what you would need to send along in the root contract via sendMessageToChild
. Of course in most setups you would implement this just using Web3.js or whatever frontend framework you're using.
4. Testing the CrossChain Transfer on Remix
// SPDX-License-Identifier: MIT
pragma solidity 0.8.13;
import {CrossChainEnabledPolygonChild} from "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/crosschain/polygon/CrossChainEnabledPolygonChild.sol";
import {AccessControlCrossChain} from "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/AccessControlCrossChain.sol";
import {FxBaseRootTunnel} from "https://github.com/fx-portal/contracts/blob/main/contracts/tunnel/FxBaseRootTunnel.sol";
address constant MUMBAI_FX_CHILD = 0xCf73231F28B7331BBe3124B907840A94851f9f11;
address constant GOERLI_CHECKPOINT_MANAGER = 0x2890bA17EfE978480615e330ecB65333b880928e;
address constant GOERLI_FX_ROOT = 0x3d1d3E34f7fB6D26245E6640E1c50710eFFf15bA;
address constant MAINNET_FX_CHILD = 0x8397259c983751DAf40400790063935a11afa28a;
address constant MAINNET_CHECKPOINT_MANAGER = 0x86E4Dc95c7FBdBf52e33D563BbDB00823894C287;
address constant MAINNET_FX_ROOT = 0xfe5e5D361b2ad62c541bAb87C45a0B9B018389a2;
contract PolygonChild is CrossChainEnabledPolygonChild, AccessControlCrossChain {
uint256 public myNumber;
constructor(address rootParent) CrossChainEnabledPolygonChild(MUMBAI_FX_CHILD) {
_grantRole(_crossChainRoleAlias(DEFAULT_ADMIN_ROLE), rootParent);
myNumber = 12;
}
function setNumberForParentChain(uint256 newNumber) external onlyRole(DEFAULT_ADMIN_ROLE) {
myNumber = newNumber;
}
function getEncodedSetNumberData(uint256 newNumber) external pure returns (bytes memory) {
return abi.encodeWithSelector(PolygonChild.setNumberForParentChain.selector, newNumber);
}
}
contract PolygonRoot is FxBaseRootTunnel {
bytes public latestData;
constructor() FxBaseRootTunnel(GOERLI_CHECKPOINT_MANAGER, GOERLI_FX_ROOT) {}
function _processMessageFromChild(bytes memory data) internal override {
latestData = data;
}
function sendMessageToChild(bytes memory message) public {
_sendMessageToChild(message);
}
}
And now you can
- Switch MetaMask to Goerli and deploy the PolygonRoot.
- Switch MetaMask to Mumbai and copy the address and deploy PolygonChild for the constructor input.
- Switch MetaMask to Goerli and call
setFxChildTunnel
on PolygonRoot passing the child address. - Now encode the data you want to send. For example to set the number for
setNumberForParentChain
as 42, the encoded data would be: 0x21148d91000000000000000000000000000000000000000000000000000000000000002a. The easiest way to get this is viagetEncodedSetNumberData
. - Call sendMessageToChild and pass along the encoded data. This will initiate the CrossChain transfer. It might take a while.
- Wait.... meanwhile you can double check the events from the FX_CHILD here or simply the transfers of the zero address here.
If that looks confusing to you, then that's no surprise. The zero address is a special system address in Polygon which is used to commit the CrossChain transfers. And the transfers also end up in the events list of the child.
In my tests it took anywhere between 2 to 25 minutes until the CrossChain transfer on Polygon was completed.
And that's it!
If you did everything correctly, you can switch back to Mumbai and read the newly set number which should have changed to 42.
Solidity Developer