Table of Contents
- Introduction
- Background
- The Problem
- Analyzing the Incompatibility
- Solution: Creating LzAppAdapter.sol
- Implementation Details
- Code Walkthrough
- Testing the Adapter
- Benefits of the Adapter Pattern
- Potential Issues and Considerations
- Conclusion
- References
Introduction
Very recently, I became interested in Chain Abstraction in Web3. You see, I entered the industry in 2020 with my first job as a Technical Writer at Polygon, helping to create documentation on understanding Ethereum scaling. Back then, it was Polygon and a few other scaling solutions, all built on the world’s decentralized computer, Ethereum. That was the state of Ethereum. Today, things are vastly different—everything is evolving rapidly. In the current blockchain landscape, integrating multiple libraries and frameworks is commonplace, but such integrations can sometimes lead to compatibility issues, especially when different libraries have overlapping functionalities or differing implementations. This article delves into a specific compatibility problem encountered when combining LayerZero’s NonblockingLzApp contract with OpenZeppelin’s Ownable contract. We explore the nature of the problem, the underlying causes, and the elegant solution implemented through an adapter pattern.
Background
LayerZero's NonblockingLzApp
LayerZero is an Omnichain Interoperability Protocol designed to facilitate seamless communication and interactions across different blockchain networks. At its core, LayerZero provides contracts like LzApp
and NonblockingLzApp
to handle message passing, ensuring reliability and security in cross-chain transactions.
-
LzApp.sol: This contract serves as a generic receiver implementation, allowing contracts to handle incoming messages from other chains. It inherits from OpenZeppelin's
Ownable
contract to manage ownership privileges. -
NonblockingLzApp.sol: An extension of
LzApp
, this abstract contract modifies the default blocking behavior of message handling. Instead of halting the message pathway upon encountering a failed message, it attempts to catch failures and store them locally for future retries, thereby ensuring non-blocking communication.
OpenZeppelin's Ownable
OpenZeppelin is a widely-adopted library providing secure and community-vetted smart contract components. Among its offerings, the Ownable
contract is fundamental, facilitating the management of ownership and access control within contracts.
- Ownable.sol: This contract module defines an owner account with exclusive access to specific functions. It employs constructor parameters to set the initial owner, which can later be transferred or renounced.
The Problem
When integrating LayerZero's NonblockingLzApp
with OpenZeppelin's Ownable
, a compatibility issue arises due to differing implementations of the Ownable
contract. Specifically:
-
Constructor Parameter Mismatch: LayerZero's
LzApp
inherits from an older version of OpenZeppelin'sOwnable
that does not accept constructor parameters, whereas the latest versions of OpenZeppelin'sOwnable
require the initial owner to be set via constructor arguments. -
Multiple Inheritance Conflicts: Directly inheriting from both
NonblockingLzApp
and the latestOwnable
can lead to inheritance order conflicts and constructor argument mismatches, resulting in compilation errors or unintended behavior.
This incompatibility necessitates an intermediary mechanism to bridge the gap between the two libraries, ensuring smooth integration without sacrificing the functionalities provided by either.
Analyzing the Incompatibility
To comprehend the root of the issue, let's dissect the inheritance structures:
-
LayerZero's LzApp:
abstract contract LzApp is Ownable { constructor(address _endpoint) { lzEndpoint = ILayerZeroEndpoint(_endpoint); } // Additional functionalities... }
-
OpenZeppelin's Ownable (Latest Version):
abstract contract Ownable is Context { address private _owner; constructor(address initialOwner) { require(initialOwner != address(0), "Ownable: owner is the zero address"); _owner = initialOwner; } // Ownership management functions... }
In the latest Ownable
, the constructor expects an initialOwner
parameter, ensuring that ownership is explicitly set upon deployment. Conversely, older versions used by LayerZero's LzApp
did not require such parameters, implicitly setting the deployer as the owner.
This discrepancy leads to conflicts when attempting to combine both contracts, as Solidity cannot reconcile the differing constructor signatures during contract initialization.
Solution: Creating LzAppAdapter.sol
To resolve the incompatibility, we introduce an adapter contract named LzAppAdapter.sol
. This adapter serves as a bridge, harmonizing the constructor parameters and inheritance hierarchy, thereby enabling seamless integration between LayerZero's NonblockingLzApp
and OpenZeppelin's Ownable
.
Key Objectives of LzAppAdapter.sol:
- Inherit from NonblockingLzApp: Maintain LayerZero's message handling capabilities.
-
Initialize Ownable Correctly: Ensure that the correct owner is set using the latest OpenZeppelin
Ownable
constructor. -
Facilitate Smooth Integration: Enable subsequent contracts to inherit from
LzAppAdapter
without encountering inheritance conflicts.
Implementation Details
LzAppAdapter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@layerzerolabs/solidity-examples/contracts/lzApp/NonblockingLzApp.sol";
abstract contract LzAppAdapter is NonblockingLzApp {
constructor(address _endpoint) NonblockingLzApp(_endpoint) Ownable(msg.sender) {}
}
Explanation:
-
Inheritance:
LzAppAdapter
inherits fromNonblockingLzApp
, ensuring that all LayerZero functionalities are retained. -
Constructor: The adapter's constructor takes an
_endpoint
address, passing it toNonblockingLzApp
. Simultaneously, it explicitly initializesOwnable
withmsg.sender
, ensuring that the deployer is set as the initial owner, conforming to the latest OpenZeppelin standards.
SavingsGroupPoC.sol
This contract demonstrates how to inherit from the LzAppAdapter
to leverage both LayerZero's cross-chain messaging and OpenZeppelin's ownership management.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./LzAppAdapter.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
* @title SavingsGroupPoC
* @dev A Proof of Concept contract for Savings Groups utilizing LayerZero for cross-chain interactions.
*/
contract SavingsGroupPoC is LzAppAdapter {
IERC20 public stablecoin;
struct Group {
uint256 contributionAmount;
uint256 cycleLength;
uint256 maxMembers;
mapping(address => bool) members;
uint256 memberCount;
}
mapping(uint256 => Group) public groups;
uint256 public nextGroupId;
uint16 constant MSG_CONTRIBUTE = 1;
uint16 constant MSG_PAYOUT = 2;
/**
* @notice Constructor for SavingsGroupPoC
* @param _lzEndpoint Address of the LayerZero endpoint
* @param _stablecoin Address of the stablecoin ERC20 token
*/
constructor(
address _lzEndpoint,
address _stablecoin
) LzAppAdapter(_lzEndpoint) {
stablecoin = IERC20(_stablecoin);
}
// Additional functionalities...
}
Ownable.sol
Ensure that you are using the latest version of OpenZeppelin's Ownable
contract, which requires an initial owner to be set via the constructor.
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.0.0) (access/Ownable.sol)
pragma solidity ^0.8.20;
import {Context} from "../utils/Context.sol";
/**
* @dev Contract module which provides a basic access control mechanism, where
* there is an account (an owner) that can be granted exclusive access to
* specific functions.
*
* The initial owner is set to the address provided by the deployer. This can
* later be changed with {transferOwnership}.
*
* This module is used through inheritance. It will make available the modifier
* `onlyOwner`, which can be applied to your functions to restrict their use to
* the owner.
*/
abstract contract Ownable is Context {
address private _owner;
/**
* @dev The caller account is not authorized to perform an operation.
*/
error OwnableUnauthorizedAccount(address account);
/**
* @dev The owner is not a valid owner account. (eg. `address(0)`)
*/
error OwnableInvalidOwner(address owner);
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
constructor(address initialOwner) {
if (initialOwner == address(0)) {
revert OwnableInvalidOwner(initialOwner);
}
_transferOwnership(initialOwner);
}
/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
_checkOwner();
_;
}
/**
* @dev Returns the address of the current owner.
*/
function owner() public view virtual returns (address) {
return _owner;
}
/**
* @dev Revert with custom message if the caller is not the owner.
*/
function _checkOwner() internal view virtual {
if (owner() != _msgSender()) {
revert OwnableUnauthorizedAccount(_msgSender());
}
}
/**
* @dev Transfers ownership to a new account (`newOwner`).
* Can only be called by the current owner.
*/
function transferOwnership(address newOwner) public virtual onlyOwner {
if (newOwner == address(0)) {
revert OwnableInvalidOwner(newOwner);
}
_transferOwnership(newOwner);
}
/**
* @dev Internal function to transfer ownership.
*/
function _transferOwnership(address newOwner) internal virtual {
address oldOwner = _owner;
_owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
}
Code Walkthrough
LzAppAdapter.sol
The LzAppAdapter
serves as a crucial intermediary between LayerZero's messaging contracts and OpenZeppelin's ownership model.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@layerzerolabs/solidity-examples/contracts/lzApp/NonblockingLzApp.sol";
abstract contract LzAppAdapter is NonblockingLzApp {
constructor(address _endpoint) NonblockingLzApp(_endpoint) Ownable(msg.sender) {}
}
-
Inheritance: By inheriting from
NonblockingLzApp
, the adapter retains all functionalities related to non-blocking cross-chain messaging. -
Constructor Logic: The constructor takes an
_endpoint
address required byNonblockingLzApp
and simultaneously initializesOwnable
by passingmsg.sender
as the initial owner. This dual initialization resolves the constructor parameter mismatch issue.
SavingsGroupPoC.sol
This contract exemplifies how SavingsGroupPoC
leverages the adapter to utilize both LayerZero's and OpenZeppelin's features seamlessly.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./LzAppAdapter.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
* @title SavingsGroupPoC
* @dev A Proof of Concept contract for Savings Groups utilizing LayerZero for cross-chain interactions.
*/
contract SavingsGroupPoC is LzAppAdapter {
IERC20 public stablecoin;
struct Group {
uint256 contributionAmount;
uint256 cycleLength;
uint256 maxMembers;
mapping(address => bool) members;
uint256 memberCount;
}
mapping(uint256 => Group) public groups;
uint256 public nextGroupId;
uint16 constant MSG_CONTRIBUTE = 1;
uint16 constant MSG_PAYOUT = 2;
/**
* @notice Constructor for SavingsGroupPoC
* @param _lzEndpoint Address of the LayerZero endpoint
* @param _stablecoin Address of the stablecoin ERC20 token
*/
constructor(
address _lzEndpoint,
address _stablecoin
) LzAppAdapter(_lzEndpoint) {
stablecoin = IERC20(_stablecoin);
}
function createGroup(
uint256 _contributionAmount,
uint256 _cycleLength,
uint256 _maxMembers
) external returns (uint256) {
uint256 groupId = nextGroupId++;
Group storage group = groups[groupId];
group.contributionAmount = _contributionAmount;
group.cycleLength = _cycleLength;
group.maxMembers = _maxMembers;
group.members[msg.sender] = true;
group.memberCount = 1;
return groupId;
}
function _nonblockingLzReceive(
uint16 _srcChainId,
bytes memory _srcAddress,
uint64 _nonce,
bytes memory _payload
) internal override {
(uint16 messageType, uint256 groupId, address member, uint256 amount) =
abi.decode(_payload, (uint16, uint256, address, uint256));
if (messageType == MSG_CONTRIBUTE) {
// Handle contribution received from another chain
Group storage group = groups[groupId];
if (!group.members[member] && group.memberCount < group.maxMembers) {
group.members[member] = true;
group.memberCount++;
}
}
}
function contribute(uint256 _groupId, uint16 _dstChainId) external payable {
Group storage group = groups[_groupId];
require(group.members[msg.sender], "Not a member");
// Transfer stablecoins to this contract
stablecoin.transferFrom(msg.sender, address(this), group.contributionAmount);
bytes memory payload = abi.encode(
MSG_CONTRIBUTE,
_groupId,
msg.sender,
group.contributionAmount
);
_lzSend(
_dstChainId,
payload,
payable(msg.sender),
address(0x0),
bytes(""),
msg.value
);
}
// View functions
function getGroup(uint256 _groupId) external view returns (
uint256 contributionAmount,
uint256 cycleLength,
uint256 maxMembers,
uint256 memberCount
) {
Group storage group = groups[_groupId];
return (
group.contributionAmount,
group.cycleLength,
group.maxMembers,
group.memberCount
);
}
}
Key Components:
- Stablecoin Integration: The contract interacts with an ERC20 stablecoin, facilitating contributions across chains.
- Group Management: Implements functionality to create and manage savings groups, ensuring members contribute the requisite amounts.
- Cross-Chain Messaging: Utilizes LayerZero's messaging capabilities to handle contributions received from other chains, maintaining the integrity and membership limits of each group.
Ownable.sol
Adhering to the latest OpenZeppelin standards, the Ownable
contract ensures secure ownership management.
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.0.0) (access/Ownable.sol)
pragma solidity ^0.8.20;
import {Context} from "../utils/Context.sol";
/**
* @dev Contract module which provides a basic access control mechanism, where
* there is an account (an owner) that can be granted exclusive access to
* specific functions.
*
* The initial owner is set to the address provided by the deployer. This can
* later be changed with {transferOwnership}.
*
* This module is used through inheritance. It will make available the modifier
* `onlyOwner`, which can be applied to your functions to restrict their use to
* the owner.
*/
abstract contract Ownable is Context {
address private _owner;
/**
* @dev The caller account is not authorized to perform an operation.
*/
error OwnableUnauthorizedAccount(address account);
/**
* @dev The owner is not a valid owner account. (eg. `address(0)`)
*/
error OwnableInvalidOwner(address owner);
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
constructor(address initialOwner) {
if (initialOwner == address(0)) {
revert OwnableInvalidOwner(initialOwner);
}
_transferOwnership(initialOwner);
}
/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
_checkOwner();
_;
}
/**
* @dev Returns the address of the current owner.
*/
function owner() public view virtual returns (address) {
return _owner;
}
/**
* @dev Revert with custom message if the caller is not the owner.
*/
function _checkOwner() internal view virtual {
if (owner() != _msgSender()) {
revert OwnableUnauthorizedAccount(_msgSender());
}
}
/**
* @dev Transfers ownership to a new account (`newOwner`).
* Can only be called by the current owner.
*/
function transferOwnership(address newOwner) public virtual onlyOwner {
if (newOwner == address(0)) {
revert OwnableInvalidOwner(newOwner);
}
_transferOwnership(newOwner);
}
/**
* @dev Internal function to transfer ownership.
*/
function _transferOwnership(address newOwner) internal virtual {
address oldOwner = _owner;
_owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
}
Key Features:
-
Constructor Parameter: Requires an explicit
initialOwner
, aligning with modern best practices for ownership initialization. - Ownership Transfers: Facilitates secure transfers of ownership, ensuring that only valid addresses can be set as new owners.
-
Access Control Modifier: The
onlyOwner
modifier restricts function access exclusively to the owner, enhancing security.
Testing the Adapter
To ensure that the LzAppAdapter
seamlessly integrates LayerZero's messaging with OpenZeppelin's ownership, comprehensive testing is essential. Here's a step-by-step guide to testing the adapter:
-
Setup Development Environment:
- Install necessary dependencies using
yarn
ornpm
. - Configure your development environment to include both LayerZero and OpenZeppelin contracts.
- Install necessary dependencies using
-
Deploy Contracts:
- Deploy the
LzAppAdapter
with the appropriate LayerZero endpoint. - Deploy the
SavingsGroupPoC
contract, ensuring that the adapter is correctly initialized.
- Deploy the
-
Ownership Verification:
- Verify that the deployer of
SavingsGroupPoC
is correctly set as the owner. - Test ownership transfer functionalities to ensure that the
transferOwnership
function works as intended.
- Verify that the deployer of
-
Cross-Chain Messaging:
- Simulate cross-chain messages to verify that
NonblockingLzApp
functions correctly within theSavingsGroupPoC
context. - Ensure that contributions from other chains are handled appropriately without blocking the message pathway.
- Simulate cross-chain messages to verify that
-
Error Handling:
- Attempt to initialize contracts with invalid owner addresses (e.g.,
address(0)
) to confirm that the contract reverts as expected. - Test access-restricted functions to ensure that only the owner can execute them.
- Attempt to initialize contracts with invalid owner addresses (e.g.,
-
Integration Tests:
- Create test scenarios where multiple groups are created, and members contribute across different chains.
- Validate that member counts and contributions are accurately tracked and managed.
By following these testing steps, developers can confidently integrate LzAppAdapter
into their projects, leveraging the strengths of both LayerZero and OpenZeppelin without running into ownership conflicts.
Benefits of the Adapter Pattern
Implementing an adapter like LzAppAdapter
offers several advantages:
- Separation of Concerns: Isolates the compatibility logic between LayerZero and OpenZeppelin, making the codebase cleaner and more maintainable.
- Reusability: The adapter can be reused across multiple contracts that require similar integrations, promoting DRY (Don't Repeat Yourself) principles.
- Flexibility: Facilitates easy upgrades or changes in either library without necessitating widespread modifications across dependent contracts.
- Enhanced Security: By centralizing the compatibility logic, it's easier to audit and ensure that ownership and access controls are correctly implemented, reducing the risk of vulnerabilities.
- Simplified Inheritance: Prevents complex multiple inheritance scenarios that can lead to ambiguous behavior or constructor conflicts.
Potential Issues and Considerations
While the adapter pattern provides a robust solution, i find that developers should be mindful of the following:
- Inherited Functionalities: Ensure that the adapter doesn't inadvertently override or interfere with functionalities provided by either LayerZero or OpenZeppelin.
- Constructor Arguments: Maintain consistency in constructor arguments across all inherited contracts to prevent initialization issues.
- Future Updates: Stay updated with both LayerZero's and OpenZeppelin's contract updates to ensure ongoing compatibility. Changes in either library will only naturally necessitate adjustments in the adapter.
- Gas Optimization: While in general, adapters promote clean code, they can introduce additional layers that might slightly impact gas costs. It's essential to monitor and optimize where possible.
- Comprehensive Testing: Given the critical role of ownership and cross-chain messaging, rigorous testing is paramount to ensure that both systems interact as intended without conflicts.
Conclusion
Web3 has advanced significantly, with Layer 2 solutions (L2s) becoming the de rigueur for scaling the world computer. However, silos and fragmentation are the inevitable consequences of this new paradigm. Integrating multiple smart contract libraries in such a fragmented ecosystem presents unique challenges, particularly when dealing with overlapping functionalities like ownership management. The compatibility issue between LayerZero’s NonblockingLzApp
and OpenZeppelin’s Ownable
serves as a prime example of these challenges. By employing the adapter pattern through LzAppAdapter.sol
, we effectively bridged the constructor parameter mismatch and inheritance conflicts, ensuring seamless integration of cross-chain messaging with secure ownership controls.
This approach not only resolves the immediate compatibility issue but also sets a foundation for scalable and maintainable contract architectures in future developments. As the blockchain ecosystem continues to grow, such design patterns will be invaluable in navigating the complexities of integrating diverse libraries and frameworks.
Top comments (0)