Solidity
February 11

LayerZero V2 OFT

Всем привет! Относительно недавно выпустили LayerZero V2 и я решил разобрать новую версию OFT. В данной статье рассмотрим основные функции OFT V2, развернем в двух тестовых сетях свой токен и переведем токены из одной сети в другую.

Начало

Для начала я создам проект hardhat

npx hardhat 

Дальше установим пакеты layerzerolabs

npm install @layerzerolabs/lz-evm-oapp-v2
Если данный способ вам не удобен, то просто копируйте контракт MyOFTV2 ниже в remix, потом компилируйте и все зависимости установятся сами. Но не забудьте включить optimizer при компиляции.

Теперь создадим свой контракт MyOFTV2.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.23;
import "@layerzerolabs/lz-evm-oapp-v2/contracts/oft/OFT.sol";
contract MyOFTV2 is OFT{    
    constructor(        
      string memory _name,        
      string memory _symbol,        
      address _lzEndpoint,        
      address _delegate    
    ) OFT(_name, _symbol, _lzEndpoint, _delegate){        
        _mint(msg.sender, 1000 * 10 ** decimals());    
    }
}

По факту мы просто наследуем контракт OFT и минтим на наш адрес токены для перевода в будущем.

Уже можно деплоить данный контракт в двух сетях и выполнять все наши задачи, но давайте рассмотрим что происходит под капотом, когда мы хотим перевести токены из одной сети в другую.

Обзор функций L0.

Начнем с самого интересного и это то, как происходит cross chain swap токенов.

Если мы перейдем в контракт OFT, то увидим две функции _debit и _credit. Данные функции переопределены из ядра OFTCore.

Это нужно для того, чтоб мы могли добавить свою логику при переводе токенов и не изменять ядро.

 function _debit(
        uint256 _amountLD,
        uint256 _minAmountLD,
        uint32 _dstEid
    ) internal virtual override returns (uint256 amountSentLD, uint256 amountReceivedLD) {
        (amountSentLD, amountReceivedLD) = _debitView(_amountLD, _minAmountLD, _dstEid);

        _burn(msg.sender, amountSentLD);
    }
    function _credit(
        address _to,
        uint256 _amountLD,
        uint32
    ) internal virtual override returns (uint256 amountReceivedLD) {
        
        _mint(_to, _amountLD);
        return _amountLD;
    }

Данные две функции предназначены для того, чтоб мы могли использовать механизм перевода токенов из одной сети в другую. Команда L0 это решила при помощи функций _mint и _burn. Смысл в том, чтоб при переводе токенов из Сети A мы сжигали токены из кошелька сети A и дальше чеканили токены в сети B на наш кошелек в сети B.

Продвигаемся глубже и заходим в контракт OFTCore. Там есть достаточно много интересного, но пройдемся по самому главному.

sharedDecimals это функция которая дает нам возможность установить общий decimals для не evm сетей, так как некоторые сети имеют max uint64, что меньше чем evm ethereum max uint256 например.

 function sharedDecimals() public pure virtual returns (uint8) {        
     return 6;    
 }

