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-Labs • Updated 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.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 callnonblockingLzReceive
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
ofLzApp
- override
nonblockingLzReceive
ofNonblockingLzApp
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
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 .
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 _adapterParams
and 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
orsend_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 theto
and executepayloadForCall
- if failed, then calls
_storeFailedMessage
(inNonblockingLzApp
) 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:
- the
to
is not a contract, so_sendAndCallAck
will only mint token toself
and return, thus not send token to theto
- the post-send-logic never succeed, which means the transfer token logic in the
callOnOFTReceived
will never be executed, the token will remain inself
// 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.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
toself
- 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 thereceiver
. 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 thefrom
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; }