First Step with LayerZero: Basics for Developers
LayerZero is a communication protocol between blockchains, designed to provide a secure and efficient transfer of data and resources between different blockchain networks. This enables developers to create applications that operate on multiple networks simultaneously, enhancing their functionality and making it easier for users to access various services without additional complications.
How Does Data Transfer Between Blockchains Work?
The process of transferring data between blockchains starts with preparing and encoding the data on one network (Chain A). Then, the _lzSend() method is used to send the encoded data to another network (Chain B) via the LayerZero infrastructure. Upon arrival, the _lzReceive() method on Chain B decodes this data, allowing it to be read and further utilized.
For such a transfer to take place, both blockchains must be properly configured for mutual communication. Each one uses the SetPeer() method to establish a trusted connection with the other blockchain, allowing them to exchange data securely. Before sending data, Chain A also calls the quote() function to get an estimated cost of the transfer, which helps plan the required fees and ensures that the process runs smoothly.
Data Encoding and Decoding
In Solidity, the programming language used to create smart contracts on EVM-compatible platforms, data encoding and decoding are done using the abi.encode, abi.encodePacked and abi.decode functions. The abi.encode functions converts various data types into a byte array (called "payload") that can be easily transmitted between blockchains. Meanwhile, abi.decode allows this encoded byte array to be read and transformed back into the original data types.
abi.encode, abi.decode
To better understand how abi.encode works, let’s look at how data is encoded in Solidity. For example, consider a case where we send two variables: uint8 and bytes32:
uint8 id = 1;
bytes32 text = "DigitalPulse24"; // Converted to bytes32
bytes memory payload = abi.encode(id, text);
The encoded payload, which will be received by _lzReceive, will look like this:
0x
0000000000000000000000000000000000000000000000000000000000000001 // (32 bytes: value of id = 1)
4469676974616c50756c73653234000000000000000000000000000000000000 // (32 bytes: text as bytes32)
The total data size is 64 bytes (2 segments of 32 bytes each).
Now, let’s consider a situation where we send uint8 and string variables:
uint8 id = 1;
string memory text = "DigitalPulse24";
bytes memory payload = abi.encode(id, text);
The encoded payload, which will be received by _lzReceive, will look like this:
0x
0000000000000000000000000000000000000000000000000000000000000001 // (32 bytes: value of id = 1)
0000000000000000000000000000000000000000000000000000000000000040 // (32 bytes: offset to the start of the string)
000000000000000000000000000000000000000000000000000000000000000e // (32 bytes: length of the string variable = 14 characters)
4469676974616c50756c73653234000000000000000000000000000000000000 // (32 bytes: text as bytes32 - '44 = D', '69 = i')
The total data size in this case is 128 bytes (4 segments of 32 bytes each).
The difference arises because string is a variable-length data type. To encode a string, additional information is required, such as an offset and the length of the string, which increases the overall size of the encoded payload. In abi.encode, each value is stored in 32-byte segments, so a dynamic type like string takes up more space than a fixed-length type like bytes32.
abi.encodePacked
Besides the abi.encode and abi.decode functions, Solidity also provides the abi.encodePacked function, which is used for more efficient data encoding. abi.encodePacked returns a more compact form of encoding, which is useful when space optimization is a priority. This function encodes data into a byte array without padding to 32 bytes, resulting in a shorter encoded output.
Let’s use the example with uint8 and bytes32 again, but this time using the abi.encodePacked method.
uint8 id = 1;
bytes32 text = "DigitalPulse24"; // Converted to bytes32
encodePayload = abi.encodePacked(id, text);
The resulting output is as follows:
0x
01 // (1 byte: value of id = 1)
4469676974616c50756c73653234000000000000000000000000000000000000 // (32 bytes: text as bytes32)
The total data size is 33 bytes.
It might seem that it is always better to use abi.encodePacked, but it is important to consider a few key points. With abi.encode, we can be certain that each encoded value occupies exactly 32 bytes, which simplifies the decoding process. However, when using abi.encodePacked, we must know the exact structure of incoming data to decode it correctly and apply the appropriate shifts. Furthermore, for variable types like string or bytes, using abi.encodePacked is not recommended. The lack of additional information about offsets and lengths of these types makes decoding more complex and prone to errors.
Simulating Data Transfer in LayerZero
Remix IDE is an excellent tool for testing smart contracts and has been used to perform this simulation. This is a simple demonstration of the process of transferring data between two points, without actual connections between blockchain networks.
Here is an example of the LayerZeroSimulation contract, which illustrates the basic principles of encoding and decoding data during information transfer:
contract LayerZeroSimulation {
bytes encodePayload;
function _lzSend(uint256 id, string calldata text) public {
encodePayload = abi.encode(id, text);
}
function getEncodePayload() public view returns (bytes memory){
return encodePayload;
}
function _lzReceive(bytes calldata payload) public pure returns (uint256, string memory){
(uint256 Id) = abi.decode(payload, (uint256));
uint256 offset = abi.decode(payload[32:], (uint256));
uint256 strLength = abi.decode(payload[offset:], (uint256));
bytes memory strBytes = new bytes(strLength);
for (uint i = 0; i < strLength; i++) {
strBytes[i] = payload[offset + 32 + i];
}
string memory text = string(strBytes);
return (Id, text);
}
}
In the first step, the _lzSend() method is used to encode the data to be sent. The encoded data can then be viewed using the getEncodePayload() function, which returns the payload as a bytes. This encoded data simulates the transfer between blockchains. When the payload is sent to another “chain,” the _lzReceive() method is responsible for decoding it. A crucial aspect of this process is the correct pointer shift to skip the metadata and read only the actual content. Using the shift payload[offset + 32 + i] allows skipping additional information and retrieving the clean text. This approach effectively demonstrates how data transfer works in LayerZero and highlights the importance of managing encoding and decoding processes correctly.
Conclusion
This article has provided a solid theoretical foundation on data transfer in LayerZero and discussed key concepts such as data encoding and decoding in Solidity. It is an excellent starting point for further exploring the development of decentralized applications. I also encourage you to check out my next article, where I demonstrate the practical application of these concepts by creating a Voting OApp to better understand how to apply this knowledge in real-world scenarios.