LayerZero based Omni Token

LayerZero based Omni Token

Tags
Web3
Cross-chain
Published
March 17, 2024
Author
Senn
 

Introduction

LayerZero is a omnichain interoperability protocol. It can be used to transfer message between different chains. In this blog, I will analyze the implementation of LayerZero’s official Omni ERC20, ERC721 and ERC 1155 and deploy omni tokens.
solidity-examples
LayerZero-LabsUpdated May 20, 2024
 

NonblockingLzApp & LzApp

Overview

Before building Omni tokens, we need to first analyze the NonblockingLzApp which includes necessary functionalities used to interact with LayerZero protocol. Developers can inherit this contract to easily use LayerZero protocol.
 
As we have illustrated in LayerZero-V1, to send x-chain message, the contract should call LayerZero’s Endpoint to execute necessary logics, and to receive x-chain message, contract should implement lzReceive to handle customized logics.
notion image
NonblockingLzApp and LzApp implement necessary functionalites to interact with LayerZero protocol, developer can just inherits NonblockingLzApp to get those abilites.
LzApp :
  • store endpoint address
  • store trusted remote
  • store min gas for each target chain
  • store payload limit for each target chain
  • implements lzReceive which calls _blockingLzReceive
  • implements _lzSend used to call endpoint to send x-chain message
NonblockingLzApp:
  • implements _blockingLzReceive which limits max return data size and call nonblockingLzReceive to handle customized logic, if transaction fails, it will record the tx to be retried.
 
So basically, developer can just:
  • implement x-chain message send process based on _lzSend of LzApp
  • override nonblockingLzReceive of NonblockingLzApp to implement customized receive x-chain message operation
 

Methods

In the LzApp, there are these two methods.

lzReceive

In lzReceive, it:
  • checks the caller should be the registered layerzero Endpoint
  • the x-chain message path is trusted
  • calls _blockingLzReceive to execute customized logic
// LayerZero-V1/contracts/lzApp/LzApp.sol function lzReceive( uint16 _srcChainId,// the source chain's id where message comes from bytes calldata _srcAddress, // the x-chain message path [remote_addr, src_addr] uint64 _nonce,// message's nonce bytes calldata _payload // message payload ) public virtual override { // lzReceive must be called by the endpoint for security require(_msgSender() == address(lzEndpoint), "LzApp: invalid endpoint caller"); bytes memory trustedRemote = trustedRemoteLookup[_srcChainId]; // if will still block the message pathway from (srcChainId, srcAddress). should not receive message from untrusted remote. require( _srcAddress.length == trustedRemote.length && trustedRemote.length > 0 && keccak256(_srcAddress) == keccak256(trustedRemote), "LzApp: invalid source sending contract" ); _blockingLzReceive(_srcChainId, _srcAddress, _nonce, _payload); }
 
NonblockingLzApp overides the _blockingLzReceive of LzApp. It:
  • limt the size of return data of the call to be smaller than 150 bytes
  • if the call fails, then store the filed message for later retry.
// LayerZero-V1/contracts/lzApp/NonblockingLzApp.sol // overriding the virtual function in LzReceiver function _blockingLzReceive( uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload ) internal virtual override { (bool success, bytes memory reason) = address(this).excessivelySafeCall( gasleft(), 150, abi.encodeWithSelector(this.nonblockingLzReceive.selector, _srcChainId, _srcAddress, _nonce, _payload) ); if (!success) { _storeFailedMessage(_srcChainId, _srcAddress, _nonce, _payload, reason); } }
 
Inside the excessivelySafeCall, we can see that it checks whether the return data size is greater than the max size.
/// @notice Use when you _really_ really _really_ don't trust the called /// contract. This prevents the called contract from causing reversion of /// the caller in as many ways as we can. /// @dev The main difference between this and a solidity low-level call is /// that we limit the number of bytes that the callee can cause to be /// copied to caller memory. This prevents stupid things like malicious /// contracts returning 10,000,000 bytes causing a local OOG when copying /// to memory. /// @param _target The address to call /// @param _gas The amount of gas to forward to the remote contract /// @param _maxCopy The maximum number of bytes of returndata to copy /// to memory. /// @param _calldata The data to send to the remote contract /// @return success and returndata, as `.call()`. Returndata is capped to /// `_maxCopy` bytes. function excessivelySafeCall( address _target, uint _gas, uint16 _maxCopy, bytes memory _calldata ) internal returns (bool, bytes memory) { // set up for assembly call uint _toCopy; bool _success; bytes memory _returnData = new bytes(_maxCopy); // dispatch message to recipient // by assembly calling "handle" function // we call via assembly to avoid memcopying a very large returndata // returned by a malicious contract assembly { _success := call( _gas, // gas _target, // recipient 0, // ether value add(_calldata, 0x20), // inloc mload(_calldata), // inlen 0, // outloc 0 // outlen ) // limit our copy to 256 bytes _toCopy := returndatasize() if gt(_toCopy, _maxCopy) { _toCopy := _maxCopy } // Store the length of the copied bytes mstore(_returnData, _toCopy) // copy the bytes from returndata[0:_toCopy] returndatacopy(add(_returnData, 0x20), 0, _toCopy) } return (_success, _returnData); }
 
