Technology, Tutorials, for Developer, Tool support

Klaytn에서 LayerZero 크로스체인 메시지 전송하는 방법

들어가며

상호운용성은 여러 블록체인이 서로 다른 환경 간에 원활하게 통신하거나 데이터를 교환할 수 없는 블록체인 기술의 한계로 널리 알려져 있습니다. 이러한 한계로 인해 서로 다른 블록체인 생태계 간의 상호 작용과 소통을 촉진하기 위한 크로스체인 솔루션 구축의 필요성이 대두되었습니다. 이를 통해 자산, 데이터, 스마트 컨트랙트가 체인 간에 자유롭게 이동하여 협업을 촉진하고 더욱 연결된 블록체인 생태계를 만들 수 있습니다.

이런 상호운용성의 한계를 극복하기 위해 LayerZero가 등장했습니다. LayerZero는 신뢰가 필요 없는 옴니체인 상호운용성 프로토콜로, 다양한 크로스체인 애플리케이션을 구축할 수 있는 강력한 로우레벨 통신 프리미티브(primitive)를 제공합니다. LayerZero를 사용하면 고립된 생태계 간에 고급 상호운용성과 기능을 제공할 수 있습니다. 

이제 개발자들은 LayerZero의 옴니체인 솔루션을 사용하여 이전에는 어렵거나 불가능했던 Ethereum, Klaytn, Polygon Optimism 등의 기능을 활용할 수 있는 디앱을 구축할 수 있습니다. 이번 아티클에서는 레이어제로에 대해 알아보고, 레이어제로를 사용해 Klaytn Baobab 테스트넷에서 다른 체인인 Mumbai로 크로스체인 메시지를 전송하는 방법을 알아보겠습니다.

LayerZero 프로토콜 개요

Image from LayerZero

LayerZero는 여러 체인에서 크로스체인 메시징을 가능하게 하며, 블록체인 상호운용성을 위한 혁신적인 솔루션으로 작용합니다. 레이어제로의 상호 통신 프로토콜은 소스 체인(tA)의 트랜잭션이 커밋되고 유효한 경우에만 메시지가 목적지 체인에 전달된다는 유효 전달(valid delivery) 개념을 기반으로 합니다. 결과적으로 두 개의 독립적인 주체가 트랜잭션(이 경우 tA)의 유효성을 확인하면 목적지 체인에서 tA가 유효하다는 것을 확인할 수 있습니다. 이러한 토대 위에서 레이어제로 프로토콜은 발전합니다. 

이 프로토콜의 핵심은 다음과 같은 구성 요소를 기반으로 합니다.

  • LayerZero Endpoint: 경량 온체인 클라이언트(스마트 콘트랙트). 이 클라이언트는 각 (지원되는) 체인에 존재하며, 레이어제로 엔드포인트가 있는 모든 체인은 LayerZero 엔드포인트가 있는 다른 체인과 크로스체인 트랜잭션을 수행할 수 있습니다. 엔드포인트의 목적은 사용자가 LayerZero 프로토콜 백엔드를 사용하여 메시지를 전송할 수 있도록 하여 유효한 전달을 보장하는 것입니다.
  • Oracle: 독립적인 엔티티가 트랜잭션의 유효성을 확인하기 위해 LayerZero는 오라클을 사용하여 소스 체인에 있는 tA가 포함된 블록의 블록 헤더를 목적지 체인에 제공합니다.
  • Relayer: 릴레이어는 트랜잭션의 유효성을 검증하는 또 다른 독립 주체로서, 앞서 언급한 트랜잭션과 관련된 증명을 제공합니다.
참고: 오라클은 제3자 탈중앙화 오라클 제공자가 될 수 있으며, 사용자가 자체적인 릴레이어 서비스를 구현할 수도 있습니다. 실제로 LayerZero는 Relayer 서비스를 제공하며, Oracle은 Chainlink의 탈중앙 오라클 네트워크에서 처리합니다.

시작하기

자, 이제 LayerZero 프로토콜에 대해 배웠으니 이제 클레이튼 테스트넷에서 Polygon Mumbai로 크로스체인 메시지를 전송해 보겠습니다. 이 튜토리얼에서는 메시지를 주고받기 위해 LayerZero엔드포인트와 상호작용하는 스마트 컨트랙트를 배포하겠습니다.

전제 조건

이 튜토리얼을 시작하기 전에 다음의 사항을 준비하세요.

CrossChainHelloWorld 컨트랙트 생성하기

이 섹션에서는 Remix IDE에서 CrossChainHelloWorld 메시징 컨트랙트를 생성해 보겠습니다. Remix IDE에서 파일 탐색기로 이동하여 컨트랙트 폴더에 `crossChainHelloWorld.sol`이라는 새 파일을 생성합니다. 새로 생성한 파일에 아래 코드를 붙여넣습니다.

