DEV Community

Jefferson
Jefferson

Posted on

Solving Inheritance Compatibility Issues Between LayerZero's NonblockingLzApp and OpenZeppelin's Ownable

Table of Contents

  1. Introduction
  2. Background
  3. The Problem
  4. Analyzing the Incompatibility
  5. Solution: Creating LzAppAdapter.sol
  6. Implementation Details
  7. Code Walkthrough
  8. Testing the Adapter
  9. Benefits of the Adapter Pattern
  10. Potential Issues and Considerations
  11. Conclusion
  12. 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:

  1. Constructor Parameter Mismatch: LayerZero's LzApp inherits from an older version of OpenZeppelin's Ownable that does not accept constructor parameters, whereas the latest versions of OpenZeppelin's Ownable require the initial owner to be set via constructor arguments.
  2. Multiple Inheritance Conflicts: Directly inheriting from both NonblockingLzApp and the latest Ownable 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:

  1. Inherit from NonblockingLzApp: Maintain LayerZero's message handling capabilities.
  2. Initialize Ownable Correctly: Ensure that the correct owner is set using the latest OpenZeppelin Ownable constructor.
  3. 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) {}
}

Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Inheritance: LzAppAdapter inherits from NonblockingLzApp, ensuring that all LayerZero functionalities are retained.
  • Constructor: The adapter's constructor takes an _endpoint address, passing it to NonblockingLzApp. Simultaneously, it explicitly initializes Ownable with msg.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...
}

Enter fullscreen mode Exit fullscreen mode

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);
    }
}

Enter fullscreen mode Exit fullscreen mode

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) {}
}

Enter fullscreen mode Exit fullscreen mode
  • 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 by NonblockingLzApp and simultaneously initializes Ownable by passing msg.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
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

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);
    }
}

Enter fullscreen mode Exit fullscreen mode

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:

  1. Setup Development Environment:
    • Install necessary dependencies using yarn or npm.
    • Configure your development environment to include both LayerZero and OpenZeppelin contracts.
  2. Deploy Contracts:
    • Deploy the LzAppAdapter with the appropriate LayerZero endpoint.
    • Deploy the SavingsGroupPoC contract, ensuring that the adapter is correctly initialized.
  3. 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.
  4. Cross-Chain Messaging:
    • Simulate cross-chain messages to verify that NonblockingLzApp functions correctly within the SavingsGroupPoC context.
    • Ensure that contributions from other chains are handled appropriately without blocking the message pathway.
  5. 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.
  6. 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:

  1. Separation of Concerns: Isolates the compatibility logic between LayerZero and OpenZeppelin, making the codebase cleaner and more maintainable.
  2. Reusability: The adapter can be reused across multiple contracts that require similar integrations, promoting DRY (Don't Repeat Yourself) principles.
  3. Flexibility: Facilitates easy upgrades or changes in either library without necessitating widespread modifications across dependent contracts.
  4. 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.
  5. 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:

  1. Inherited Functionalities: Ensure that the adapter doesn't inadvertently override or interfere with functionalities provided by either LayerZero or OpenZeppelin.
  2. Constructor Arguments: Maintain consistency in constructor arguments across all inherited contracts to prevent initialization issues.
  3. 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.
  4. 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.
  5. 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.

References

Top comments (0)