LayerZero ODCs

The Omnichain implementation of the ODC brings multi-chain communication capabilities to the Nexera Standard, enabling read/write operations for components distributed on different blockchains. The ODC components rely on the ODC Omnichain Proxy that routes requests to the LayerZero Endpoint infractrucutre.

Interaction Process

The Omnichain Proxy is a LayerZero OApp which provides essential functionality to communicate with the LayerZero Endpoint. The Omnichain Proxy is used to:

  • Manage cross-chain Data Point access.
  • Enable communication between Data Objects located in different blockchains.

Another component that plays an important role in the cross-chain communication process is the Omnichain Data Index smart contract; it allows:

  • Managing access to Data Points for both local and remote Data Managers, with the Data Index using the Omnichain Proxy to verify admin rights across blockchains.
  • Ensuring trustworthy requests from other blockchains via the Omnichain Proxy, which serves as a trusted party providing valid origin data to the Data Index.

Note

The Omnichain Data Index is based on the EIP-2535 standard and its functionality depends on the facets selected for its implementation.

Below you can find the interaction flow for the LayerZero ODC implementation:

Scenario TypeFlow
Write to Local and Remote Data ObjectsUser -> Local Data Manager -> Local Data Index -> Local Data Object -> Local ODC Proxy -> Local LZ Endpoint -> Remote LZ Endpoint -> Remote ODC Proxy -> Remote Data Index -> Remote Data Object

Note

Developers have the flexibility to deploy their own or reuse someone else's Data Objects across different blockchains, which can communicate with each other through the Omnichain Proxy.

The diagram below illustrates the process of cross-chain interaction between different ODC components.

Context: A User wants to allocate a new data point on Chain 1 and grant approval for the Data Manager on Chain 2 to use this data point.

image
Click here to see the detailed description of the flow.
  1. The User allocates a new data point via the Data Point Registry on Chain 1.

  2. The User then sends a request to the Data Index to approve the Data Manager on Chain 2 to access the data point allocated on Chain 1. This request verifies whether the User is the Admin of the data point on Chain 1.

  3. The Data Index sends the request to the Omnichain Proxy, which forwards the request to the LayerZero Endpoint.

    3.1 The LayerZero Endpoint, serving as a gateway between the two blockchains, relays the request to the corresponding LayerZero Endpoint on Chain 1. This request is sent to Chain 1 as a transaction using the LayerZero off-chain service.

  4. The LayerZero Endpoint on Chain 1 forwards the request to the Omnichain Proxy on Chain 1.

    4.1 The Omnichain Proxy forwards the request further to the Data Index.

    4.2 The request is then sent to the Data Point Registry, which verifies that the User is indeed the Admin of the data point.

    4.3 The Data Point Registry returns the result of the operation to the Data Index.

    4.4 The Data Index forwards the operation result back to the Omnichain Proxy.

    4.5 The Omnichain Proxy forwards the operation result back to the LayerZero Endpoint, which sends the operation result in a separate transaction, passing the data to the next endpoint on Chain 2.

  5. The LayerZero Endpoint on Chain 2 returns the operation result to the Omnichain Proxy.

    5.1 The Omnichain Proxy forwards the operation result to the Data Index, which ultimately grants the Data Manager access to the data point from Chain 1.

Cross-chain Asset Transfer

LayerZero ODCs can be utilized for a variety of use cases that involve performing actions across different blockchains using the ERC-7208 standard. The example provided below demonstrates the fractionalization of an asset and transfer use case and how it can be implemented in your dapp system.

The omnichain communication enables the asset transfer between Chain 1 and Chain 2. In this cross-chain transfer example, a user first wraps their asset (ERC-20/721/1155) in the Vault using the OmnichainWrapperFractionalizerDM, then retrieves ERC-1155 and ERC-20 fractions. These fractions are then transferred to Chain 2 via the Omnichain proxy and the LayerZero Endpoint.

The diagram below highlights the process of asset wrapping, fractionalization, and transfer to Chain 2:

image
Click here to see the detailed description of the flow.
  1. The User sends the request to the OmnichainWrapperFractionalizerDM smart contract to wrap their assets.

    1.1 The OmnichainWrapperFractionalizerDM smart contract wraps assets in the Vault.

    1.2 The OmnichainWrapperFractionalizerDM sends the request to the OmnichainFungibleFractionsDO smart contract through the Data Index to mint new fractions. The Data Index acts as a gating mechanism to check necessary permissions for OmnichainWrapperFractionalizerDM.

    1.3 The Data Index forwards the mint request to the OmnichainFungibleFractionsDO, which mints the fractions.

    1.4 The User receives both ERC-1155 and ERC-20 fractions at their address.

  2. The User initiates the transfer of fractions to Chain 2 and sends the request to the OmnichainWrapperFractionalizerDM.

    2.1 The OmnichainWrapperFractionalizerDM sends the transfer request to the OmnichainFungibleFractionsDO via the Data Index, which checks if it has sufficient write permissions.

    2.2 The Data Index forwards the transfer request to the OmnichainFungibleFractionsDO.

    2.3 The OmnichainFungibleFractionsDO decreases the User balance in the Chain 1 and sends the request to the Chain 2 to increase the fraction balance. The request is sent to the Omnichain Proxy.

    2.4 The Omnichain Proxy forwards the increase balance request to the LayerZero Endpoint. It triggers the LayerZero offchain service to create a new transaction on Chain 2 to finalize the fraction transfer process.

  3. The LayerZero Endpoint sends the transfer request to the Omnichain Proxy on Chain 2.

    3.1 The Omnichain Proxy then forwards the transfer request to the Data Index, which checks for sufficient write permissions.

    3.2 The Data Index forwards the transfer request to the OmnichainFungibleFractionsDO, which finally increases the fraction balance on Chain 2.

    3.3. To make the balance update visible to offchain services, the OmnichainFungibleFractionsDO sends the request to OmnichainWrapperFractionalizerDM to emit the Transfer event.

Note

There are situations when a transaction originating from Chain 1 gets reverted on Chain 2 due to different reasons. A developer must implement an error-handling mechanism in the Data Object to handle these scenarios, because usually, the revert will not be visible to the Data Object located in Chain 1.

ERC-20 Omnichain Transfer Example

The ERC-20 Fraction transfer involves function calls across multiple contracts. This section will describe the diagram above from the perspective of the function execution flow, dividing it into four parts, each representing the boundary of the corresponding component.

Chain 1

Data Manager

The flow begins with a user interacting with the Data Manager by calling the transferFrom function presented in the FractionERC20DataManager contract. This function acts as the entry point for the user to make an omnichain transfer:

    function transferFrom(
        address from,
        OmnichainAddress to,
        uint256 value,
        address payable refundAddress
    ) public payable virtual override(IFungibleFraction, OmnichainERC20Transfers) returns (bool) {
        return super.transferFrom(from, to, value, refundAddress);
    }

The FractionERC20DataManager inherits from OmnichainUpgradeableERC20DataManager, which also inherits from the OmnichainERC20Transfers contract, where the transferFrom function is declared.

The transferFrom function returns the result of the invocation of the _transferFromOmnichain function:

function _transferFromOmnichain(address from, OmnichainAddress to, uint256 amount, address payable refundAddress) private returns (bool) {
    _spendAllowance(from, _msgSender(), amount);
    _beforeOmnichainTokenTransfer(from, to, amount);
 
    if (from == address(0)) {
        revert ERC20InvalidSender(address(0));
    }
 
    _writeOmnichainTransfer(from, to, amount, refundAddress);
 
    (uint32 toChainId, ) = to.decode();
    emit Transfer(from, address(uint160(toChainId)), amount);
    return true;
}

As you can see, inside the code, the _writeOmnichainTransfer function is invoked, which is declared in the OmnichainUpgradeableERC20DataManager contract:

function _writeOmnichainTransfer(address from, OmnichainAddress to, uint256 amount, address payable refundAddress) internal override {
    if (!_isRegisteredAsOmnichainIncreaseBalanceHandler) {
        revert NotRegisteredAsOmnichainIncreaseBalanceHandler();
    }
 
    // Omnichain Mint & Burn are not supported yet, so we have only transfer
    dataIndex.write{value: msg.value}(
        fungibleTokenDO,
        datapoint,
        IOmnichainFungibleTokenOperations.omnichainTransfer.selector,
        abi.encode(from, to, amount, refundAddress)
    );
}