solidity

// SPDX-License-Identifier: Unlicensed

pragma solidity ^0.8.17;

// This line imports the NonblockingLzApp contract from LayerZero's solidity-examples Github repo.

import "https://github.com/LayerZero-Labs/solidity-examples/blob/main/contracts/lzApp/NonblockingLzApp.sol";

// This contract is inheriting from the NonblockingLzApp contract.

contract CrossChainHelloWorld is NonblockingLzApp {

    // A public string variable named "data" is declared. This will be the message sent to the destination.

    string public data = "Nothing received yet";

    // A uint16 variable named "destChainId" is declared to hold the LayerZero Chain Id of the destination blockchain.

    uint16 destChainId;

    //This constructor initializes the contract with our source chain's _lzEndpoint.

    constructor(address _lzEndpoint, address initialOwner) NonblockingLzApp(_lzEndpoint) Ownable(initialOwner) {

        // Below is an "if statement" to simplify wiring our contract's together.

        // In this case, we're auto-filling the dest chain Id based on the source endpoint.

        // For example: if our source endpoint is Klaytn Baobab, then the destination is Polygon Mumbai.

        // NOTE: This is to simplify our tutorial, and is not standard wiring practice in LayerZero contracts.

        // Wiring 1: If Source == Klaytn Baobab, then Destination Chain = Polygon Mumbai

        if (_lzEndpoint == 0x6aB5Ae6822647046626e83ee6dB8187151E1d5ab) destChainId = 10109;

        // Wiring 2: If Source == Polygon Mumbai, then Destination Chain = Klaytn Baobab

        if (_lzEndpoint == 0xf69186dfBa60DdB133E91E9A4B5673624293d8F8) destChainId = 10150;

    }

    // This function is called when data is received. It overrides the equivalent function in the parent contract.

    function _nonblockingLzReceive(uint16, bytes memory, uint64, bytes memory _payload) internal override {

       // The LayerZero _payload (message) is decoded as a string and stored in the "data" variable.

       data = abi.decode(_payload, (string));

    }

    // This function is called to send the data string to the destination.

    // It's payable, so that we can use our native gas token to pay for gas fees.

    function send(string memory _message) public payable {

        // The message is encoded as bytes and stored in the "payload" variable.

        bytes memory payload = abi.encode(_message);

        // The data is sent using the parent contract's _lzSend function.

        _lzSend(destChainId, payload, payable(msg.sender), address(0x0), bytes(""), msg.value);

    }

    // This function allows the contract owner to designate another contract address to trust.

    // It can only be called by the owner due to the "onlyOwner" modifier.

    // NOTE: In standard LayerZero contract's, this is done through SetTrustedRemote.

    function trustAddress(address _otherContract) public onlyOwner {

        trustedRemoteLookup[destChainId] = abi.encodePacked(_otherContract, address(this));   

    }

    // This function estimates the fees for a LayerZero operation.

    // It calculates the fees required on the source chain, destination chain, and by the LayerZero protocol itself.

    // @param dstChainId The LayerZero endpoint ID of the destination chain where the transaction is headed.

    // @param adapterParams The LayerZero relayer parameters used in the transaction.

    // Default Relayer Adapter Parameters = 0x00010000000000000000000000000000000000000000000000000000000000030d40

    // @param _message The message you plan to send across chains. 

    // @return nativeFee The estimated fee required denominated in the native chain's gas token.

    function estimateFees(uint16 dstChainId, bytes calldata adapterParams, string memory _message) public view returns (uint nativeFee, uint zroFee) {

        //Input the message you plan to send.

        bytes memory payload = abi.encode(_message);

        // Call the estimateFees function on the lzEndpoint contract.

        // This function estimates the fees required on the source chain, the destination chain, and by the LayerZero protocol.

        return lzEndpoint.estimateFees(dstChainId, address(this), payload, false, adapterParams);

    }

}

