LayerZero Omnichain Fungible Tokens Adapter
10 Feb 2025 | BlockchainLayerZero
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가 실행됩니다.

Installation
아래 명령어로 OFTAdapter가 포함된 프로젝트를 생성할 수 있습니다.
OFTAdapter 를 선택하면 해당 프로젝트에 OFT가 포함되어 있습니다.
npx create-lz-oapp

이후 아래 명령어를 사용하여 패키지를 추가 설치 해줍니다.
pnpm add @layerzerolabs/oft-evm
Deploy
-
ERC20 Token 컨트랙트 배포
OFTAdapter를 사용하기 위해선 기존 배포되어 있는 ERC20 Token 컨트랙트가 필요합니다.
테스트 환경이기 때문에 직접 배포하여 사용합니다.
-
hardhat.config.ts구성본문에서는 amoy를 사용하지 않을거라 제거했음
oftAdapter 부분에 0번 과정에서 배포한 tokenAddress를 입력 해 주었음

-
빌드
pnpm install # Install dependencies pnpm compile # Compile contract
.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"- Rename
-
배포
npx hardhat lz:deployOFTAdapter설정을 추가해주었기 때문에 sepolia에는 OFTAdapter만 배포 되었습니다.
-
layerzero.config.ts구성실제 컨트랙트를 연결할 체인들을 구성
본문에서는 amoy를 사용하지 않아 제거했음

-
Contract wire
npx hardhat lz:oapp:wire --oapp-config layerzero.config.ts
- ERC20.approve 수행
- ERC20 토큰에 대해서 OFTAdapter가 컨트롤 할 수 있도록 approve를 수행해줍니다.
- spender는 OFTAdapter 입니다.
-
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
-
아래와 같이 LayerZero Explorer에서도 확인할 수 있음

-
Source Chain에서 처리 됨

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

-
OFTAdapter Setup for Both Chains
위에서는 한쪽은 OFTAdapter, 한쪽은 OFT 컨트랙트를 사용한 예시를 보여드렸습니다.
지금부터는 양쪽에 모두 OFTAdpter를 사용하여 배포한 뒤 실제 전송을 처리해봅니다.
위 과정과 일부 겹치는 부분이 있기 때문에 일부 내용이 생략되었습니다.
Deploy
-
hardhat.config.ts설정양쪽 체인 모두
oftAdapter설정
-
빌드
pnpm install # Install dependencies pnpm compile # Compile contract -
배포
npx hardhat lz:deploy -
Contract wiring
layerzero.config.ts에 contractName 확인
npx hardhat lz:oapp:wire --oapp-config layerzero.config.ts
- ERC20.approve 수행
- ERC20 토큰에 대해서 OFTAdapter가 컨트롤 할 수 있도록 approve를 수행해줍니다.
- spender는 OFTAdapter 입니다.
-
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
-
아래와 같이 LayerZero Explorer에서도 확인할 수 있음

-
Source Chain에서 처리 됨

-
Destination Chain 처리

-