LayerZero Omnichain Fungible Tokens Adapter

LayerZero

Overview

이전 포스팅에서 LayerZero를 사용하여 ONFT(Omnichain Non-Fungible Tokens)를 배포하고 실행하는 과정을 다뤄보았습니다.

이번 포스팅에선 OFTAdapter(Omnichain Fungible Tokens Adapter)를 배포하고 실행하는 과정을 다뤄보겠습니다.

LayerZero V2 OFTAdapter

ERC20의 safeTransferFrom을 사용하여 spender로부터 OFT Adapter 컨트랙트로 토큰을 전송하면, 페어링된 OFT 컨트랙트를 통해 선택한 대상 체인(Chain B)에서 동일한 수량의 토큰이 _mint 됩니다.

소스 체인의 OFT Adapter에 있는 토큰을 lock하려면 OFT.send(Chain B)를 호출해야 하며, 이로 인해 토큰이 _burn되고 프로토콜을 통해 메시지가 전송되어 Adapter에서 수신 주소(Chain A)로 ERC20.safeTransfer가 실행됩니다.

image.png

Installation

아래 명령어로 OFTAdapter가 포함된 프로젝트를 생성할 수 있습니다.

OFTAdapter 를 선택하면 해당 프로젝트에 OFT가 포함되어 있습니다.

npx create-lz-oapp

image.png

이후 아래 명령어를 사용하여 패키지를 추가 설치 해줍니다.

pnpm add @layerzerolabs/oft-evm

Deploy

  1. ERC20 Token 컨트랙트 배포

    OFTAdapter를 사용하기 위해선 기존 배포되어 있는 ERC20 Token 컨트랙트가 필요합니다.

    테스트 환경이기 때문에 직접 배포하여 사용합니다.

  2. hardhat.config.ts 구성

    본문에서는 amoy를 사용하지 않을거라 제거했음

    oftAdapter 부분에 0번 과정에서 배포한 tokenAddress를 입력 해 주었음

    image.png

  3. 빌드

     pnpm install # Install dependencies
     pnpm compile # Compile contract
    

    image.png

  4. .env 구성
    • Rename .env.example -> .env
    • Choose your preferred means of setting up your deployer wallet/account:
     MNEMONIC="test test test test test test test test test test test junk"
     or...
     PRIVATE_KEY="0xabc...def"
    
  5. 배포

     npx hardhat lz:deploy
    

    OFTAdapter 설정을 추가해주었기 때문에 sepolia에는 OFTAdapter만 배포 되었습니다.

    image.png

  6. layerzero.config.ts 구성

    실제 컨트랙트를 연결할 체인들을 구성

    본문에서는 amoy를 사용하지 않아 제거했음

    image.png

  7. Contract wire

     npx hardhat lz:oapp:wire --oapp-config layerzero.config.ts
    

    image.png

  8. ERC20.approve 수행
    • ERC20 토큰에 대해서 OFTAdapter가 컨트롤 할 수 있도록 approve를 수행해줍니다.
    • spender는 OFTAdapter 입니다.
  9. sendToken 생성 후 실행

     // tasks/sendToken.ts
        
     import { task } from 'hardhat/config';
     import { HardhatRuntimeEnvironment } from 'hardhat/types';
        
     import { Options } from '@layerzerolabs/lz-v2-utilities';
        
     export default task('sendToken', 'Send a token to the destination chain')
         .addParam('dstNetwork', 'The destination network name (from hardhat.config.ts)')
         .addParam('amount', 'The amount of tokens to send')
         .setAction(async (taskArgs, hre: HardhatRuntimeEnvironment) => {
             const { amount, dstNetwork } = taskArgs;
             const [signer] = await hre.ethers.getSigners();
        
             const addressAsBytes32 = "0x0000000000000000000000002f32e86e8fc5e762aa32a09d4970cb3216fefaf4";
        
             // Get destination network's EID
             const dstNetworkConfig = hre.config.networks[dstNetwork];
             const dstEid = dstNetworkConfig.eid;
        
             // Get current network's EID
             const srcNetworkConfig = hre.config.networks[hre.network.name];
             const srcEid = srcNetworkConfig?.eid;
        
             console.log('Sending message:');
             console.log('- From:', signer.address);
             console.log('- Source network:', hre.network.name, srcEid ? `(EID: ${srcEid})` : '');
             console.log('- Destination:', dstNetwork || 'unknown network', `(EID: ${dstEid})`);
             console.log('- Amount:', amount);
        
             const myOFTAdapter = await hre.deployments.get('MyOFTAdapter');
             const contract = await hre.ethers.getContractAt('MyOFTAdapter', myOFTAdapter.address, signer);
        
             // Add executor options with gas limit
             const options = Options.newOptions().addExecutorLzReceiveOption(200000, 0).toBytes();
        
             // Get quote for the message
             console.log('Getting quote...');
             const sendParam = {
                 dstEid: dstEid,
                 to: addressAsBytes32,
                 amountLD: amount,
                 minAmountLD: amount,
                 extraOptions: options,
                 composeMsg: "0x00",
                 oftCmd: "0x00"
             }
             const quotedFee = await contract.quoteSend(sendParam, false);
             console.log('Quoted fee:', hre.ethers.utils.formatEther(quotedFee.nativeFee));
        
             // Send the message
             console.log('Sending tokens...');
             const tx = await contract.send(sendParam, quotedFee, signer.address, {value: quotedFee.nativeFee});
        
             const receipt = await tx.wait();
             console.log('🎉 Tokens sent! Transaction hash:', receipt.transactionHash);
             console.log(
                 'Check token balance on LayerZero Scan: https://testnet.layerzeroscan.com/tx/' +
                 receipt.transactionHash,
             );
         });
    
     import { EndpointId } from '@layerzerolabs/lz-definitions'
        
     import "./tasks/sendToken" // 추가
    
     npx hardhat sendToken --network sepolia-testnet --dst-network avalanche-testnet --amount 100000000000000000
    

    image.png

    • 아래와 같이 LayerZero Explorer에서도 확인할 수 있음

      image.png

    • Source Chain에서 처리 됨

      image.png

    • 실제 Fuji chain에 전송된 것을 확인할 수 있음

      image.png