위의 코드는 모든 오류와 예외를 자동으로 처리하여 대상 LayerZero 엔드포인트의 메시지 큐가 차단되지 않도록 하는 레이어인 NonblockingLzApp.sol을 상속받습니다. 위의 코드에 제공된 코멘트를 읽어보시면 그 작동 원리를 이해하실 수 있습니다. 하지만, 이 튜토리얼에서는 실행 순서대로 함수를 코드로 살펴볼 것입니다: 

  • trustAddress: 기본적으로 `SetTrustedRemoteAddress` 함수를 실행해야 하지만, 이 가이드에서는 LayerZero 사용자 애플리케이션 컨트랙트가 메시지를 수락할 컨트랙트 주소를 저장하기 위해 `trustAddress` 함수를 만들었습니다. 신뢰할 수 있는 리모트 설정에 대한 자세한 내용은 Set Trusted Remotes을 참조하세요.
  • estimateFees: 이 함수는 메시지를 보내기 위해 지불할 네이티브 가스 토큰의 양을 계산하는 데 도움이 됩니다. 이를 위해 LayerZero는 대상 체인아이디(destination chainId), 어댑터 매개변수(adapter parameters), 전송할 메시지(message)가 주어지면 Oracle과 Relayer 서비스를 사용합니다.
    • 이 튜토리얼에서는 대상 체인아이디로 10109, 메시지로 Klaytn의 HelloWorld, 어댑터 파라미터로 `0x000100000000000000000000000000000000000000030d40`을 사용하며, 어댑터 파라미터 인코딩 방법은 Relayer Adapter Parameters에서 확인할 수 있습니다. 요금이 어떻게 산정되는지 자세히 알아보려면 Estimating Message Fees을 참조하세요.
  • send: 이 기능은 전송하고자 하는 메시지를 대상 체인으로 전송합니다. 이 기능은 유료 기능이며, 트랜잭션과 함께 미리 계산된 수수료를 보내야 합니다. 보내기 기능에 대해 자세히 알아보려면 Send Messages를 참조하세요.

Klaytn Baobab 테스트넷에 Remix와 컨트랙트 배포하기

crossChainHelloWorld 컨트랙트를 생성했으니, 이 섹션에서는 소스 체인(Klaytn Baobab)과 목적지 체인(Mumbai) 모두에 동일한 컨트랙트를 배포해 보겠습니다. 

소스 체인에 배포하기 (Klaytn Baobab)

다음 단계로 소스 체인에 배포할 것입니다:

Remix IDE 에서

  1. Solidity Compiler Tab에서 crossChainHelloWorld 컨트랙트를 컴파일합니다.
  2. 트랜잭션 배포 및 실행 탭에서Injected Web3 환경을 사용하여 메타마스크를 연결합니다. 메타마스크가 Klaytn Baobob에 연결되어 있는지 확인합니다.
  3. 배포하기 전에 생성자 인자 필드(constructor argument field)에 Klaytn Baobab LayerZero 엔드포인트 컨트랙트 주소 `0x6aB5Ae6822647046626e83ee6dB8187151E1d5ab`를 붙여넣습니다.
  4. 배포 버튼을 클릭하고 배포가 성공하면 해당 주소를 복사합니다.

대상 체인에 배포 (Mumbai)

이제, 아래 단계에 따라 대상 체인에 배포할 것입니다.

Remix IDE에

  1. Solidity Compiler Tab에서 crossChainHelloWorld 컨트랙트를 컴파일합니다,
  2. Deploy & run transaction Tab에서 Injected Web3 환경을 사용하여 메타마스크를 연결합니다. 메타마스크가 뭄바이에 연결되어 있는지 확인합니다.
  3. 배포하기 전에 생성자 인수 필드(constructor argument field)에 폴리곤 뭄바이 LayerZero 엔드포인트 컨트랙트 주소 `0xf69186dfBa60DdB133E91E9A4B5673624293d8F8` 를 붙여넣습니다.
  4. 배포 버튼을 클릭하고, 배포가 성공하면 해당 주소를 복사합니다.
참고: 뭄바이가 아닌 다른 EVM 체인을 대상 체인으로 사용하려는 경우, 여기에서 지원되는 다른 체인(테스트넷) 엔드포인트를 확인할 수 있습니다.

신뢰할 수 있는 소스 추가하기

이 섹션에서는 신뢰할 수 있는 소스를 설정하겠습니다. 보안 관점에서 컨트랙트는 알려진 컨트랙트로부터만 메시지를 수신하여 서로 안전하게 연결되어야 하기 때문에 이것이 핵심입니다.

이를 위해 LZApp.sol에서 LayerZero는 각 체인에서 신뢰할 수 있는 소스 하나를 trustedRemoteLookup 맵에 저장했습니다. 따라서 앞서 설명한 것처럼 신뢰할 수 있는 리모트를 설정하려면 `SetTrustedRemoteAddress` 함수를 호출해야 합니다. 간단하게 하기 위해 이 함수를 신뢰 주소 함수로 추상화했으며, 이 함수가 컨트랙트를 연결하기 위해 호출할 것입니다.

자, 이제 컨트랙트를 서로 연결해 봅시다!

소스 체인에서 연결

