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


LayerZero Omnichain Not Fungible Tokens (ONFT)

LayerZero

Overview

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

LayerZero V2 ONFT

ONFT도 OFT와 같이 사용하려는 모든 체인에 ONFT 컨트랙트가 배포 되어 있어야 합니다.

이후 OFT와 동일하게 토큰 전송시 bufn & mint 로직을 사용하고 있습니다.

image.png

Installation

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

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

npx create-lz-oapp

image.png

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

pnpm add @layerzerolabs/onft-evm

Deploy

  1. hardhat.config.ts 구성

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

    image.png

  2. Mint function 추가
    • 템플릿엔 mint가 별도로 없기 때문에 추가해 주어야 합니다.
      • 프로덕션에서 사용하려면 onlyOwner 같은 modifier 를 반드시 넣어주세요
        // SPDX-License-Identifier: UNLICENSED
        pragma solidity ^0.8.22;
              
        import { ONFT721 } from "@layerzerolabs/onft-evm/contracts/onft721/ONFT721.sol";
              
        contract MyONFT721 is ONFT721 {
            constructor(
                string memory _name,
                string memory _symbol,
                address _lzEndpoint,
                address _delegate
            ) ONFT721(_name, _symbol, _lzEndpoint, _delegate) {}
              
            function mint(address to, uint256 tokenId) external virtual {
                _mint(to, tokenId);
            }
        }
              
      
  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
    

    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. Mint Task 생성 후 실행
    • tasks/mint.ts

        // tasks/mint.ts
              
        import { task } from 'hardhat/config';
        import { HardhatRuntimeEnvironment } from 'hardhat/types';
              
        export default task('mint', 'Mint a token to the destination chain')
          .addParam('id', 'The token id to mint')
          .setAction(async (taskArgs, hre: HardhatRuntimeEnvironment) => {
            const { id } = taskArgs;
            const [signer] = await hre.ethers.getSigners();
              
            console.log('Minting tokens:');
            console.log('- From:', signer.address);
            console.log('- Token ID:', id);
              
            const myONFT721 = await hre.deployments.get('MyONFT721');
            const contract = await hre.ethers.getContractAt('MyONFT721', myONFT721.address, signer);
              
            // Send the message
            console.log('Sending message...');
            const tx = await contract.mint(signer.address, id);
              
            const receipt = await tx.wait();
            console.log('🎉 Tokens minted! Transaction hash:', receipt.transactionHash);
          });
      
    • hardhat.config.ts 에 추가

        import { EndpointId } from '@layerzerolabs/lz-definitions'
        import "./tasks/mint" // 추가
              
        // Set your preferred authentication method
      
    • 실행

        npx hardhat mint --network sepolia-testnet --id 1
      

      image.png

      image.png

  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('id', 'The token id to send')
         .setAction(async (taskArgs, hre: HardhatRuntimeEnvironment) => {
             const { id, 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('- Token ID:', id);
        
             const myONFT721 = await hre.deployments.get('MyONFT721');
             const contract = await hre.ethers.getContractAt('MyONFT721', myONFT721.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,
                 tokenId: id,
                 extraOptions: options,
                 composeMsg: "0x00",
                 onftCmd: "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/mint"
     import "./tasks/sendToken" // 추가
    
     npx hardhat sendToken --network sepolia-testnet --dst-network avalanche-testnet --id 1
    

    image.png

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

      image.png

    • Source Chain에서 처리 됨

      image.png

      • token을 zero address로 보낸 것으로 burn처리 하였음을 확인할 수 있음
    • 실제 Fuji chain에 전송된 것을 확인할 수 있음

      image.png

      • 실제 mint 형식으로 전송 된 것을 확인할 수 있음

LayerZero Omnichain Applications & Omnichain Fungible Tokens

LayerZero

Overview

이번 포스팅에서는 LayerZero를 사용하여 OApp(Omnichain Applications)과 OFT(Omnichain Fungible Tokens)를 배포하고 실행하는 과정을 설명합니다.

Getting Started with Contract Standards

LayerZero의 Contract Standards 를 사용하여 임의의 데이터, 토큰 등을 해당 프로토콜을 사용하여 전송할 수 있습니다.

이 표준들은 LayerZero Endpoint contract를 통해 omnichain 메시지를 보내고, 받고, 구성하기 위한 공통 기능을 구현하고 있습니다.

  • OAppSender._lzSend: EndpointV2.send 를 호출하여 byte 메시지를 보내는 Internal function
  • OAppReceiver._lzReceive :EndpointV2.lzReceive 를 호출한 뒤 encoded message를 byte로 전달하는 internal function

Quickstart - Create Your First Omnichain App

Creating an OApp

아래 명령어를 사용하여 새 프로젝트를 생성합니다.

npx create-lz-oapp@latest

OApp Smart Contract

프로젝트를 생성하면 MyOApp.sol 컨트랙트가 있습니다.

// contracts/MyOApp.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.22;

import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { OApp, MessagingFee, Origin } from "@layerzerolabs/oapp-evm/contracts/oapp/OApp.sol";
import { MessagingReceipt } from "@layerzerolabs/oapp-evm/contracts/oapp/OAppSender.sol";

contract MyOApp is OApp {
    constructor(address _endpoint, address _delegate) OApp(_endpoint, _delegate) Ownable(_delegate) {}

    // This is where the message will be stored after it is received on the destination chain
    string public data = "Nothing received yet.";

    /**
     * @notice Sends a message from the source chain to a destination chain.
     * @param _dstEid The endpoint ID of the destination chain.
     * @param _message The message string to be sent.
     * @param _options Additional options for message execution.
     * @dev Encodes the message as bytes and sends it using the `_lzSend` internal function.
     * @return receipt A `MessagingReceipt` struct containing details of the message sent.
     */
    function send(
        uint32 _dstEid,
        // The message to be sent to the destination chain
        string memory _message,
        bytes calldata _options
    ) external payable returns (MessagingReceipt memory receipt) {
        bytes memory _payload = abi.encode(_message);
        receipt = _lzSend(_dstEid, _payload, _options, MessagingFee(msg.value, 0), payable(msg.sender));
    }

    /**
     * @notice Quotes the gas needed to pay for the full omnichain transaction in native gas or ZRO token.
     * @param _dstEid Destination chain's endpoint ID.
     * @param _message The message.
     * @param _options Message execution options (e.g., for sending gas to destination).
     * @param _payInLzToken Whether to return fee in ZRO token.
     * @return fee A `MessagingFee` struct containing the calculated gas fee in either the native token or ZRO token.
     */
    function quote(
        uint32 _dstEid,
        string memory _message,
        bytes memory _options,
        bool _payInLzToken
    ) public view returns (MessagingFee memory fee) {
        bytes memory payload = abi.encode(_message);
        fee = _quote(_dstEid, payload, _options, _payInLzToken);
    }

    /**
     * @dev Internal function override to handle incoming messages from another chain.
     * @dev _origin A struct containing information about the message sender.
     * @dev _guid A unique global packet identifier for the message.
     * @param payload The encoded message payload being received.
     *
     * @dev The following params are unused in the current implementation of the OApp.
     * @dev _executor The address of the Executor responsible for processing the message.
     * @dev _extraData Arbitrary data appended by the Executor to the message.
     *
     * Decodes the received payload and processes it as per the business logic defined in the function.
     */
    function _lzReceive(
        Origin calldata /*_origin*/,
        bytes32 /*_guid*/,
        bytes calldata payload,
        address /*_executor*/,
        bytes calldata /*_extraData*/
    ) internal override {
        data = abi.decode(payload, (string));
    }
}

Configuration

hardhat.config.ts 에 배포할 네트워크에 대한 정보를 입력합니다.

networks: {
    'avalanche-testnet': {
        eid: EndpointId.AVALANCHE_V2_TESTNET,
        url: process.env.RPC_URL_FUJI || 'https://rpc.ankr.com/avalanche_fuji',
        accounts,
    },
    'amoy-testnet': {
        eid: EndpointId.AMOY_V2_TESTNET,
        url: process.env.RPC_URL_AMOY || 'https://polygon-amoy-bor-rpc.publicnode.com',
        accounts,
    },
}

 .env.example 를 복사해 .env  를 만들고 .env를 수정합니다.

PRIVATE_KEY = your_private_key; // Required
RPC_URL_FUJI = your_fuji_rpc; // Optional but recommended
RPC_URL_AMOY = your_amoy_rpc; // Optional but recommended

Deploying Contracts

아래 명렁어를 이용하여 배포합니다.

npx hardhat lz:deploy

image.png

Configuration and wiring

이제 Chain간 Contract를 연결할 수 있는 준비가 되었습니다. 이를 위해 설정을 구성해야합니다. 해당 설정은 layerzero.config.ts 에 구성합니다.

// layerzero.config.ts
import {EndpointId} from '@layerzerolabs/lz-definitions';

import type {OAppOmniGraphHardhat, OmniPointHardhat} from '@layerzerolabs/toolbox-hardhat';

const fujiContract: OmniPointHardhat = {
  eid: EndpointId.AVALANCHE_V2_TESTNET,
  contractName: 'MyOApp',
};

const amoyContract: OmniPointHardhat = {
  eid: EndpointId.AMOY_V2_TESTNET,
  contractName: 'MyOApp',
};

const config: OAppOmniGraphHardhat = {
  contracts: [
    {
      contract: fujiContract,
    },
    {
      contract: amoyContract,
    },
  ],
  connections: [
    {
      from: fujiContract,
      to: amoyContract,
    },
    {
      from: amoyContract,
      to: fujiContract,
    },
  ],
};

export default config;

설정을 다 구성 했다면 아래 명령어를 이용하여 contract들을 연결할 수 있습니다.

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

결과적으로 아래와 같이 출력 됩니다.

image

Sending Your First Message

이제 실제 메시지를 보내기 위해 ts파일로 script를 구성합니다.

tasks/sendMessage.ts 로 새 파일을 만들고 아래 코드를 입력합니다.

// tasks/sendMessage.ts

import {task} from 'hardhat/config';
import {HardhatRuntimeEnvironment} from 'hardhat/types';

import {Options} from '@layerzerolabs/lz-v2-utilities';

export default task('sendMessage', 'Send a message to the destination chain')
  .addParam('dstNetwork', 'The destination network name (from hardhat.config.ts)')
  .addParam('message', 'The message to send')
  .setAction(async (taskArgs, hre: HardhatRuntimeEnvironment) => {
    const {message, dstNetwork} = taskArgs;
    const [signer] = await hre.ethers.getSigners();

    // 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('- Message:', message);

    const myOApp = await hre.deployments.get('MyOApp');
    const contract = await hre.ethers.getContractAt('MyOApp', myOApp.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 quotedFee = await contract.quote(dstEid, message, options, false);
    console.log('Quoted fee:', hre.ethers.utils.formatEther(quotedFee.nativeFee));

    // Send the message
    console.log('Sending message...');
    const tx = await contract.send(dstEid, message, options, {value: quotedFee.nativeFee});

    const receipt = await tx.wait();
    console.log('🎉 Message sent! Transaction hash:', receipt.transactionHash);
    console.log(
      'Check message status on LayerZero Scan: https://testnet.layerzeroscan.com/tx/' +
        receipt.transactionHash,
    );
  });

또한 hardhat.config.ts 에도 위에서 만든 파일을 Import 합니다.

// hardhat.config.ts
// (...)
import {EndpointId} from '@layerzerolabs/lz-definitions';
import './tasks/sendMessage'; // Import the task

이후 아래 명령어로 cross-chain message를 보낼 수 있습니다.

npx hardhat sendMessage --network avalanche-testnet --dst-network amoy-testnet --message "Hello Omnichain World (sent from Avalanche)"

# 실제로는 amoy를 사용하지 않고 sepolia를 사용했음 (Gas fee 없어서)
npx hardhat sendMessage --network sepolia-testnet --dst-network avalanche-testnet --message "Hello Omnichain World (sent from Seploia)"

결과는 아래와 같습니다.

Sending message:
- From: 0x498098ca1b7447fC5035f95B80be97eE16F82597
- Source network: avalanche-testnet (EID: 40106)
- Destination: amoy-testnet (EID: 40267)
- Message: Hello Omnichain World (sent from Avalanche)
Getting quote...
Quoted fee: 0.004605311339306711
Sending message...
🎉 Message sent! Transaction hash: 0x47bd60f2710c2ec5a496c55c9763bd87fd4c599b541ad1287540fce9852ede65
Check message status on LayerzeRo Scan: https://testnet.layerzeroscan.com/tx/0x47bd60f2710c2ec5a496c55c9763bd87fd4c599b541ad1287540fce9852ede65
  • 실패 ( avax → sepolia )

    image.png

image.png

image.png

LayerZero V2 OFT

oft

OFT는 모든 체인에 배포되어야 하며, 각 체인에서 토큰을 발행하고 전송할 수 있습니다.

각 체인간 전송하는 과정에서 기본적으로 Burn & Mint 처리가 됩니다.

oftadapter

만약 이미 존재하는 ERC20을 사용하여 OFTAdapter를 구성한 경우 Lock & Mint 처리가 됩니다.

Installation

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

npx create-lz-oapp

image.png

추가로 아래 명령어로 npm 패키지를 설치합니다.

pnpm add @layerzerolabs/oft-evm

OFT를 생성하려면, 토큰을 존재시키고자 하는 모든 체인에 OFT Contract를 배포해야 합니다.

이미 해당 체인에 토큰이 존재하는 경우, 해당 토큰의 중개 역할을 하는 Lock Box로서 OFT Adapter Contract를 배포할 수 있습니다.

기본적으로 OFT는 ERC20 규칙을 따르며, decimals 값을 18로 설정합니다. 다른 값을 사용하려면 컨트랙트에서 decimals() 함수를 오버라이드해야 합니다.

Deployment Workflow

  1. OFT를 연결하려는 모든 체인에 배포

    OFT를 생성하려면, 토큰을 존재시키고자 하는 모든 체인에 OFT 컨트랙트를 배포해야 합니다.

  2. setPeer를 사용하여 체인 간 연결

    OFT는 OApp을 확장하므로, OFT.setPeer를 호출하여 각 대상 체인의 컨트랙트를 허용 목록(whitelist)에 추가해야 합니다.

  3. DVN(Decentralized Verification Network) 설정

    블록 확인(block confirmations), 보안 임계값(security threshold), 실행자(Executor), 최대 메시지 크기(max message size), 송·수신 라이브러리(send/receive libraries) 등의 설정을 구성해야 합니다.

  4. 보안 및 실행자(Executor) 설정 스크립트 참고

    보안 및 실행자 설정과 관련된 예제 스크립트는 “Security and Executor Configuration” 섹션에서 확인할 수 있습니다.

  5. 가스 설정 (권장 옵션)

    OFT는 OAppOptionsType3을 상속받으므로, 사용자가 aOFT.send를 호출할 때 특정 가스 설정을 강제할 수 있습니다.

  6. OFTAdapter를 사용하는 경우 (필수 설정)

    OFTAdapter를 사용할 경우, 전송할 토큰의 양만큼 ERC20.approve를 호출하여 OFTAdapter가 ERC20 토큰을 사용할 수 있도록 승인해야 합니다.

Deploy

  1. hardhat.config.ts 구성

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

    image.png

  2. Mint function 추가
    • 템플릿엔 mint가 별도로 없기 때문에 추가해 주어야 합니다.
      • 프로덕션에서 사용하려면 onlyOwner 같은 modifier 를 반드시 넣어주세요
        // SPDX-License-Identifier: UNLICENSED
        pragma solidity ^0.8.22;
              
        import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
        import { OFT } from "@layerzerolabs/oft-evm/contracts/OFT.sol";
              
        contract MyOFT is OFT {
            constructor(
                string memory _name,
                string memory _symbol,
                address _lzEndpoint,
                address _delegate
            ) OFT(_name, _symbol, _lzEndpoint, _delegate) Ownable(_delegate) {}
              
            // Mint function
            function mint(address to, uint256 amount) external virtual {
                _mint(to, amount);
            }
        }
              
      
  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
    

    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. Mint Task 생성 후 실행
    • tasks/mint.ts

        // tasks/mint.ts
              
        import { task } from 'hardhat/config';
        import { HardhatRuntimeEnvironment } from 'hardhat/types';
              
        import { Options } from '@layerzerolabs/lz-v2-utilities';
              
        export default task('mint', 'Mint a token to the destination chain')
          .addParam('amount', 'The amount of tokens to mint')
          .setAction(async (taskArgs, hre: HardhatRuntimeEnvironment) => {
            const { amount } = taskArgs;
            const [signer] = await hre.ethers.getSigners();
              
            console.log('Minting tokens:');
            console.log('- From:', signer.address);
            console.log('- Amount:', amount);
              
            const myOFT = await hre.deployments.get('MyOFT');
            const contract = await hre.ethers.getContractAt('MyOFT', myOFT.address, signer);
              
            // Send the message
            console.log('Sending message...');
            const tx = await contract.mint(signer.address, amount);
              
            const receipt = await tx.wait();
            console.log('🎉 Tokens minted! Transaction hash:', receipt.transactionHash);
          });
      
    • hardhat.config.ts 에 추가

        import { EndpointId } from '@layerzerolabs/lz-definitions'
        import "./tasks/mint" // 추가
              
        // Set your preferred authentication method
      
    • 실행

        npx hardhat mint --network sepolia-testnet --amount 10000000000000000000
      

      image.png

      image.png

  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 myOFT = await hre.deployments.get('MyOFT');
             const contract = await hre.ethers.getContractAt('MyOFT', myOFT.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/mint"
     import "./tasks/sendToken" // 추가
    
     npx hardhat sendToken --network sepolia-testnet --dst-network avalanche-testnet --amount 1000000000000000000
    

    image.png

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

      image.png

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

      image.png

    • Source Chain에서는 send와 함께 Burn 처리됨

      image.png

TroubleShooting

  • Message Blocked

    image.png

    • Config이 잘못되는 경우 Blocked가 되는 경우가 있음
      • create-lz-oapp 을 사용해서 만드는 프로젝트의 기본설정을 사용했는데 그게 문제가 된 것 같음
      • layerzero.config.ts 의 connections 부분을 수정
        • 이상한 옵션을 싹 제거함
              connections: [
                  {
                      from: fujiContract,
                      to: sepoliaContract,
                  },
                  {
                      from: sepoliaContract,
                      to: fujiContract,
                  }
              ],
        

2024 Advent-of-spin Challenge 4

이 포스트는 advent-of-spin에 업로드 된 Challenge 4에 대해서 진행했던 내용을 정리한 포스트 입니다.

Rust 언어로 진행하였으며 이 포스트에 게시된 소스코드는 Github 에서 확인할 수 있습니다.

Treasure hunt

실제 challenge의 내용은 보물찾기를 통해 찾아야 합니다.

시작점은 다음 엔드포인트입니다

curl https://treasurehunt.fermyon.app/start

image.png

단서를 토대로 아래 명령어를 통해서 POST 요청을 보냈습니다.

curl -X POST https://treasurehunt.fermyon.app/artist-check \
-H "Content-Type: application/json" \
-d '{"name": "Bryan Adams"}'

image.png

다음 힌트를 얻기 위해 GET /song-hint 엔드포인트로 요청을 보냅니다

curl https://treasurehunt.fermyon.app/song-hint

image.png

이 base64로 인코딩된 텍스트를 디코딩해보면 노래 가사를 알 수 있을 것 같습니다. 터미널에서 다음 명령어로 디코딩해봅니다.

echo "U2FpZCBTYW50YSB0byBhIGJveSBjaGlsZCAiV2hhdCBoYXZlIHlvdSBiZWVuIGxvbmdpbmcgZm9yPyIKIkFsbCBJIHdhbnQgZm9yIENocmlzdG1hcyBpcyBhIFJvY2sgYW5kIFJvbGwgZWxlY3RyaWMgZ3VpdGFyIgpBbmQgYXdheSB3ZW50IFJ1ZG9scGggYSB3aGl6emluZyBsaWtlIGEgc2hvb3Rpbmcgc3Rhci4K" | base64 -d

image.png

이 가사는 Run Rudolph Run의 일부입니다.

아래 명령어로 답을 제출해봅니다.

curl -X POST https://treasurehunt.fermyon.app/song-check \
-H "Content-Type: application/json" \
-d '{"name": "Run Rudolph Run"}'

image.png

아래 명령어로 마지막 문제를 확인합니다.

curl https://treasurehunt.fermyon.app/final-riddle

image.png

아래 명령어로 정답을 확인합니다.

curl -X POST https://treasurehunt.fermyon.app/answer \
-H "Content-Type: application/json" \
-d '{"name": "Dasher"}'

image.png

이제 실제 구현 spec이 나왔습니다.

Spec

  • GET /api/my 엔드포인트 생성
    • content-type 헤더는 application/json 이어야 함
    • 아래 포맷을 만족해야함

        {
          "favorite" : {
            "music": "YOUR_LINK_HERE"
          }
        } 
      

Work

  • 이번 챌린지는 단순하게 GET 메소드로 라우팅을 하나만 뚫으면 되는 간단한 챌린지 입니다.
  • spin new 명령어를 이용하여 새로운 프로젝트를 생성해 줍니다.

    image.png

  • /api/my 라우팅을 지정하기 위해 spin.toml 을 수정합니다.

    image.png

    • trigger.http 부분의 route를
      route = "/api/my"
    

    로 수정 해 주었습니다.

  • src/lib.rs 를 구현하기 위해 아래처럼 코드를 작성합니다.

      use spin_sdk::http::{IntoResponse, Method, Request, Response};
      use spin_sdk::http_component;
        
      #[http_component]
      fn handle_request(req: Request) -> anyhow::Result<impl IntoResponse> {
          println!("Received request: {:?}", req.method());
        
          let (status, body) = match *req.method() {
              Method::Get => {
                  let json_body = serde_json::json!({
                      "favorite": {
                          "music": "https://music.apple.com/de/playlist/advent-of-spin-2024/pl.u-xky2TDpLaZ?l=en-GB"
                      }
                  });
                  (200, serde_json::to_vec(&json_body).unwrap())
              }
              _ => (404, Vec::new()),
          };
        
          Ok(Response::builder()
              .status(status)
              .header("content-type", "application/json")
              .body(body)
              .build())
      }
    
    • serde_json 을 dependency로 사용하기 때문에 cargo add serde_json 을 수행하여 의존성 추가를 해 주어야 합니다.

Test

  • hurl --test test.hurl 명령어로 테스트가 가능합니다.

    image.png


2024 Advent-of-spin Challenge 3

이 포스트는 advent-of-spin에 업로드 된 Challenge 3에 대해서 진행했던 내용을 정리한 포스트 입니다.

Rust 언어로 진행하였으며 이 포스트에 게시된 소스코드는 Github 에서 확인할 수 있습니다.

Spec

  • Gift-Suggestion-Generator Wasm 컴포넌트
  • /gift-suggestions.html 페이지 추가
    • 사용자 이름, 나이, 취향을 입력하면 선물 제안을 생성하는 API를 호출하는 페이지
  • /api/generate-gift-suggestions path에서 POST method 구현하기’
    • AI가 선물 제안을 생성하는 API
    • request
        {
             "name": "Riley Parker",
             "age": 15,
             "likes": "Computers, Programming, Mechanical Keyboards"
         }
      
    • response
        {
            "name": "Riley Parker",
            "giftSuggestions": "I bet Riley would be super happy with a new low profile mechanical keyboard or a couple of new books about software engineering"
        }
      

Work

이 Challenge도 Challenge 2과 마찬가지로 static page와 api가 구현된 wasm component를 사용하기 위해 challenge 2에서 만들어놓은 프로젝트를 기반으로 사용하려고 합니다.

우선 프론트엔드 부분을 구현하기 위해 assets/gift-suggestions.html 파일을 생성합니다.

assets/gift-suggestions.html 경로에 GET 요청을 보내면 아래와 같은 응답을 받을 수 있도록 구현합니다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Gift Suggestions</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 20px;
            background-color: #f9f9f9;
        }
        h1 {
            color: #2c3e50;
        }
        form {
            display: flex;
            flex-direction: column;
            gap: 10px;
            max-width: 400px;
            margin-bottom: 20px;
        }
        label {
            font-weight: bold;
        }
        input, button {
            padding: 10px;
            font-size: 1rem;
        }
        button {
            background-color: #3498db;
            color: white;
            border: none;
            cursor: pointer;
        }
        button:hover {
            background-color: #2980b9;
        }
        #result {
            background-color: white;
            border: 1px solid #ddd;
            padding: 15px;
            max-width: 400px;
        }
    </style>
</head>
<body>
    <h1>🎁 Gift Suggestions</h1>
    <form id="giftForm">
        <label for="name">Name:</label>
        <input type="text" id="name" placeholder="Enter name" required>

        <label for="age">Age:</label>
        <input type="number" id="age" placeholder="Enter age" required>

        <label for="likes">Likes/Interests:</label>
        <input type="text" id="likes" placeholder="e.g., Programming, Sports" required>

        <button type="submit">Get Gift Suggestions</button>
    </form>

    <div id="result" hidden>
        <h2>Gift Suggestions for <span id="childName"></span></h2>
        <p id="suggestions"></p>
    </div>

    <script>
        const form = document.getElementById("giftForm");
        const resultDiv = document.getElementById("result");
        const childNameSpan = document.getElementById("childName");
        const suggestionsParagraph = document.getElementById("suggestions");

        form.addEventListener("submit", async (event) => {
            event.preventDefault();

            // 폼에서 데이터 가져오기
            const name = document.getElementById("name").value;
            const age = parseInt(document.getElementById("age").value);
            const likes = document.getElementById("likes").value;

            // API 요청
            try {
                const response = await fetch("/api/generate-gift-suggestions", {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ name, age, likes })
                });

                if (!response.ok) {
                    throw new Error(`API Error: ${response.status}`);
                }

                const data = await response.json();

                // 결과 표시
                childNameSpan.textContent = name;
                suggestionsParagraph.textContent = data.giftSuggestions;
                resultDiv.hidden = false;
            } catch (error) {
                console.error("Error fetching gift suggestions:", error);
                alert("Failed to fetch gift suggestions. Please try again later.");
            }
        });
    </script>
</body>
</html>

code

index.html

이번에도 Component Dependencies 기능을 사용하여 선물 아이디어를 반환하도록 하는 기능이 포함된 wasm component를 추가해보겠습니다.

이 컴포넌트는 gIthub./template 폴더에 포함되어 있으므로 해당 파일을 가져와서 사용하겠습니다.

ss

첫번째로 venv를 사용하여 가상 환경을 구성합니다.

python3 -m venv venv

그러면 venv 폴더가 생성됩니다.

ss

이제 가상 환경을 활성화합니다.

source venv/bin/activate

ss

이후에 Dependencies를 설치합니다.

pip install -r requirements.txt

ss

이제 wasm component를 구현 해야합니다. app.py 파일을 확인하면 아래와 같은 코드를 확인할 수 있습니다.

from gift_suggestions_generator import exports
from gift_suggestions_generator.exports.generator import Suggestions
# from spin_sdk import llm

class Generator(exports.Generator):
    def suggest(self, name: str, age: int, likes: str):
        # Implement your gift suggestion here
        return Suggestions("John Doe", "I bet John would be super happy with a new mechanical keyboard.")

우리는 이 코드를 llm을 이용하여 더 적절하게 추천을 할 수 있도록 수정해보겠습니다.

from gift_suggestions_generator import exports
from gift_suggestions_generator.exports.generator import Suggestions
from spin_sdk import llm

class Generator(exports.Generator):
    def suggest(self, name: str, age: int, likes: str):
        prompt = (
            f"Suggest a personalized gift for {name}, "
            f"a {age}-year-old person who likes {likes}. "
            "Be creative and thoughtful, and provide a brief reason for your suggestion."
        )
        
        try:
            result = llm.infer("llama2-chat", prompt)
            suggestion_text = result.text  
        except Exception as e:
            suggestion_text = "Unable to generate a suggestion at the moment. Please try again later."

        return Suggestions(name, suggestion_text)

이제 wasm component를 생성하기 위해 아래 커맨드를 입력합니다.

componentize-py -d ./wit/ -w gift-suggestions-generator componentize -m spin_sdk=spin-imports app -o gift-suggestions-generator.wasm

ss

그러면 gift-suggestions-generator.wasm 파일이 생성됩니다.

ss

이제 이 wasm 파일을 spin 프로젝트에 추가해야 합니다.

spin deps add ./gift-suggestions-generator.wasm 커맨드를 사용하여 spin 프로젝트에 추가해 줍니다.

ss

ss

이후 spin deps generate-bindings 커맨드를 사용하여 wasm 파일에 대한 binding을 생성해 주어야 하는데 이를 위해서 spin plugin을 설치해 주어야 합니다.

  • https://github.com/fermyon/spin-deps-plugin/tree/main?tab=readme-ov-file#installation

설치가 완료 되었다면 아래 명령어를 사용하여 binding을 생성합니다.

spin deps generate-bindings -L rust -o src/bindings -c challenge3

위 과정이 정상적으로 수행 되엇다면 src/bindings 경로에 파일이 생성된 것을 확인할 수 있습니다.

ss

ss

이제 이 파일을 이용하여 rust 코드를 작성해보겠습니다.

src/lib.rs 에 GET을 처리하기 위한 로직을 구현합니다.

use serde::{Deserialize, Serialize};
use spin_sdk::http::Method;
use spin_sdk::http::{IntoResponse, Request, Response};
use spin_sdk::http_component;

mod bindings;

#[derive(Debug, Serialize, Deserialize)]
struct SuggestionsRequest {
    name: String,
    age: u8,
    likes: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct SuggestionsResonse {
    name: String,
    #[warn(non_snake_case)]
    giftSuggestions: String,
}

#[http_component]
fn handle_request(req: Request) -> anyhow::Result<impl IntoResponse> {
    println!("Received request: {:?}", req.method());

    let (status, body) = match *req.method() {
        Method::Post => {
            let body = req.body();
            let suggestions_request: SuggestionsRequest = match serde_json::from_slice(&body) {
                Ok(suggestions_request) => suggestions_request,
                Err(_) => return Ok(Response::builder().status(400).body(Vec::new()).build()),
            };
            let gift_suggestions = bindings::deps::components::advent_of_spin::generator::suggest(
                &suggestions_request.name,
                suggestions_request.age,
                &suggestions_request.likes,
            )
            .unwrap();
            let json_body = SuggestionsResonse {
                name: gift_suggestions.clone().name,
                giftSuggestions: gift_suggestions.clone().suggestions,
            };
            (200, serde_json::to_vec(&json_body).unwrap())
        }
        _ => (404, Vec::new()),
    };

    Ok(Response::builder()
        .status(status)
        .header("content-type", "application/json")
        .body(body)
        .build())
}

실행 전 spin.toml 에 해당 옵션을 추가해 줍니다.

dependencies_inherit_configuration = true
ai_models = ["llama2-chat"]

ss

  • spin build 로 빌드를 수행합니다.

    spin build

  • spin up 으로 로컬에서 실행이 가능합니다.

    spin up

  • curl을 사용하여 가볍게 테스트가 가능합니다.
    • POST
            curl -X POST \             
            -H "Content-Type: application/json" \
            -d '{
                "name": "John Doe",
                "age": 15,
                    "likes": "Computers, Programming, Mechanical Keyboards"
                }' \
            http://localhost:3000/api/generate-gift-suggestions
      
  • 아래 명령어로 제출 전 테스트가 가능합니다.

      hurl --test test.hurl
    

    test