Inside the _storeFailedMessage, it stores the payload in failedMessages and emit event.
// LayerZero-V1/contracts/lzApp/NonblockingLzApp.sol function _storeFailedMessage( uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload, bytes memory _reason ) internal virtual { failedMessages[_srcChainId][_srcAddress][_nonce] = keccak256(_payload); emit MessageFailed(_srcChainId, _srcAddress, _nonce, _payload, _reason); }

_lzSend

checks whethre the x-chain message path is trusted, checks the payload size and calls Endpoint to send it.
// LayerZero-V1/contracts/lzApp/LzApp.sol function _lzSend( uint16 _dstChainId, // destination chain's id bytes memory _payload, // message payload address payable _refundAddress, // refund address if the msg.value is redundant address _zroPaymentAddress, // address to pay ZRO token as fee bytes memory _adapterParams, // additional information used by Relayer uint _nativeFee // native fee ) internal virtual { bytes memory trustedRemote = trustedRemoteLookup[_dstChainId]; require(trustedRemote.length != 0, "LzApp: destination chain is not a trusted source"); _checkPayloadSize(_dstChainId, _payload.length); lzEndpoint.send{value: _nativeFee}(_dstChainId, trustedRemote, _payload, _refundAddress, _zroPaymentAddress, _adapterParams); } function _checkPayloadSize(uint16 _dstChainId, uint _payloadSize) internal view virtual { uint payloadSizeLimit = payloadSizeLimitLookup[_dstChainId]; if (payloadSizeLimit == 0) { // use default if not set payloadSizeLimit = DEFAULT_PAYLOAD_SIZE_LIMIT; } require(_payloadSize <= payloadSizeLimit, "LzApp: payload size is too large"); }
 

Omni ERC20

notion image

Overview

Omni ERC20(OFTV2) supports two kinds of x-chain message, Let’s say there is two blockchains and , and two OFTV2 and deployed on and respectively.
  • Account can lock his/her token on and send a x-chain message to to send equal amount of token to on
  • Account can lock his/her token on and send a x-chain message to to send equal amount of token to on , and send arbitrary calldata to .
notion image
To implement a OmniERC20, we can just inherits the OFTV2
pragma solidity ^0.8.0; import "../OFTV2.sol"; contract OFTV2Mock is OFTV2 { constructor(address _layerZeroEndpoint, uint _initialSupply, uint8 _sharedDecimals) OFTV2("ExampleOFT", "OFT", _sharedDecimals, _layerZeroEndpoint) { _mint(_msgSender(), _initialSupply); } }

Send token

For on to send tokens to on . should call OFTV2Mock.sendFrom
// LayerZero-V1/contracts/token/oft/v2/BaseOFTV2.sol struct LzCallParams { address payable refundAddress;// address to accept refund( msg.value - neededFee) address zroPaymentAddress;// address to pay for the zro(If use zro as payment) bytes adapterParams;// information used by relayer, including tx type, native fee, etc. } function sendFrom( address _from, // address to transfer token uint16 _dstChainId, // destination chain id bytes32 _toAddress, // token transfer-to address uint _amount, // amount of token to transfer, decimal is the current chain's decimal LzCallParams calldata _callParams // x-chain related params ) public payable virtual override { _send(_from, _dstChainId, _toAddress, _amount, _callParams.refundAddress, _callParams.zroPaymentAddress, _callParams.adapterParams); }
 
In side the _send:
  • checks the gas limit is qualified. Note that the gas used by Relayer to send x-chain message is encoded in the _adapterParams
  • convert token amount to fit the target chain.
  • burn token of _from
  • encode payload and calls endpoint to send x-chain message
// LayerZero-V1/contracts/token/oft/v2/OFTCoreV2.sol function _send( address _from, uint16 _dstChainId, bytes32 _toAddress, uint _amount, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams ) internal virtual returns (uint amount) { // check gas limit _checkGasLimit(_dstChainId, PT_SEND, _adapterParams, NO_EXTRA_GAS); // convert token decimal to fit the target chain's contract (amount, ) = _removeDust(_amount); // burn token of _from amount = _debitFrom(_from, _dstChainId, _toAddress, amount); // amount returned should not have dust require(amount > 0, "OFTCore: amount too small"); // encode payload and calls endpoint to send x-chain message bytes memory lzPayload = _encodeSendPayload(_toAddress, _ld2sd(amount)); _lzSend(_dstChainId, lzPayload, _refundAddress, _zroPaymentAddress, _adapterParams, msg.value); emit SendToChain(_dstChainId, _from, _toAddress, amount); }
 