OFTAdapter Setup for Both Chains

위에서는 한쪽은 OFTAdapter, 한쪽은 OFT 컨트랙트를 사용한 예시를 보여드렸습니다.

지금부터는 양쪽에 모두 OFTAdpter를 사용하여 배포한 뒤 실제 전송을 처리해봅니다.

위 과정과 일부 겹치는 부분이 있기 때문에 일부 내용이 생략되었습니다.

Deploy

  1. hardhat.config.ts 설정

    양쪽 체인 모두 oftAdapter 설정

    image.png

  2. 빌드

     pnpm install # Install dependencies
     pnpm compile # Compile contract
    
  3. 배포

     npx hardhat lz:deploy
    
  4. Contract wiring

    layerzero.config.ts 에 contractName 확인

    image.png

     npx hardhat lz:oapp:wire --oapp-config layerzero.config.ts
    

    image.png

  5. ERC20.approve 수행
    • ERC20 토큰에 대해서 OFTAdapter가 컨트롤 할 수 있도록 approve를 수행해줍니다.
    • spender는 OFTAdapter 입니다.
  6. sendToken 생성 후 실행

     // tasks/sendToken.ts
        
     import { task } from 'hardhat/config';
     import { HardhatRuntimeEnvironment } from 'hardhat/types';
        
     import { Options } from '@layerzerolabs/lz-v2-utilities';
        
     export default task('sendToken', 'Send a token to the destination chain')
         .addParam('dstNetwork', 'The destination network name (from hardhat.config.ts)')
         .addParam('amount', 'The amount of tokens to send')
         .setAction(async (taskArgs, hre: HardhatRuntimeEnvironment) => {
             const { amount, dstNetwork } = taskArgs;
             const [signer] = await hre.ethers.getSigners();
        
             const addressAsBytes32 = "0x0000000000000000000000002f32e86e8fc5e762aa32a09d4970cb3216fefaf4";
        
             // Get destination network's EID
             const dstNetworkConfig = hre.config.networks[dstNetwork];
             const dstEid = dstNetworkConfig.eid;
        
             // Get current network's EID
             const srcNetworkConfig = hre.config.networks[hre.network.name];
             const srcEid = srcNetworkConfig?.eid;
        
             console.log('Sending message:');
             console.log('- From:', signer.address);
             console.log('- Source network:', hre.network.name, srcEid ? `(EID: ${srcEid})` : '');
             console.log('- Destination:', dstNetwork || 'unknown network', `(EID: ${dstEid})`);
             console.log('- Amount:', amount);
        
             const myOFTAdapter = await hre.deployments.get('MyOFTAdapter');
             const contract = await hre.ethers.getContractAt('MyOFTAdapter', myOFTAdapter.address, signer);
        
             // Add executor options with gas limit
             const options = Options.newOptions().addExecutorLzReceiveOption(200000, 0).toBytes();
        
             // Get quote for the message
             console.log('Getting quote...');
             const sendParam = {
                 dstEid: dstEid,
                 to: addressAsBytes32,
                 amountLD: amount,
                 minAmountLD: amount,
                 extraOptions: options,
                 composeMsg: "0x00",
                 oftCmd: "0x00"
             }
             const quotedFee = await contract.quoteSend(sendParam, false);
             console.log('Quoted fee:', hre.ethers.utils.formatEther(quotedFee.nativeFee));
        
             // Send the message
             console.log('Sending tokens...');
             const tx = await contract.send(sendParam, quotedFee, signer.address, {value: quotedFee.nativeFee});
        
             const receipt = await tx.wait();
             console.log('🎉 Tokens sent! Transaction hash:', receipt.transactionHash);
             console.log(
                 'Check token balance on LayerZero Scan: https://testnet.layerzeroscan.com/tx/' +
                 receipt.transactionHash,
             );
         });
    
     import { EndpointId } from '@layerzerolabs/lz-definitions'
        
     import "./tasks/sendToken" // 추가
    
     npx hardhat sendToken --network sepolia-testnet --dst-network avalanche-testnet --amount 1000000000000000000
    

    image.png

    • 아래와 같이 LayerZero Explorer에서도 확인할 수 있음

      image.png

    • Source Chain에서 처리 됨

      image.png

    • Destination Chain 처리

      image.png