функции send и _lzReceive очень важны во время перевода, так как они взаимодействуют с endpoint L0.

 function send(
        SendParam calldata _sendParam,
        MessagingFee calldata _fee,
        address _refundAddress
    ) external payable virtual returns (MessagingReceipt memory msgReceipt, OFTReceipt memory oftReceipt) {
        (uint256 amountSentLD, uint256 amountReceivedLD) = _debit(
            _sendParam.amountLD,
            _sendParam.minAmountLD,
            _sendParam.dstEid
        );

        (bytes memory message, bytes memory options) = _buildMsgAndOptions(_sendParam, amountReceivedLD);

        msgReceipt = _lzSend(_sendParam.dstEid, message, options, _fee, _refundAddress);
		oftReceipt = OFTReceipt(amountSentLD, amountReceivedLD);

        emit OFTSent(msgReceipt.guid, _sendParam.dstEid, msg.sender, amountSentLD);
    }

    function _lzReceive(
        Origin calldata _origin,
        bytes32 _guid,
        bytes calldata _message,
        address /*_executor*/, // @dev unused in the default implementation.
        bytes calldata /*_extraData*/ // @dev unused in the default implementation.
    ) internal virtual override {
        address toAddress = _message.sendTo().bytes32ToAddress();
  		uint256 amountReceivedLD = _credit(toAddress, _toLD(_message.amountSD()), _origin.srcEid);

        if (_message.isComposed()) {
            bytes memory composeMsg = OFTComposeMsgCodec.encode(
                _origin.nonce,
                _origin.srcEid,
                amountReceivedLD,
                _message.composeMsg()
            );

            endpoint.sendCompose(toAddress, _guid, 0 /* the index of the composed message*/, composeMsg);
        }

        emit OFTReceived(_guid, _origin.srcEid, toAddress, amountReceivedLD);
    }

Endpoint - это специальные смарт-контракты L0, которые принимают обрабатывают и отправляют наши транзакции между сетями. Это если коротко.

И так функции send и _lzReceive работают с endpoint, но что именно они отправляют и принимают?

В протоколе L0 есть такое понятие options (ранее вы могли слышать про них как adapterParams).

Options - это способ передачи информации между сетями, так как сеть B не имеет ни какого понятия о состоянии сети A. Есть разные виды options, которые предоставляет нам L0. Нас же интересует lzReceiveOption. Эти параметры используются для передачи газа, который мы будем тратить для доставки сообщения и передачи msg.value, если это необходимо.

Для их генерации команда L0 предоставила нам уже готовые функции. Их можно посмотреть в документации и открыть в remix

Из важного, раньше наши options могли начинаться с так называемых магических числе 0001 и 0002 (идентификаторы options), но они устарели и их больше не используют. Теперь только 0003.

Я для примера сгенерировал options с gas 200000 и msg.value 0. Это такие стандартные параметры, которые даже в документации L0 предоставляются.

 0x00030100110100000000000000000000000000030d40

функция send вызывается при отправке сообщения или в нашем случае токенов. Все наши параметры, которые мы передаем собираются и отправляются в _lzSend функцию, которая в свою очередь выполняя некоторые проверки и отправляет наше сообщение в endpoint.

Endpoint принимает наш запрос, обрабатывает и отправляет в сеть B транзакцию, вызвав для этого функцию _lzReceive.

О входных значениях кроме options для функции send поговорим чуть позже.

Функция _lzReceive уже будет вызываться конечной точкой (endpoint) в нашем контракте в сети B , и обрабатывать (расшифровывать) запакованные options. И таким образом наш перевод токенов будет завершен.

Также у нас появляются новые понятия как SD LD. Грубо говоря это выравнивание по decimals разных сетей при помощи sharedDecimals. Как раз поэтому мы увидим новый параметр функции send, чуть позже.

function _toLD(uint64 _amountSD) internal view virtual returns (uint256 amountLD) {
        return _amountSD * decimalConversionRate;
    }
    function _toSD(uint256 _amountLD) internal view virtual returns (uint64 amountSD) {
        return uint64(_amountLD / decimalConversionRate);
    }

Если мы продвинемся дальше и перейдем в контракт OAppOptionsType3

То там будет новая интересная функция setEnforcedOptions

function setEnforcedOptions(EnforcedOptionParam[] calldata _enforcedOptions) public virtual onlyOwner {
        for (uint256 i = 0; i < _enforcedOptions.length; i++) {
             _assertOptionsType3(_enforcedOptions[i].options);
            enforcedOptions[_enforcedOptions[i].eid][_enforcedOptions[i].msgType] = _enforcedOptions[i].options;
        }

        emit EnforcedOptionSet(_enforcedOptions);
    }