Inside the function, you can see the invocation of the dataIndex.write() function, which forwards the transfer message to the Omnichain Data Index implementation. This step marks the transition of the execution flow to the Data Index component.

Data Index

The write() function is declared under the DataManagerFacet, which represents the Data Index:

function write(
    IDataObject dobj,
    DataPoint dp,
    bytes4 operation,
    bytes calldata data
) external payable whenNotPaused onlyApprovedDM(dp) returns (bytes memory) {
    return IDOData(dobj).write{value: msg.value}(dp, operation, data);
}

Once the message is delivered to the Data Index, it calls the onlyApprovedDM() function to verify if the caller (Data Manager) has sufficient permissions. Only then does it call the write() operation on the Data Object, forwarding the message data further.

Data Object

The contract that receives the message from the Data Index is the OmnichainFungibleTokenDO. This contract inherits from the OmnichainBaseDataObject, which further inherits from BaseDataObject. It has the write() function declared, which was initially called by the Data Index:

function write(DataPoint dp, bytes4 operation, bytes calldata data) external payable onlyDataIndex(dp) returns (bytes memory) {
    _handleNativePaymentInWrite();
    return _dispatchWrite(dp, operation, data);
}

This invocation calls the _dispatchWrite function, which is declared under the OmnichainBaseDataObject contract. The _dispatchWrite function relies on the IOmnichainFungibleTokenOperations.omnichainTransfer.selector to make the _omnichainTransfer call:

    function _omnichainTransfer(
        DataPoint dp,
        address from,
        OmnichainAddress to,
        uint256 amount,
        address payable refundAddress
    ) internal virtual returns (bytes32) {
        (uint32 toChainId, ) = OmnichainAddresses.decode(to);
        bytes32 diidFrom = _diid(dp, from);
        ChainidTools.requireNotCurrentChain(toChainId);
        DiidData storage diiddFrom = _diidData(dp, diidFrom);
        _decreaseBalance(diiddFrom, amount, dp, diidFrom);
        _decreaseLocalTotalSupply(dp, amount);
        return _increaseOmnichainBalance(dp, OmnichainAddresses.encode(from), to, amount, refundAddress);
    }

Before the omnichain transfer is made, the account balance is decreased on the local chain by calling _decreaseBalance, and _increaseOmnichainBalance is called on the target chain. The latter function is declared under the OmnichainFungibleTokensDO contract. Inside the _increaseOmnichainBalance function, the _sendIncreaseOmnichainBalanceRequest is called, which is also part of the OmnichainFungibleTokensDO contract:

function _sendIncreaseOmnichainBalanceRequest(DataPoint dp, uint32 toChainId, bytes memory data, address payable refundAddress) private returns (bytes32) {
    return
        _proxy.queryDataObjectWrite{value: msg.value}(
            toChainId,
            address(this), // Address of DO on target chain is the same on all chains
            dp,
            IOmnichainFungibleTokenOperations.omnichainIncreaseBalance.selector,
            data,
            OMNICHAIN_INCREASE_BALANCE_GAS_LIMIT,
            OMNICHAIN_INCREASE_BALANCE_CALLBACK_GAS_LIMIT,
            refundAddress
        );
}

Note that this function forwards the request to the Omnichain Proxy by calling queryDataObjectWrite. This call relies on the IOmnichainFungibleTokenOperations.omnichainIncreaseBalance.selector to execute the _omnichainIncreaseBalance function.

Omnichain Proxy

The queryDataObjectWrite function is part of the OmnichainProxy contract:

    function queryDataObjectWrite(
        uint32 chainId,
        address dobj,
        DataPoint dp,
        bytes4 operation,
        bytes calldata data,
        uint128 destinationGasLimit,
        uint128 callbackGasLimit,
        address payable refundAddress
    ) external payable whenNotPaused returns (bytes32) {
        uint32 eid = _targetChainEid(chainId);
        OmnichainAddress originalSender = OmnichainAddresses.encode(msg.sender);
        bytes memory opData = abi.encode(dobj, dp, operation, data, originalSender);
        return _sendMessageAndRecordCallback(eid, Operation.DATAOBJECT_WRITE, opData, destinationGasLimit, callbackGasLimit, refundAddress);
    }