Remix IDE 에서

  1. Injected Provider 환경에 있고 컨트랙트가 여전히 “CrossChainHelloWorld.sol”인지 확인합니다.
  2. 배포된 컨트랙트 탭에서 배포된 컨트랙트를 확인할 수 있다면, `trustAddress` 함수를 찾아 뭄바이 네트워크에 배포한 컨트랙트의 주소를 붙여넣습니다. 그렇지 않으면 소스 컨트랙트(Klaytn Baobab)의 주소를 가져와 주소 입력란(At Address)에 붙여넣어 컨트랙트 인스턴스를 로드합니다. 이 작업이 완료되면 trustAddress 함수를 찾아 Mumbai 네트워크에 배포한 컨트랙트의 주소를 붙여넣습니다.
  3. 트랜잭션을 클릭하고 메타마스크에서 트랜잭션을 확인합니다.

대상 체인에 연결하기

Remix IDE 에서

  1. Injected Provider 환경에 있고, 컨트랙트가 여전히 “CrossChainHelloWorld.sol”인지 확인합니다.
  2. 배포된 컨트랙트 탭에서 뭄바이 네트워크에 배포한 컨트랙트를 여전히 사용할 수 있다면 `trustAddress` 함수를 찾아 클레이튼 바오밥 네트워크에 배포한 컨트랙트의 주소를 붙여넣습니다. 그렇지 않으면, 대상 컨트랙트의 주소(Mumbai)를 가져와 주소 입력란(At Address)에 붙여넣어 컨트랙트 인스턴스를 로드합니다. 이 작업이 완료되면 신뢰 주소 함수를 찾아 클레이튼 바오밥 네트워크에 배포한 컨트랙트의 주소를 붙여넣습니다.
  3. 트랜잭션을 클릭하고 메타마스크에서 트랜잭션을 확인합니다.

크로스 체인 트랜잭션에 대한 가스 수수료 추정하기

컨트랙트를 서로 연결했으면 연결된 컨트랙트 간에 트랜잭션을 전송할 수 있어야 합니다. 하지만 그 전에 크로스 체인 트랜잭션에 대한 가스 수수료를 추정해 보겠습니다.

앞서 설명한 것처럼 estimateFees 함수를 호출하여 이를 수행할 것입니다. 이 함수는 세 가지를 인자로 받습니다:

  • destination chainId: 10109
  • adapter Parameter: 0x00010000000000000000000000000000000000000000000000000000000000030d40
  • message: Hello World from Klaytn

함수를 실행하면 Wei로 예상되는 가스 요금이 나옵니다. 함수를 실행한 후 얻은 값은 `78536182898815077`입니다.

LayerZero로 Klaytn Baobab에서 크로스체인 메시지 보내기

이제 크로스체인 트랜잭션을 수행할 차례입니다. 이를 위해 value 필드에 위와 동일한 message 와 위에서 계산한 Wei 값을 입력하여 Send 함수를 호출하면 성공적으로 실행할 수 있습니다.

대상 체인으로 전송된 메시지를 확인하려면 메타마스크를 통해 대상 네트워크에 연결해야 합니다. Injected Provider  환경에 있고 선택한 컨트랙트가 여전히 “CrossChainHelloWorld.sol”인지 확인하세요. 그런 다음 대상 컨트랙트의 주소를 가져와 주소 입력란(At Address)에 붙여넣습니다. 버튼을 누르면 결과 컨트랙트를 사용하여 대상 컨트랙트의 `data`변수를 볼 수 있을 것입니다.

탐색기에서 크로스체인 메시지 트랜잭션 확인하기

트랜잭션을 전송한 후 Klaytn Baobab Explorer에 들어가 트랜잭션 해시를 사용해 트랜잭션을 확인할 수 있어야 합니다. 성공했다면, 트랜잭션이 확인되고 트랜잭션이 입력된 흔적을 확인할 수 있습니다.

또한, 소스 체인의 트랜잭션 해시를 LayerZero scan 스캔에 붙여넣어 크로스 체인 메시지의 상태를 확인할 수 있습니다.

Conclusion

튜토리얼을 끝까지 마치신 것을 축하드립니다! 이번 튜토리얼을 통해 LayerZero에 대해 알아보고, 클레이튼에서 크로스체인 메시지를 보내는 방법을 배웠습니다. 이제부터 여러분은 LayerZero를 사용해 서로 다른 블록체인 시스템을 연결할 수 있는 힘과 기회를 발견할 수 있습니다. 크로스 체인 탈중앙화 거래소, 크로스 체인 대출 등 이전에는 불가능했던 다양한 종류의 대규모 애플리케이션을 구축할 수 있기 때문에, 그 기회는 무궁무진합니다.

이 튜토리얼은 LayerZero의 기능 중 일부에 불과하며, 크로스체인 상호운용성 프로토콜에는 아직 더 많은 것이 있습니다. 자세한 내용은  LayerZero Docs에서 확인하시기 바랍니다. 궁금한 점이 있으시다면Klaytn Forum을 방문해 주세요.