Данная функция позволяет жёстко установить параметры options, то есть при вызове функции send, данные условия будут вызываться каждый раз.

Теперь функция setPeer

function setPeer(uint32 _eid, bytes32 _peer) public virtual onlyOwner {
        peers[_eid] = _peer;
        emit PeerSet(_eid, _peer);
    }

Данная функция позволяет установить связь между нашими контрактами в разных сетях. В принципе все.

Это было очень кратко и поверхностно, но для общего понимания процесса думаю достаточно, возможно в будущем разберем весь протокол более подробно.

Также разумеется я рассмотрел не все функции, но тем не менее давайте развернем наш OFT и выполним перевод.

Деплой

Так как я работаю в hardhat, то я и деплой буду выполнять через hardhat. Если вы работаете в remix, то вам будет нужно подключить свой метамаск в нужной сети и деплоить уже там.

НО я советую научиться пользоваться hardhat и делать все там, потому что это более профессионально и быстрее, если научиться.

Скрипт для деплоя в hardhat

В файл deploy.js в папку scripts вставляем данный код.

const hre = require("hardhat");

async function main() {

  const [deployer] = await ethers.getSigners();

  console.log("Deploying contracts with the account:", deployer.address);

  console.log("Account balance:", (await deployer.getBalance()).toString());
  
  const token = await ethers.deployContract("MyOFTV2", ["Token name", "Token symbol", "0x6edce65403992e310a62460808c4b910d972f10f", "Your wallet address"]);

  console.log("Token address:", await token.address);
  
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });ocess.exit(1);
  });

Я тут ни чего особенного не делаю, просто деплой.

Параметры конструктора:

Первый, это название токена

Второй, это символ токена

Третий, это адрес endpoint

Четвертый, это адрес владельца контракта, то есть наш адрес кошелька.

Адреса endpoint

Я тут покажу два адреса, работать буду в них.

Sepolia (Testnet)

endpointId: 40161
endpoint: 0x6edce65403992e310a62460808c4b910d972f10f

Mumbai (Polygon Testnet)

endpointId: 40109
endpoint: 0x6edce65403992e310a62460808c4b910d972f10f

Как можно заметить адреса одинаковые, L0 позаботились о нас.

endpointId (eid) это идентификатор конечной точки (ранее chainID). По ним endpoint понимает в какую сеть нужно отправить транзакцию.

hardhat config

  require("@nomicfoundation/hardhat-toolbox");
require("@nomiclabs/hardhat-etherscan");
const PRIVATE_KEY = "YOUR_KEY"
module.exports = {
  solidity: {
    compilers: [
      {
        version: "0.8.23",
        settings: {
          optimizer: {
            enabled: true,
            runs: 200,
          },
        },
      },
    ],
  },
  networks: {
      hardhat: {
        forking: {
          url: "https://eth-mainnet.g.alchemy.com/v2/API",
          blockNumber: 18928295
        },
      },
      main: {
        url: "https://eth-mainnet.g.alchemy.com/v2/API",
        chainId: 1,
        accounts: [
          PRIVATE_KEY
        ],
      },
      mumbai: {
        url: "https://polygon-mumbai.g.alchemy.com/v2/API",
        chainId: 80001,
        accounts: [
          PRIVATE_KEY
        ],
      },
      sepolia: {
        url: "https://eth-sepolia.g.alchemy.com/v2/API",
        chainId: 11155111,
        accounts: [
          PRIVATE_KEY
        ],
      },
  
      
    },
    etherscan:{
      apiKey: "API",
    }
  }

Берите его и вставляйте в hardhat.config.js

Устанавливайте свои параметры в пустые места:

PRIVATE_KEY - приватник вашего кошелька метамаск. Как его брать думаю все знают.

API для сетей sepolia и mumbai - я использую alchemy, но вы можете брать откуда вам удобно. Как создать свое приложение в alchemy можно легко найти в интернете или у меня в прошлых статьях.

