This post explored the cross-chain helpers introduced in OpenZeppelin Contracts 4.6 in 2022. Those particular helpers were removed in 5.0. Current Contracts 5.x has different ERC-7786 based contracts; swapping the import version will not migrate this example.
Let’s keep the original Polygon example useful for understanding the older design, with its dependencies and limitations spelled out. Its adapters covered:

What are the Difficulties with CrossChain?
Why send data between chains? A governance contract on Ethereum might need to change a setting on Polygon. Straightforward request. Slightly more paperwork than a normal function call.
- Who delivered it? The destination usually sees a bridge or system contract as
msg.sender. Use that bridge’s authenticated sender mechanism; a sender address included in arbitrary bytes is not evidence. - Who may act? Check the trusted bridge, source chain and remote application. Identical contract addresses on two chains do not imply identical owners or permissions. The remote application also needs its own caller policy.
- Can it be reused? For signatures, EIP-712 gives structured encoding and domain separation. Include the intended domain and enforce application nonces or consumed-message tracking where needed. EIP-712 does not add replay protection for you.
How the OZ CrossChain Support works
The 4.x design uses CrossChainEnabled as its common abstraction. AccessControlCrossChain then checks separate role aliases for remote calls.
Below are excerpts of the adapter mechanisms, not standalone receivers. Each depends on its adapter authenticating the bridge first. See the versioned 4.x API for the surrounding checks.
function processMessageFromRoot(
uint256, /* stateId */
address rootMessageSender,
bytes calldata data
)AMB_Bridge(bridge).messageSender()LibArbitrumL2.crossChainSender(LibArbitrumL2.ARBSYS)Optimism_Bridge(messenger).xDomainMessageSender()The original 4.6.0 sources preserve the implementation discussed here. Current development should start with the current API and the selected bridge’s documentation.
How to use it - Polygon Example
Let’s look at the old Polygon FxPortal flow. There are two separate jobs: authenticate the remote contract, then authorize what that contract is allowed to request.
The original root example was missing its caller restriction. The corrected version below restricts both sending and tunnel setup to the deploying administrator. The child grants the remote root only the number-setting role.
1. Creating the Root Contract
The root-side contract inherits the pinned historical FxBaseRootTunnel. Its checkpoint manager and FxRoot addresses are constructor parameters.
The old Goerli addresses are no longer useful deployment instructions. For any new integration, obtain the correct bridge deployments from Polygon’s documentation and verify the source and destination networks. An RPC endpoint and a bridge address are different things.
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.
// SPDX-License-Identifier: MIT
// Historical API example; not a current production deployment template.
pragma solidity 0.8.13;
import {FxBaseRootTunnel} from "https://github.com/0xPolygon/fx-portal/blob/baed24d22178201bca33140c303e0925661ec0ac/contracts/tunnel/FxBaseRootTunnel.sol";
contract PolygonRoot is FxBaseRootTunnel {
address public immutable administrator;
bytes public latestData;
constructor(address checkpointManager_, address fxRoot_)
FxBaseRootTunnel(checkpointManager_, fxRoot_)
{
require(checkpointManager_ != address(0) && fxRoot_ != address(0), "zero bridge");
administrator = msg.sender;
}
modifier onlyAdministrator() {
require(msg.sender == administrator, "not administrator");
_;
}
function setFxChildTunnel(address child)
public override onlyAdministrator
{
require(child != address(0), "zero child");
super.setFxChildTunnel(child);
}
function sendMessageToChild(bytes calldata message)
external onlyAdministrator
{
require(fxChildTunnel != address(0), "child not configured");
_sendMessageToChild(message);
}
function _processMessageFromChild(bytes memory data) internal override {
latestData = data;
}
}// SPDX-License-Identifier: MIT
// Historical API example; not a current production deployment template.
pragma solidity 0.8.13;
import {CrossChainEnabledPolygonChild} from "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.6.0/contracts/crosschain/polygon/CrossChainEnabledPolygonChild.sol";
import {AccessControlCrossChain} from "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.6.0/contracts/access/AccessControlCrossChain.sol";
contract PolygonChild is CrossChainEnabledPolygonChild, AccessControlCrossChain {
bytes32 public constant SET_NUMBER_ROLE = keccak256("SET_NUMBER_ROLE");
uint256 public myNumber = 12;
event MessageSent(bytes message);
constructor(address rootParent, address fxChild_)
CrossChainEnabledPolygonChild(fxChild_)
{
require(rootParent != address(0) && fxChild_ != address(0), "zero bridge or parent");
_grantRole(_crossChainRoleAlias(SET_NUMBER_ROLE), rootParent);
}
function setNumberForParentChain(uint256 newNumber)
external onlyRole(SET_NUMBER_ROLE)
{
myNumber = newNumber;
}
function getEncodedSetNumberData(uint256 newNumber)
external pure returns (bytes memory)
{
return abi.encodeWithSelector(PolygonChild.setNumberForParentChain.selector, newNumber);
}
// An application can call this after its own authorization checks.
// Emitting the event does not itself execute a root-chain transaction.
function _sendMessageToRoot(bytes memory message) internal {
emit MessageSent(message);
}
}2. Creating the Child Contract
The child lives on Polygon PoS and inherits CrossChainEnabledPolygonChild 4.6.0. Supply the trusted FxChild address and the intended root application explicitly.
The Mumbai address in the original version belonged to a retired testnet. Today’s Polygon PoS testnet is Amoy, with Sepolia as its parent. That does not make this old library a current Amoy integration recipe.
AccessControlCrossChain separates a local role from its remote alias. During an authenticated bridge call, onlyRole(SET_NUMBER_ROLE) checks the remote sender against _crossChainRoleAlias(SET_NUMBER_ROLE).
Here only the chosen root contract gets that alias. We do not grant DEFAULT_ADMIN_ROLE just to change one number. This small example deliberately has no role-rotation or administrator-transfer flow.
That authenticates the root contract. Its onlyAdministrator check supplies the upstream permission rule; the bridge does not decide that rule for us.
setNumberForParentChain changes the number after the role check. The optional _sendMessageToRoot helper illustrates the reverse path’s event.
Emitting MessageSent is only the child-side step. Processing it on the root also needs the matching receipt and checkpoint proofs; the event alone does not call _processMessageFromChild.
3. Get Encoded Data Helper
The child example already includes this optional encoding helper. It produces the calldata for its number setter:
function getEncodedSetNumberData(uint256 newNumber) external pure returns (bytes memory) {
return abi.encodeWithSelector(PolygonChild.setNumberForParentChain.selector, newNumber);
}Those bytes identify setNumberForParentChain and its argument. The authorized root caller can use them with sendMessageToChild. A frontend can produce the same bytes with its ABI encoder.
Encoding a call does not authorize it. Think of it as writing the address on an envelope; it is not a signature from the landlord.
4. The historical example and what to test
The original walkthrough used Goerli and Mumbai. Those deployment steps are retired. The complete historical example below keeps Solidity 0.8.13, pins OpenZeppelin 4.6.0 and pins FxPortal to a commit instead of importing changing branches.
It explains the old API and fixes the missing application checks. It is not a current production bridge template, and this review did not exercise it across two live chains.
// SPDX-License-Identifier: MIT
// Historical API example; not a current production deployment template.
pragma solidity 0.8.13;
import {CrossChainEnabledPolygonChild} from "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.6.0/contracts/crosschain/polygon/CrossChainEnabledPolygonChild.sol";
import {AccessControlCrossChain} from "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.6.0/contracts/access/AccessControlCrossChain.sol";
import {FxBaseRootTunnel} from "https://github.com/0xPolygon/fx-portal/blob/baed24d22178201bca33140c303e0925661ec0ac/contracts/tunnel/FxBaseRootTunnel.sol";
contract PolygonChild is CrossChainEnabledPolygonChild, AccessControlCrossChain {
bytes32 public constant SET_NUMBER_ROLE = keccak256("SET_NUMBER_ROLE");
uint256 public myNumber = 12;
event MessageSent(bytes message);
constructor(address rootParent, address fxChild_)
CrossChainEnabledPolygonChild(fxChild_)
{
require(rootParent != address(0) && fxChild_ != address(0), "zero bridge or parent");
_grantRole(_crossChainRoleAlias(SET_NUMBER_ROLE), rootParent);
}
function setNumberForParentChain(uint256 newNumber)
external onlyRole(SET_NUMBER_ROLE)
{
myNumber = newNumber;
}
function getEncodedSetNumberData(uint256 newNumber)
external pure returns (bytes memory)
{
return abi.encodeWithSelector(PolygonChild.setNumberForParentChain.selector, newNumber);
}
// An application can call this after its own authorization checks.
// Emitting the event does not itself execute a root-chain transaction.
function _sendMessageToRoot(bytes memory message) internal {
emit MessageSent(message);
}
}
contract PolygonRoot is FxBaseRootTunnel {
address public immutable administrator;
bytes public latestData;
constructor(address checkpointManager_, address fxRoot_)
FxBaseRootTunnel(checkpointManager_, fxRoot_)
{
require(checkpointManager_ != address(0) && fxRoot_ != address(0), "zero bridge");
administrator = msg.sender;
}
modifier onlyAdministrator() {
require(msg.sender == administrator, "not administrator");
_;
}
function setFxChildTunnel(address child)
public override onlyAdministrator
{
require(child != address(0), "zero child");
super.setFxChildTunnel(child);
}
function sendMessageToChild(bytes calldata message)
external onlyAdministrator
{
require(fxChildTunnel != address(0), "child not configured");
_sendMessageToChild(message);
}
function _processMessageFromChild(bytes memory data) internal override {
latestData = data;
}
}Before adapting this pattern, test it with local bridge fixtures:
- Only the configured administrator can set the child tunnel or send a request.
- The child accepts the intended bridge and remote root, with the narrow setter role.
- Local calls without a local role do not gain the remote role.
- Invalid messages fail without changing application state.
- If using the reverse path, validate checkpoint, receipt and replay behavior for the chosen bridge version.
Then follow the bridge’s current integration guide for a supported network pair. The old Mumbai explorer links are not a deployment debugger anymore.


In my original 2022 tests, delivery took between 2 and 25 minutes. That is a historical observation, not a current latency guarantee. Bridge processing and confirmation rules depend on the network and direction.
And that’s the useful part of this example: separate message delivery, remote identity and application permissions. Three checks, two chains, one number. Distributed systems do like to make us work for 42.
For a new project, start with the current OpenZeppelin cross-chain API and your chosen bridge’s documentation rather than deploying these historical dependencies.





Join the conversation
Comments are hosted by Disqus and load only when you choose to enable them.