Check gas limit

decode the extraGas from _adapterParamsand check whether it’s enough to cover the min tx gas and the additional gas.
// LayerZero-V1/contracts/lzApp/LzApp.sol function _checkGasLimit( uint16 _dstChainId, uint16 _type, bytes memory _adapterParams, uint _extraGas ) internal view virtual { // decode _adapterParams to get extraGas uint providedGasLimit = _getGasLimit(_adapterParams); // get the min gas to send tx on destination chain uint minGasLimit = minDstGasLookup[_dstChainId][_type]; require(minGasLimit > 0, "LzApp: minGasLimit not set"); // check gas limit require(providedGasLimit >= minGasLimit + _extraGas, "LzApp: gas limit is too low"); } function _getGasLimit(bytes memory _adapterParams) internal pure virtual returns (uint gasLimit) { // bytes [2 32 ] // fields [txType extraGas] require(_adapterParams.length >= 34, "LzApp: invalid adapterParams"); assembly { gasLimit := mload(add(_adapterParams, 34)) } }
 

Remove dust

Because tokens on different blockchains may have different decimals, so before sending x-chain message, we need first convert the amount, so that the target chain will receive correct information about amount of token to mint. refer
// LayerZero-V1/contracts/token/oft/v2/OFTCoreV2.sol function _send( address _from, uint16 _dstChainId, bytes32 _toAddress, uint _amount, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams ) internal virtual returns (uint amount) { // [senn] omit for brevity... // convert token decimal to fit the target chain's contract (amount, ) = _removeDust(_amount); // [senn] omit for brevity... }

Burn token of from

To send token from account on blockchain to account on blockchain . should burn equivalent tokens. Also, sendFrom can be called by approved account uses allowance.
// LayerZero-V1/contracts/token/oft/v2/OFTCoreV2.sol function _send( address _from, uint16 _dstChainId, bytes32 _toAddress, uint _amount, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams ) internal virtual returns (uint amount) { // [senn] omit for brevity... // burn token of _from amount = _debitFrom(_from, _dstChainId, _toAddress, amount); // amount returned should not have dust require(amount > 0, "OFTCore: amount too small"); // [senn] omit for brevity... }
// LayerZero-V1/contracts/token/oft/v2/OFTV2.sol function _debitFrom( address _from, uint16, bytes32, uint _amount ) internal virtual override returns (uint) { // get the msg.sender address spender = _msgSender(); // spend spender's allowance of _from if (_from != spender) _spendAllowance(_from, spender, _amount); // burn _burn(_from, _amount); return _amount; }

Send x-chain message

After necessary operations, the contract will call layerzero’s endpoint to send x-message which will mint corresponding token on the target chain.
The payload is : [_toAddress, _ld2sd(amount)]
// LayerZero-V1/contracts/token/oft/v2/OFTCoreV2.sol function _send( address _from, uint16 _dstChainId, bytes32 _toAddress, // to address should use bytes32 to fit non-EVM chain uint _amount, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams ) internal virtual returns (uint amount) { // [senn] omit for brevity... // encode payload and calls endpoint to send x-chain message bytes memory lzPayload = _encodeSendPayload(_toAddress, _ld2sd(amount)); _lzSend(_dstChainId, lzPayload, _refundAddress, _zroPaymentAddress, _adapterParams, msg.value); // [senn] omit for brevity... } // packet type uint8 public constant PT_SEND = 0; uint8 public constant PT_SEND_AND_CALL = 1; function _encodeSendPayload(bytes32 _toAddress, uint64 _amountSD) internal view virtual returns (bytes memory) { return abi.encodePacked(PT_SEND, _toAddress, _amountSD); }
// LayerZero-V1/contracts/lzApp/LzApp.sol function _lzSend( uint16 _dstChainId, bytes memory _payload, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams, uint _nativeFee ) internal virtual { // check dst chain has been registered bytes memory trustedRemote = trustedRemoteLookup[_dstChainId]; require(trustedRemote.length != 0, "LzApp: destination chain is not a trusted source"); // check payload size is valid _checkPayloadSize(_dstChainId, _payload.length); // call endpoint to send x-chain message lzEndpoint.send{value: _nativeFee}(_dstChainId, trustedRemote, _payload, _refundAddress, _zroPaymentAddress, _adapterParams); } // ua can not send payload larger than this by default, but it can be changed by the ua owner uint public constant DEFAULT_PAYLOAD_SIZE_LIMIT = 10000; function _checkPayloadSize(uint16 _dstChainId, uint _payloadSize) internal view virtual { uint payloadSizeLimit = payloadSizeLimitLookup[_dstChainId]; if (payloadSizeLimit == 0) { // use default if not set payloadSizeLimit = DEFAULT_PAYLOAD_SIZE_LIMIT; } require(_payloadSize <= payloadSizeLimit, "LzApp: payload size is too large"); }
 