Дальше есть такой интересный параметр etherscan в конце конфига. Он нужен для верификации наших контрактов. Его можно взять, если вы зайдете на ehterscan mainnet и poligonscan mainnet и авторизуетесь. Потом зайдете в свой профиль, и там найдете вкладку API Keys. Заходим и создаем.

etherscan

ВАЖНО! Хоть мы и создали api keys в etherscan и polygonscan mainnet, данный api keys можно использовать и в тестовых сетях как sepolia или mumbai. И хоть параметр в конфиге называется etherscan, мы туда вставляем api keys от etherscan и от polygonscan

И так, вы должны были создать 2 api ключа для etherscan и polygonscan, которые мы будем потом вставлять по очереди и верифицировать контракт.

Возможно это кажется достаточно сложным, но уверяю вас, если вы это сделаете, то вам больше не придется по пол часа тыкать в remix, чтоб верифицировать контракты, а просто одной командой в секунду подтверждать.

Данные настройки конфига и деплой скрипт можно использовать дальше в своих проектах просто копипастом и не париться.

Теперь команды деплоя.

Если вы работаете в remix, то просто нажимаем кнопку deploy, предварительно подключив метамаск и выбрав нужную сеть. И не забываем что при компиляции вы должны были включить optimizer.

Для hardhat:

Открываем терминал, и мы должны находится в директории своего проекта.

Выполняем 2 команды:

Sepolia:

npx hardhat run scripts\deploy.js --network sepolia 

Mumbai

npx hardhat run scripts\deploy.js --network mumbai 

Если вы сделали все правильно, указали в конфиге все параметры, то у вас должно все получиться. Сохраняем адрес развернутого контракта, и можно его открыть в etherscan и polygonscan

Теперь верификация. Самое приятное, это то что нам не нужно использовать параметр все в одном файле (flatten), как это обычно делают при верификации через remix, и отображаться в etherscan каждый контракт будет отдельно, не как один огромный файл с кучей контрактов в нем.

Если вы использовали remix, то вам нужно сделать контракт MyOFTV2 flatten и перейти в etherscan. Там уже делать верификацию через него. Как это делать я уже показывал в прошлых статьях или вы можете найти в интернете.

Команды для верификации hardhat:

Sepolia:

Вставляем в hardhat.config.js api keys от etherscan в параметр etherscan и выполняем команду

npx hardhat verify --network sepolia Address deploy contract "MyOFTV2" "MOV" "0x6edce65403992e310a62460808c4b910d972f10f" "0xA788815dDDEfb278F06C248b03e7bBc1e5Ed6009" 

Mumbai:

Вставляем в hardhat.config.js api keys от polygonscan в параметр etherscan и выполняем команду

npx hardhat verify --network mumbai address deploy contract "MyOFTV2" "MOV" "0x6edce65403992e310a62460808c4b910d972f10f" "0xA788815dDDEfb278E06F248b03e7bBc1e5Ed6009" 

Заменяем фразу Address deploy contract на адрес вашего контракта в сети sepolia и mumbai соответственно.

Также передаем параметры конструктора, я тут для примера написал свои, но вы меняйте их на свои, которые вы указывали во время деплоя. Можете скопировать их из deploy.js скрипта своего

Если вы все сделали правильно, то у вас должно было все получиться и теперь можно открывать etherscan и polygonscam и там уже работать.

Перевод токенов

Для начала открываем наш контракт в etherscan sepolia

Переходим во вкладку Write Contract и подключаем кошелек, который мы использовали во время деплоя контрактов.

Далее находим функцию setPeer

Вставляем eid mumbai и адрес контракта в сети mumbai.

ВАЖНО!!! Вы должны перевести ваш адрес в bytes32, грубо говоря просто дополнить нулями bytes20 как у меня.

eid: 40109
bytes32: 0x000000000000000000000000b68C9f0c26c0911A81A3e33338abb28245F935Ef