During the execution, _sendMessageAndRecordCallback is called, which subsequently calls the _sendMessage function:

    function _sendMessage(
        uint32 eid,
        Operation op,
        bytes memory opData,
        uint128 destinationGasLimit,
        uint128 callbackGasLimit,
        address refundAddress
    ) internal nonReentrant returns (bytes32) {
        bytes memory message = abi.encode(op, opData, callbackGasLimit);
        MessagingFee memory fee = _estimateFee(eid, message, destinationGasLimit);
        if (msg.value < fee.nativeFee) revert NotEnoughFunds(msg.value, fee.nativeFee);
        uint256 valueForTargetChain;
        unchecked {
            valueForTargetChain = msg.value - fee.nativeFee;
        }
        fee.nativeFee = msg.value; // The whole fee (gas fee + value for destination) is calculated so that we have nothing left on the source
        bytes memory opts = OptionsBuilder.newOptions().addExecutorLzReceiveOption(
            destinationGasLimit,
            uint128(_convertSourceValueToDestinationValue(eid, valueForTargetChain))
        );
        MessagingReceipt memory receipt = _lzSend(eid, message, opts, fee, refundAddress);
        emit MessageSent(receipt.guid);
        return receipt.guid;
    }

The final step in this function execution chain is the _lzSend() function, which ultimately sends the request to the LayerZero Endpoint and transfers the ERC-20 fractions to Chain 2.

Chain 2

Omnichain Proxy

Once the message is delivered to the Omnichain Proxy on Chain 2, the _lzReceive() function is invoked, and in the process, the second function _executeReceivedQuery() is called, which is responsible for processing the request from Chain 1:

    function _lzReceive(
        Origin calldata _origin,
        bytes32 _guid,
        bytes calldata payload,
        address /*_executor*/,
        bytes calldata /*_extraData*/
    ) internal override whenNotPaused {
        (Operation op, bytes memory opData, uint128 callbackGasLimit) = abi.decode(payload, (Operation, bytes, uint128));
        if (op == Operation.CALLBACK) {
            (bytes32 originalGuid, bytes memory cbData) = abi.decode(opData, (bytes32, bytes));
            _executeCallback(originalGuid, cbData);
        } else {
            _executeReceivedQuery(_guid, _origin.srcEid, op, opData, callbackGasLimit);
        }
    }

The _executeReceivedQuery() function contains the invocation of the _executeOperation() which is responsible for determining which operation to invoke: check the admin of a data point, approve a Data Manager, read from a Data Object, or write to a Data Object. In this example, the operation writes to the Data Object - _executeDataObjectWrite():

    function _executeOperation(Operation op, bytes memory opData) internal returns (bytes memory) {
        if (op == Operation.IS_DATAPOINT_ADMIN) {
            (DataPoint dp, address account) = abi.decode(opData, (DataPoint, address));
            bool isAdmin = _executeIsDataPointAdmin(dp, account);
            return abi.encode(isAdmin);
        } else if (op == Operation.APPROVE_DATA_MANAGER) {
            (DataPoint dp, OmnichainAddress dm, bool approved, OmnichainAddress sender) = abi.decode(
                opData,
                (DataPoint, OmnichainAddress, bool, OmnichainAddress)
            );
            _executeApproveDataManager(dp, dm, approved, sender);
            return "";
        } else if (op == Operation.DATAOBJECT_READ) {
            (address dobj, DataPoint dp, bytes4 operation, bytes memory data) = abi.decode(opData, (address, DataPoint, bytes4, bytes));
            return _executeDataObjectRead(dobj, dp, operation, data);
        } else if (op == Operation.DATAOBJECT_WRITE) {
            (address dobj, DataPoint dp, bytes4 operation, bytes memory data, OmnichainAddress originalSender) = abi.decode(
                opData,
                (address, DataPoint, bytes4, bytes, OmnichainAddress)
            );
            return _executeDataObjectWrite(dobj, dp, operation, data, originalSender);
        } else {
            revert UnknownOperation(op);
        }
    }