Send and call

User can call sendAndCall to send x-chain message to send token and execute arbitrary logic.
// LayerZero-V1/contracts/token/oft/v2/BaseOFTV2.sol function sendAndCall( address _from, // the address where token transferred from uint16 _dstChainId, // the destination chain id bytes32 _toAddress, // the address where token transferred to uint _amount, // amount of token to transfer based on current chain's decimal bytes calldata _payload, // payload to execute on the destination contract uint64 _dstGasForCall, // gasLimit to execute payload on destination chain LzCallParams calldata _callParams // x-chain related params ) public payable virtual override { _sendAndCall( _from, _dstChainId, _toAddress, _amount, _payload, _dstGasForCall, _callParams.refundAddress, _callParams.zroPaymentAddress, _callParams.adapterParams ); }
 
Inside the _sendAndCall:
  • checks gas limit
  • remove dust
  • encode payload
  • calls endpoint to send x-chain message
// LayerZero-V1/contracts/token/oft/v2/OFTCoreV2.sol function _sendAndCall( address _from, uint16 _dstChainId, bytes32 _toAddress, uint _amount, bytes memory _payload, uint64 _dstGasForCall, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams ) internal virtual returns (uint amount) { // checks gas limit _checkGasLimit(_dstChainId, PT_SEND_AND_CALL, _adapterParams, _dstGasForCall); // remove dust (amount, ) = _removeDust(_amount); amount = _debitFrom(_from, _dstChainId, _toAddress, amount); require(amount > 0, "OFTCore: amount too small"); // encode the msg.sender into the payload instead of _from bytes memory lzPayload = _encodeSendAndCallPayload(msg.sender, _toAddress, _ld2sd(amount), _payload, _dstGasForCall); _lzSend(_dstChainId, lzPayload, _refundAddress, _zroPaymentAddress, _adapterParams, msg.value); emit SendToChain(_dstChainId, _from, _toAddress, amount); }

Receive message

OmniERC20's _nonblockingLzReceive will be called to receive x-chain message:
  • decode the packet type, whether the message is about send or send_and_call
  • decode other information in the packet, like the token amount to transfer and the token receiver address.
  • mint token to the receiver
  • [optional] if is and send and call transaction, if the call transaction fails, then stores it in the nonblockingLzApp for later retry.
// LayerZero-V1/contracts/token/oft/v2/OFTCoreV2.sol function _nonblockingLzReceive( uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload ) internal virtual override { // decode out the packet type uint8 packetType = _payload.toUint8(0); // call corresponding function according to packet type if (packetType == PT_SEND) { _sendAck(_srcChainId, _srcAddress, _nonce, _payload); } else if (packetType == PT_SEND_AND_CALL) { _sendAndCallAck(_srcChainId, _srcAddress, _nonce, _payload); } else { revert("OFTCore: unknown packet type"); } }

Send

inside the _sendAck, it decodes the to and amount, then calculates correct token amount , and mint to the to .
// LayerZero-V1/contracts/token/oft/v2/OFTCoreV2.sol function _sendAck( uint16 _srcChainId, bytes memory, uint64, bytes memory _payload ) internal virtual { (address to, uint64 amountSD) = _decodeSendPayload(_payload); if (to == address(0)) { to = address(0xdead); } uint amount = _sd2ld(amountSD); amount = _creditTo(_srcChainId, to, amount); emit ReceiveFromChain(_srcChainId, to, amount); } // payload: [packet type, address, amount ] // [1 byte, 20 byte, 8 byte ] function _decodeSendPayload(bytes memory _payload) internal view virtual returns (address to, uint64 amountSD) { require(_payload.toUint8(0) == PT_SEND && _payload.length == 41, "OFTCore: invalid payload"); to = _payload.toAddress(13); // drop the first 12 bytes of bytes32 amountSD = _payload.toUint64(33); } function _sd2ld(uint64 _amountSD) internal view virtual returns (uint) { return _amountSD * _ld2sdRate(); } function _creditTo( uint16, address _toAddress, uint _amount ) internal virtual override returns (uint) { _mint(_toAddress, _amount); return _amount; }
 

send and call