Теперь подключайтесь к polygonscan mumbai и делаем тоже самое, но указываем параметры сети sepolia

eid: 40161
bytes32: 0x0000000000000000000000009fb877E638F9e6De44D3043a74077eA8C8395537

Теперь возвращаемся в наш контракт в сети sepolia

И нам нужно установить enforcedOptions, вызываем функцию setEnforcedOptions

[{eid: 40109,msgType: 1,options: 0x00030100110100000000000000000000000000030d40},{eid: 40109,msgType: 2,options: 0x00030100110100000000000000000000000000030d40}]

Здесь мы передаем массив структур с двумя элементами, можно скопировать мой и просто вставить.

структура состоит из 3 параметров:

1) eid уже известный нам параметр
40109 для mumbai

2) msgType тут есть только 2 типа сообщения и мы оба укажем, хоть будем пользоваться только SEND

SEND = 1

SEND_AND_CALL = 2

3) Это наши сгенерированные options ранее. 0x00030100110100000000000000000000000000030d40

Теперь главное мы выполняем вызов функции send

не уместилась в один скрин вся функция

Наконец разбираемся с параметрами функции send:

msg.value: 0.002 
dstEid: 40109 
to: 0x000000000000000000000000a788815dddefb278e06c248b03e7bbc1e5ed6009
amountLD: 543212345432123
minamountLD: 543000000000000
extraOptions: 0x
composeMsg: 0x
oftCmd: 0x
nativeFee: 2000000000000000
lzTokenFee: 0
_refundAddress: 0xA788815dDDEfb278E06C248b03e7bBc1e5Ed6009

msg.value - это сумма на оплату транзакции в другой сети, 0.02 будет нам достаточно

dstEid - уже знаем

to - адрес на который мы переводим токены в другой сети

amountLD - количество токенов

minamountLD - минимальное количество токенов, которое будет отправлено, так как мы помним что у нас есть sharedDecimals которые, выравнивают decimals для не evm сетей, и так как мы указали 6, а у нашего токена decimals 18, то разумеется будет нужно выравнивать цену и мы сами можем указать это число. Как это сделать? Просто указать до 6 знаков amount. Так как у меня amount только 15 знаков, то и соответственно максимальное возможное число это 543000000000000 в minamountLD

extraOptions - дополнительные опции которые склеиваются с enforcedOptrions если это требуется. Главное не дублируйте их, а то 2 раза выполните транзакцию.

composeMsg - дополнительное сообщение или логика, которое выполниться после перевода, если это требуется

nativeFee - ВАЖНО указать тоже число что и msg.value

lzTokenFee - если вы хотите оплатить в токенах L0, иначе 0

_refundAddress - адрес куда придут средства в случае неудачно транзакции.

Теперь вызываем функцию send и смотрим на нашу транзакцию в LayerZero scan

Для этого вставляйте в поиск L0 scan хэш вашей транзакции сети sepolia

Вот допустим моя транзакция которую я только что указал,

Тут мы можем увидеть хэш транзакции в сети sepolia и потом хэш транзакции в сети mumbai, дожидаемся Status: Delivered и проверяем баланс токенов в сети mumbai

Если мы зайдем в наш контракт в mumbai и вызовем функцию balanceOf, то увидим, что баланс токенов у нас увеличился, и как мы видим сумма была выравнена. (P.S там 643,а не 543, потому что я уже переводил до этого токены)

Особенность OFT V2 как можно заметить это sharedDecimals, и если вы работаете в evm сетях подобных ethereum, то лучше ставить sharedDecimals = 18.

На этом думаю можно закончить. Сегодня мы не только выполнили перевод токенов между сетями, но и разобрались что именно происходит в этот момент. Всем удачи.

Полезные ссылки:

Различие OFT V1 и OFT V2

Документация L0

моя транзакция на L0 scan

Мой телегам канал