In the _executeDataObjectWrite() operation, the Data Index writeOmnichain() function is called:

    function _executeDataObjectWrite(
        address dobj,
        DataPoint dp,
        bytes4 operation,
        bytes memory data,
        OmnichainAddress originalSender
    ) internal returns (bytes memory) {
        if (address(_dataIndex) == address(0)) revert IncorrectDataIndexImplementationAddress(address(_dataIndex));
        return IOmnichainData(address(_dataIndex)).writeOmnichain(dobj, dp, operation, data, originalSender);
    }

Data Index

The writeOmnichain() function is declared under the OmnichainDataFacet contract. It verifies if the caller is the trusted Omnichain Proxy implementation, allowing trust in the originalSender account, meaning that it is the address that in fact initially sent this message from Chain 1.

Then the write() operation is called on the Data Object located on Chain 2:

function writeOmnichain(
        address dobj,
        DataPoint dp,
        bytes4 operation,
        bytes calldata data,
        OmnichainAddress originalSender
    ) external whenNotPaused returns (bytes memory) {
        if (msg.sender != address(OmnichainSupportStorage.layout().proxy())) revert SenderIsNotOmnichainProxy(msg.sender);
 
        (uint32 osChainId, address osAddress) = OmnichainAddresses.decode(originalSender);
        if (
            !(osAddress == dobj && OmnichainSupportStorage.layout().approvedChain(osChainId, osAddress)) &&
            !AccessManagerStorage.layout().dmApprovals[dp][originalSender]
        ) revert OmnichainDataManagerNotApproved(dp, originalSender);
        return IDOData(dobj).write(dp, operation, data);
    }
}

Data Object

The contract that receives the message from the Data Index is the OmnichainFungibleTokenDO. This contract inherits from the OmnichainBaseDataObject, which further inherits from BaseDataObject. It has the write() function declared, which was initially called by the Data Index:

function write(DataPoint dp, bytes4 operation, bytes calldata data) external payable onlyDataIndex(dp) returns (bytes memory) {
    _handleNativePaymentInWrite();
    return _dispatchWrite(dp, operation, data);
}

The _dispatchWrite() function is conditioned by IOmnichainFungibleTokenOperations.omnichainIncreaseBalance.selector to call the _omnichainIncreaseBalance() function.

The ERC-20 standard requires emitting a Transfer event after the increase balance operation. For this, the callIncreaseBalanceHandlersAndRevertOnFail() handler is called in the _omnichainIncreaseBalance() function. It ensures that the Data Manager emits the event.

Data Manager

By calling the callIncreaseBalanceHandlersAndRevertOnFail() handler, the underlying function of the Data Manager is called: IOmnichainFungibleTokenIncreaseBalanceCallback(handler).afterOmnichainFungibleTokenIncreaseBalanceOnTargetChain().

function afterOmnichainFungibleTokenIncreaseBalanceOnTargetChain(OmnichainAddress from, address to, uint256 amount) external virtual returns (bool) {
    if (msg.sender != address(omnichainFungibleDO)) {
        revert UnauthorizedCaller();
    }
    (uint32 fromChainId, ) = OmnichainAddresses.decode(from);
    emit Transfer(address(uint160(fromChainId)), to, amount);
    return true;
}

It verifies that the caller of the function is indeed the Data Object address and emits the Transfer event.

If this call reverts, the balance is decreased.

The result of this operation is returned to the initial caller, which is the Omnichain Proxy.

Omnichain Proxy Callback

Chain 2

The result of the increase balance operation is returned to the _executeReceivedQuery() function in the Omnichain Proxy, and the callback is triggered.

The callback data is sent as a transaction to Chain 1 via the LayerZero Endpoint. Specifically for this, the _lzSend() is called and the Operation.CALLBACK is requested on Chain 1.

function _executeReceivedQuery(bytes32 guid, uint32 srcEid, Operation op, bytes memory opData, uint128 callbackGasLimit) private nonReentrant {
    bytes memory opResult = _executeOperation(op, opData);
    if (callbackGasLimit == 0) {
        emit OperationWithoutCallbackExecuted(guid, opResult);
        return;
    }
 
    // Prepare & send callback
    bytes memory cbData = abi.encode(guid, opResult);
    bytes memory message = abi.encode(Operation.CALLBACK, cbData, 0);
    MessagingFee memory fee = _estimateFee(srcEid, message, callbackGasLimit);
    if (msg.value < fee.nativeFee) revert NotEnoughFunds(msg.value, fee.nativeFee);
    uint256 valueForSourceChain;
    unchecked {
        valueForSourceChain = msg.value - fee.nativeFee;
    }
    fee.nativeFee = msg.value; // The whole fee (gas fee + value for source) is calculated so that we have nothing left on destination
    bytes memory opts = OptionsBuilder.newOptions().addExecutorLzReceiveOption(
        callbackGasLimit,
        uint128(_convertSourceValueToDestinationValue(srcEid, valueForSourceChain))
    );
    MessagingReceipt memory receipt = _lzSend(srcEid, message, opts, fee, owner()); // Refund to owner(), but should be nothing to refund, because everything is sent back to origin chain
    emit CallbackSent(guid, receipt.guid);
}