inside the _sendAndCallAck:
  • decode from, to, amount, payloadForCall, gasForCall
  • check whether token bridge has been executed, if not then first credit token to self and record it.
  • check whether to is a contract, if not, then no call
  • calls this.callOnOFTReceived to transfer token to the to and execute payloadForCall
  • if failed, then calls _storeFailedMessage (in NonblockingLzApp) to store it for later retry.
In the send and call process, certain logic should be executed successfully after token has been bridged to to , but the post-token-send logic may failed. To ensure the atomicity of this process, _sendAndCallAck sends the token to self, and transfer the token to the to in the callOnOFTReceived function where the post-token-send logic is implemented. So if the the post-token-send logic reverts, the token still remain in self not transferred to the to , which keep the atomicity of the process. And later this send and execute can be retried. The credited flag is used to record whether the token has been transferred to self before.
This may lead lock of token in two scenarios:
  1. the to is not a contract, so _sendAndCallAck will only mint token to self and return, thus not send token to the to
  1. the post-send-logic never succeed, which means the transfer token logic in the callOnOFTReceived will never be executed, the token will remain in self
// LayerZero-V1/contracts/token/oft/v2/OFTCoreV2.sol function _sendAndCallAck( uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload ) internal virtual { (bytes32 from, address to, uint64 amountSD, bytes memory payloadForCall, uint64 gasForCall) = _decodeSendAndCallPayload(_payload); bool credited = creditedPackets[_srcChainId][_srcAddress][_nonce]; uint amount = _sd2ld(amountSD); // credit to this contract first, and then transfer to receiver only if callOnOFTReceived() succeeds if (!credited) { amount = _creditTo(_srcChainId, address(this), amount); creditedPackets[_srcChainId][_srcAddress][_nonce] = true; } // check whether to is a contract, if not, then no call if (!_isContract(to)) { emit NonContractAddress(to); return; } // workaround for stack too deep uint16 srcChainId = _srcChainId; bytes memory srcAddress = _srcAddress; uint64 nonce = _nonce; bytes memory payload = _payload; bytes32 from_ = from; address to_ = to; uint amount_ = amount; bytes memory payloadForCall_ = payloadForCall; // no gas limit for the call if retry uint gas = credited ? gasleft() : gasForCall; (bool success, bytes memory reason) = address(this).excessivelySafeCall( gasleft(), 150, abi.encodeWithSelector(this.callOnOFTReceived.selector, srcChainId, srcAddress, nonce, from_, to_, amount_, payloadForCall_, gas) ); if (success) { bytes32 hash = keccak256(payload); emit CallOFTReceivedSuccess(srcChainId, srcAddress, nonce, hash); } else { // store the failed message into the nonblockingLzApp _storeFailedMessage(srcChainId, srcAddress, nonce, payload, reason); } }

Proxy Omni ERC20

The above if the implementation of the native Omni ERC20 built on LayerZero V1. But what if we want to endow standard ERC20 with the ability of bridge between different blockchains? Don’t worry, we can use a proxy to help with that. The basic logic is: if wants to bridge token from chain to chain , rather than burn the tokens on , will transfer the tokens to the proxy which also ensures the invariability of the supply of the token on two chains. Note here the proxy is not the proxy used in upgradable contract.
notion image
 