Chain 1

Omnichain Proxy

Once the message reaches the Omnichain Proxy on Chain 1, the _lzReceive function is called:

function _lzReceive(
    Origin calldata _origin,
    bytes32 _guid,
    bytes calldata payload,
    address /*_executor*/,
    bytes calldata /*_extraData*/
) internal override whenNotPaused {
    (Operation op, bytes memory opData, uint128 callbackGasLimit) = abi.decode(payload, (Operation, bytes, uint128));
    if (op == Operation.CALLBACK) {
        (bytes32 originalGuid, bytes memory cbData) = abi.decode(opData, (bytes32, bytes));
        _executeCallback(originalGuid, cbData);
    } else {
        _executeReceivedQuery(_guid, _origin.srcEid, op, opData, callbackGasLimit);
    }
}

Then the _executeCallback() function is called, which forwards the succeeded/failed result back to the Data Object via the IOmnichainCallbackReceiver(cbd.target).omnichainCallback(guid, cbData) call:

function _executeCallback(bytes32 guid, bytes memory cbData) internal virtual nonReentrant {
    CallbackData memory cbd = _callbackData[guid];
    delete _callbackData[guid];
    IOmnichainCallbackReceiver(cbd.target).omnichainCallback(guid, cbData);
    emit CallbackExecuted(guid, cbd.target);
    if (msg.value > 0) {
        cbd.refund.sendValue(msg.value);
        emit RefundSent(guid, cbd.refund, msg.value);
    }
}

The origin of the result is tracked and determined by the Data Object via the guid identifier. Note that the guid argument is identified in the Data Object as the rid.

Data Object

When the operation result reaches the Data Object, the omnichainCallback() function verifies if the callback data sender is indeed the Omnichain Proxy, and the next operation, _omnichainCallback, is invoked:

function omnichainCallback(bytes32 rid, bytes calldata data) public virtual {
    if (msg.sender != address(_proxy)) revert SenderIsNotOmnichainProxy(msg.sender);
 
    OmnichainCallbackHandlerData memory cbData = _callbackData[rid];
 
    if (cbData.operation == CallbackOperation.IS_DATAPOINT_ADMIN_RESPONSE) {
        delete _callbackData[rid];
 
        _setOmnichainDataIndexImplementationCallback(cbData.opData, data);
    } else {
        _omnichainCallback(rid, data);
    }
}

The _omnichainCallback function acts in two ways:

  • If the selector is correct, the _omnichainIncreaseBalanceCallback is called.
  • Filters nonexistent contract calls via the UnknownWriteOperation() function.
function _omnichainCallback(bytes32 rid, bytes calldata data) internal override {
    (DataPoint dp, bytes4 opSelector, bytes memory opData) = abi.decode(data, (DataPoint, bytes4, bytes));
    if (opSelector == IOmnichainFungibleFractionsOperations.omnichainIncreaseBalanceCallback.selector) {
        bool success = abi.decode(opData, (bool));
        _omnichainIncreaseBalanceCallback(dp, rid, success);
    } else {
        revert UnknownWriteOperation(opSelector);
    }
}

If the transfer to Chain 2 was not successful, then the refund is issued on Chain 1 via the _refundOmnichainTransfer() call. By invoking this operation, the balance is increased for the refund address specified as an argument in the transferFrom() function:

    function _omnichainIncreaseBalanceCallback(DataPoint dp, bytes32 rid, bool success) private {
        if (!success) {
            _refundOmnichainTransfer(dp, rid);
        }
        delete _pendingOmnichainTransfers[rid];
    }

On this page