Let’s look at the proxy’s implementation. It only inherits the BaseOFTV2 to get the ability to interact with LayerZero protocol. It has a storage innertoken which is initialized during deployment which is the token we want to attach with omni ability. It has another additional storage outboundAmount which is used to record the total bridge-out amount, which can potentially be used by protocol to control the flux between different blockchains.
contract ProxyOFTV2 is BaseOFTV2 { using SafeERC20 for IERC20; IERC20 internal immutable innerToken; uint internal immutable ld2sdRate; // total amount is transferred from this chain to other chains, ensuring the total is less than uint64.max in sd uint public outboundAmount; constructor( address _token, uint8 _sharedDecimals, address _lzEndpoint ) BaseOFTV2(_sharedDecimals, _lzEndpoint) Ownable(msg.sender) { innerToken = IERC20(_token); (bool success, bytes memory data) = _token.staticcall( abi.encodeWithSignature("decimals()") ); require(success, "ProxyOFT: failed to get token decimals"); uint8 decimals = abi.decode(data, (uint8)); require( _sharedDecimals <= decimals, "ProxyOFT: sharedDecimals must be <= decimals" ); ld2sdRate = 10 ** (decimals - _sharedDecimals); } // [senn] omit for brevity... }
 
In its implementation of _debitFrom , it:
  • transfer token from the from to self
  • return dust to the sender
  • update and check outboundAmount
// LayerZero-V1/contracts/token/oft/v2/ProxyOFTV2.sol function _debitFrom( address _from, uint16, bytes32, uint _amount ) internal virtual override returns (uint) { require(_from == _msgSender(), "ProxyOFT: owner is not send caller"); // transfer token from from to self _amount = _transferFrom(_from, address(this), _amount); // _amount still may have dust if the token has transfer fee, then give the dust back to the sender (uint amount, uint dust) = _removeDust(_amount); if (dust > 0) innerToken.safeTransfer(_from, dust); // check total outbound amount outboundAmount += amount; uint cap = _sd2ld(type(uint64).max); require(cap >= outboundAmount, "ProxyOFT: outboundAmount overflow"); return amount; }
 
In the _creditTo, it transfers the token to receiver
function _creditTo( uint16, address _toAddress, uint _amount ) internal virtual override returns (uint) { outboundAmount -= _amount; // tokens are already in this contract, so no need to transfer if (_toAddress == address(this)) { return _amount; } return _transferFrom(address(this), _toAddress, _amount); }

Omni ERC721

The implemetation logic of Omni ERC721 is similar to the Omni ERC20.
The payload is : [_toAddress, _tokenIds]
The main difference between Omni ERC721 and Omni ERC20 is the gas consideration regarding the batch transfer of NFTs. In ERC721 protocol, each token is non-fungible whose transfer should be handled separately which means more gas consumption than Omni ERC20 when transferring multiple NFTs. To support unlimited token transfer under limited gas cap, Omni ERC721 choose to record and iterate the x-chain message to transfer all NFTs.
 
In the _nonblockingLzReceive it calls _creditTill to transfer multiple NFTs until the remaining gas cant handle of bridge logic of a single NFT. If there are remaining NFTs to be brideged, it records the hash of the payload and the index of NFT to be transfered in storedCredits. Later we can call clearCredits to proceed the remaining NFTs’ bridge.
 
Note that this implementation uses hash of the _payload as the key to store the bridge record, this is reasonable because NFTs are non-fungible, each token Id is unique, the _payload of two x-chain message won’t collide until one has been executed completedly. For example, it on chain has sent a x-chain message to send NFT 1, 2, 3 to on chain , these NFT 1,2,3 has been locked on the , attacker cant send another x-chain message with same NFTs 1,2,3. Only when those NFTs has all been bridged to and then bridged back to , then the owner of these token can send another message with the same payload.
// LayerZero-V1/contracts/token/onft721/ONFT721Core.sol struct StoredCredit { uint16 srcChainId; address toAddress; uint index; // which index of the tokenIds remain bool creditsRemain; } function _nonblockingLzReceive( uint16 _srcChainId, bytes memory _srcAddress, uint64, /*_nonce*/ bytes memory _payload ) internal virtual override { // decode and load the toAddress (bytes memory toAddressBytes, uint[] memory tokenIds) = abi.decode(_payload, (bytes, uint[])); address toAddress; assembly { toAddress := mload(add(toAddressBytes, 20)) } uint nextIndex = _creditTill(_srcChainId, toAddress, 0, tokenIds); if (nextIndex < tokenIds.length) { // not enough gas to complete transfers, store to be cleared in another tx bytes32 hashedPayload = keccak256(_payload); storedCredits[hashedPayload] = StoredCredit(_srcChainId, toAddress, nextIndex, true); emit CreditStored(hashedPayload, _payload); } emit ReceiveFromChain(_srcChainId, _srcAddress, toAddress, tokenIds); } // When a srcChain has the ability to transfer more chainIds in a single tx than the dst can do. // Needs the ability to iterate and stop if the minGasToTransferAndStore is not met function _creditTill( uint16 _srcChainId, address _toAddress, uint _startIndex, uint[] memory _tokenIds ) internal returns (uint) { uint i = _startIndex; while (i < _tokenIds.length) { // if not enough gas to process, store this index for next loop if (gasleft() < minGasToTransferAndStore) break; _creditTo(_srcChainId, _toAddress, _tokenIds[i]); i++; } // indicates the next index to send of tokenIds, // if i == tokenIds.length, we are finished return i; } // Public function for anyone to clear and deliver the remaining batch sent tokenIds function clearCredits(bytes memory _payload) external virtual nonReentrant { bytes32 hashedPayload = keccak256(_payload); require(storedCredits[hashedPayload].creditsRemain, "no credits stored"); (, uint[] memory tokenIds) = abi.decode(_payload, (bytes, uint[])); uint nextIndex = _creditTill( storedCredits[hashedPayload].srcChainId, storedCredits[hashedPayload].toAddress, storedCredits[hashedPayload].index, tokenIds ); require(nextIndex > storedCredits[hashedPayload].index, "not enough gas to process credit transfer"); if (nextIndex == tokenIds.length) { // cleared the credits, delete the element delete storedCredits[hashedPayload]; emit CreditCleared(hashedPayload); } else { // store the next index to mint storedCredits[hashedPayload] = StoredCredit( storedCredits[hashedPayload].srcChainId, storedCredits[hashedPayload].toAddress, nextIndex, true ); } }
 
In the implemetation of _creditTo:
  • if the token exists, then check the token belongs to self and transfer to the receiver. This token is bridged from , and now
  • if the token doesn’t exist, which means the token hasn’t be bridged before, then just mints to the receiver.
function _creditTo( uint16, address _toAddress, uint _tokenId ) internal virtual override { require( !_exists(_tokenId) || (_exists(_tokenId) && ERC721.ownerOf(_tokenId) == address(this)) ); if (!_exists(_tokenId)) { _safeMint(_toAddress, _tokenId); } else { _transfer(address(this), _toAddress, _tokenId); } }
 
In the _debitFrom:
  • check the msg.sender has authority to transfer the token, and the from is the owner of the token.
  • tranfer the token to self.
function _debitFrom( address _from, uint16, bytes memory, uint _tokenId ) internal virtual override { require( _isAuthorized(ownerOf(_tokenId), _msgSender(), _tokenId), "ONFT721: send caller is not owner nor approved" ); require( ERC721.ownerOf(_tokenId) == _from, "ONFT721: send from incorrect owner" ); _transfer(_from, address(this), _tokenId); }

Proxy Omni ERC721

Omni ERC721 also can use proxy to attach omni ability to standard ERC721.
 
The difference is, it implements onERC721Received and calls safeTransferFrom to transfer NFT from from to self in _debitFrom. And also uses safeTransferFrom rather than mint to give corresponding NFT to the receiver
function _debitFrom( address _from, uint16, bytes memory, uint _tokenId ) internal virtual override { require( _from == _msgSender(), "ProxyONFT721: owner is not send caller" ); token.safeTransferFrom(_from, address(this), _tokenId); } // TODO apply same changes from regular ONFT721 function _creditTo( uint16, address _toAddress, uint _tokenId ) internal virtual override { token.safeTransferFrom(address(this), _toAddress, _tokenId); } function onERC721Received( address _operator, address, uint, bytes memory ) public virtual override returns (bytes4) { // only allow `this` to transfer token from others if (_operator != address(this)) return bytes4(0); return IERC721Receiver.onERC721Received.selector; }

Omni ERC1155

The payload is : [_toAddress, _tokenIds, _amounts]
 
It has sendFrom and sendBatchFrom to bridge token according to the amount of token types.
// LayerZero-V1/contracts/token/onft1155/ONFT1155Core.sol function sendFrom( address _from, uint16 _dstChainId, bytes memory _toAddress, uint _tokenId, uint _amount, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams ) public payable virtual override { _sendBatch( _from, _dstChainId, _toAddress, _toSingletonArray(_tokenId), _toSingletonArray(_amount), _refundAddress, _zroPaymentAddress, _adapterParams ); } function sendBatchFrom( address _from, uint16 _dstChainId, bytes memory _toAddress, uint[] memory _tokenIds, uint[] memory _amounts, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams ) public payable virtual override { _sendBatch(_from, _dstChainId, _toAddress, _tokenIds, _amounts, _refundAddress, _zroPaymentAddress, _adapterParams); }
// LayerZero-V1/contracts/token/onft1155/ONFT1155Core.sol function _sendBatch( address _from, uint16 _dstChainId, bytes memory _toAddress, uint[] memory _tokenIds, uint[] memory _amounts, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams ) internal virtual { _debitFrom(_from, _dstChainId, _toAddress, _tokenIds, _amounts); bytes memory payload = abi.encode(_toAddress, _tokenIds, _amounts); if (_tokenIds.length == 1) { if (useCustomAdapterParams) { _checkGasLimit(_dstChainId, FUNCTION_TYPE_SEND, _adapterParams, NO_EXTRA_GAS); } else { require(_adapterParams.length == 0, "LzApp: _adapterParams must be empty."); } _lzSend(_dstChainId, payload, _refundAddress, _zroPaymentAddress, _adapterParams, msg.value); emit SendToChain(_dstChainId, _from, _toAddress, _tokenIds[0], _amounts[0]); } else if (_tokenIds.length > 1) { if (useCustomAdapterParams) { _checkGasLimit(_dstChainId, FUNCTION_TYPE_SEND_BATCH, _adapterParams, NO_EXTRA_GAS); } else { require(_adapterParams.length == 0, "LzApp: _adapterParams must be empty."); } _lzSend(_dstChainId, payload, _refundAddress, _zroPaymentAddress, _adapterParams, msg.value); emit SendBatchToChain(_dstChainId, _from, _toAddress, _tokenIds, _amounts); } }
 
The _debitFrom is to check whether the msg.sender has the approval to transfer those tokens, and burn those tokens.
function _debitFrom( address _from, uint16, bytes memory, uint[] memory _tokenIds, uint[] memory _amounts ) internal virtual override { address spender = _msgSender(); require( spender == _from || isApprovedForAll(_from, spender), "ONFT1155: send caller is not owner nor approved" ); _burnBatch(_from, _tokenIds, _amounts); }
 
the _creditTo is just batch mint tokens to the receiver.
function _creditTo( uint16, address _toAddress, uint[] memory _tokenIds, uint[] memory _amounts ) internal virtual override { _mintBatch(_toAddress, _tokenIds, _amounts, ""); }
 

Proxy Omni ERC1155

Proxy Omni ERC1155 implements onERC1155Received and onERC1155BatchReceived and uses safeBatchTransferFrom to handle bridge-out and bridge-in logic.
// LayerZero-V1/contracts/token/onft1155/ProxyONFT1155.sol function _debitFrom( address _from, uint16, bytes memory, uint[] memory _tokenIds, uint[] memory _amounts ) internal virtual override { require( _from == _msgSender(), "ProxyONFT1155: owner is not send caller" ); token.safeBatchTransferFrom( _from, address(this), _tokenIds, _amounts, "" ); } function _creditTo( uint16, address _toAddress, uint[] memory _tokenIds, uint[] memory _amounts ) internal virtual override { token.safeBatchTransferFrom( address(this), _toAddress, _tokenIds, _amounts, "" ); } function onERC1155Received( address _operator, address, uint, uint, bytes memory ) public virtual override returns (bytes4) { // only allow `this` to tranfser token from others if (_operator != address(this)) return bytes4(0); return this.onERC1155Received.selector; } function onERC1155BatchReceived( address _operator, address, uint[] memory, uint[] memory, bytes memory ) public virtual override returns (bytes4) { // only allow `this` to tranfser token from others if (_operator != address(this)) return bytes4(0); return this.onERC1155BatchReceived.selector; }

Appendix

SharedDecimal

Tokens on different blockchains may have different decimals. For example, on has decimal 3. on has decimal 2. If user wants to transfer 1 (1*10 **3) from to , the actual minted amount of will be 1* 10 **2 rather than 1*10 **3.
In LayerZero, the x-chain message of token bridge contains the token amount in the format of the destination chain(use decimal of contract on destination chain). So in the above case, the x-chain message should be: bridge 1*10**2 token.
LayerZero use sharedDecimals and ld2sdRate to faciliate the conversion, sharedDecimals is the collective part of decimal between two contracts on different blockchains which will be used in x-chain message to represent token amount to be bridged:
In the above example, if user wants to send 1.012 token, the actual sent token will be 1.01(remove the dust to fit the destination chain contract’s decimal)
// LayerZero-V1/contracts/token/oft/v2/OFTCoreV2.sol function _send( address _from, uint16 _dstChainId, bytes32 _toAddress, uint _amount, address payable _refundAddress, address _zroPaymentAddress, bytes memory _adapterParams ) internal virtual returns (uint amount) { _checkGasLimit(_dstChainId, PT_SEND, _adapterParams, NO_EXTRA_GAS); // remove dust to fit the decimal on (amount, ) = _removeDust(_amount); amount = _debitFrom(_from, _dstChainId, _toAddress, amount); // amount returned should not have dust require(amount > 0, "OFTCore: amount too small"); bytes memory lzPayload = _encodeSendPayload(_toAddress, _ld2sd(amount)); _lzSend(_dstChainId, lzPayload, _refundAddress, _zroPaymentAddress, _adapterParams, msg.value); emit SendToChain(_dstChainId, _from, _toAddress, amount); } function _removeDust(uint _amount) internal view virtual returns (uint amountAfter, uint dust) { dust = _amount % _ld2sdRate(); amountAfter = _amount - dust; }
 
ld2sdRate is set at the deployment and is immutable.
uint internal immutable ld2sdRate; constructor( string memory _name, string memory _symbol, uint8 _sharedDecimals, address _lzEndpoint ) ERC20(_name, _symbol) BaseOFTV2(_sharedDecimals, _lzEndpoint) Ownable(msg.sender) { uint8 decimals = decimals(); require( _sharedDecimals <= decimals, "OFT: sharedDecimals must be <= decimals" ); ld2sdRate = 10 ** (decimals - _sharedDecimals); } function _ld2sdRate() internal view virtual override returns (uint) { return ld2sdRate; }
 

Memo