Покрокове керівництво для стандартного мостового контракту Optimism
Optimism (opens in a new tab) — це оптимістичний ролап. Оптимістичні ролапи можуть обробляти транзакції за набагато нижчою ціною, ніж мережа Ethereum Mainnet (також відома як рівень 1 або L1), оскільки транзакції обробляються лише кількома вузлами, а не кожним вузлом у мережі. Водночас усі дані записуються на L1, тому все можна перевірити та реконструювати з усіма гарантіями цілісності та доступності Mainnet.
Щоб використовувати активи L1 на Optimism (або будь-якому іншому L2), їх потрібно переказати через міст. Один зі способів досягти цього — заблокувати активи (найпоширенішими є ETH і токени ERC-20) на L1 та отримати еквівалентні активи для використання на L2. Зрештою, той, у кого вони опиняться, може захотіти повернути їх на L1 через міст. При цьому активи спалюються на L2, а потім вивільняються користувачеві на L1.
Саме так працює стандартний міст Optimism (opens in a new tab). У цій статті ми розглянемо вихідний код цього мосту, щоб побачити, як він працює, і вивчимо його як приклад добре написаного коду на Solidity.
Потоки керування
Міст має два основні потоки:
- Внесення (з L1 до L2)
- Виведення (з L2 до L1)
Процес внесення
Рівень 1
- При внесенні ERC-20, вкладник надає мосту дозвіл (allowance) на витрату суми, що вноситься
- Вкладник викликає міст L1 (
depositERC20,depositERC20To,depositETH, абоdepositETHTo) - Міст L1 отримує у володіння актив, що переказується через міст
- ETH: актив передається вкладником у межах виклику
- ERC-20: актив переказується мостом самому собі з використанням дозволу (allowance), наданого вкладником
- Міст L1 використовує механізм міждоменних повідомлень для виклику
finalizeDepositна мосту L2
Рівень 2
- Міст L2 перевіряє, чи є виклик
finalizeDepositлегітимним:- Надійшло з контракту міждоменних повідомлень
- Початково надійшло з мосту на L1
- Міст L2 перевіряє, чи є контракт токена ERC-20 на L2 правильним:
- Контракт L2 повідомляє, що його аналог L1 збігається з тим, звідки надійшли токени на L1
- Контракт L2 повідомляє, що підтримує правильний інтерфейс (з використанням ERC-165 (opens in a new tab)).
- Якщо контракт L2 правильний, викликати його, щоб викарбувати відповідну кількість токенів на відповідну адресу. Якщо ні, розпочати процес виведення, щоб дозволити користувачеві отримати токени на L1.
Процес виведення
Рівень 2
- Той, хто виводить кошти, викликає міст L2 (
withdrawабоwithdrawTo) - Міст L2 спалює відповідну кількість токенів, що належать
msg.sender - Міст L2 використовує механізм міждоменних повідомлень для виклику
finalizeETHWithdrawalабоfinalizeERC20Withdrawalна мосту L1
Рівень 1
- Міст L1 перевіряє, чи є виклик
finalizeETHWithdrawalабоfinalizeERC20Withdrawalлегітимним:- Надійшло з механізму міждоменних повідомлень
- Початково надійшло з мосту на L2
- Міст L1 переказує відповідний актив (ETH або ERC-20) на відповідну адресу
Код рівня 1
Це код, який виконується на L1, у мережі Ethereum Mainnet.
IL1ERC20Bridge
Цей інтерфейс визначено тут (opens in a new tab). Він містить функції та визначення, необхідні для переказу токенів ERC-20 через міст.
1// SPDX-License-Identifier: MITБільша частина коду Optimism випущена під ліцензією MIT (opens in a new tab).
1pragma solidity >0.5.0 <0.9.0;На момент написання статті остання версія Solidity — 0.8.12. Поки не випущено версію 0.9.0, ми не знаємо, чи сумісний цей код із нею.
1/**2 * @title IL1ERC20Bridge3 */4interface IL1ERC20Bridge {5 /**********6 * Події *7 **********/89 event ERC20DepositInitiated(Показати всеУ термінології мосту Optimism deposit означає переказ з L1 на L2, а withdrawal — переказ з L2 на L1.
1 address indexed _l1Token,2 address indexed _l2Token,У більшості випадків адреса ERC-20 на L1 не збігається з адресою еквівалентного ERC-20 на L2.
Список адрес токенів можна переглянути тут (opens in a new tab).
Адреса з chainId 1 знаходиться на L1 (Mainnet), а адреса з chainId 10 — на L2 (Optimism).
Інші два значення chainId призначені для тестової мережі Kovan (42) та оптимістичної тестової мережі Kovan (69).
1 address indexed _from,2 address _to,3 uint256 _amount,4 bytes _data5 );Можна додавати примітки до переказів, і в такому разі вони додаються до подій, що їх фіксують.
1 event ERC20WithdrawalFinalized(2 address indexed _l1Token,3 address indexed _l2Token,4 address indexed _from,5 address _to,6 uint256 _amount,7 bytes _data8 );Той самий контракт мосту обробляє перекази в обох напрямках. У випадку мосту L1 це означає ініціалізацію внесення та фіналізацію виведення коштів.
12 /********************3 * Публічні функції *4 ********************/56 /**7 * @dev отримати адресу відповідного контракту мосту L2.8 * @return Адреса відповідного контракту мосту L2.9 */10 function l2TokenBridge() external returns (address);Показати всеЦя функція насправді не потрібна, оскільки на L2 це попередньо розгорнутий контракт, тому вона завжди знаходиться за адресою 0x4200000000000000000000000000000000000010.
Вона тут для симетрії з мостом L2, оскільки адресу мосту L1 дізнатися не так просто.
1 /**2 * @dev внести суму ERC20 на баланс викликаючого на L2.3 * @param _l1Token Адреса L1 ERC20, який ми вносимо4 * @param _l2Token Адреса відповідного L2 ERC20 на L15 * @param _amount Сума ERC20 для внесення6 * @param _l2Gas Ліміт газу, необхідний для завершення внесення на L2.7 * @param _data Необов’язкові дані для пересилання на L2. Ці дані надаються8 * виключно для зручності зовнішніх контрактів. Крім встановлення максимальної9 * довжини, ці контракти не дають жодних гарантій щодо їхнього вмісту.10 */11 function depositERC20(12 address _l1Token,13 address _l2Token,14 uint256 _amount,15 uint32 _l2Gas,16 bytes calldata _data17 ) external;Показати всеПараметр _l2Gas — це кількість газу L2, яку транзакція може витратити.
До певного (високого) ліміту це безкоштовно (opens in a new tab), тому, якщо контракт ERC-20 не робить нічого дивного під час карбування, це не повинно бути проблемою.
Ця функція обробляє поширений сценарій, коли користувач переказує активи через міст на ту саму адресу в іншому блокчейні.
1 /**2 * @dev внести суму ERC20 на баланс одержувача на L2.3 * @param _l1Token Адреса L1 ERC20, який ми вносимо4 * @param _l2Token Адреса відповідного L2 ERC20 на L15 * @param _to Адреса L2, на яку буде зараховано виведення коштів.6 * @param _amount Сума ERC20 для внесення.7 * @param _l2Gas Ліміт газу, необхідний для завершення внесення на L2.8 * @param _data Необов’язкові дані для пересилання на L2. Ці дані надаються9 * виключно для зручності зовнішніх контрактів. Крім встановлення максимальної10 * довжини, ці контракти не дають жодних гарантій щодо їхнього вмісту.11 */12 function depositERC20To(13 address _l1Token,14 address _l2Token,15 address _to,16 uint256 _amount,17 uint32 _l2Gas,18 bytes calldata _data19 ) external;Показати всеЦя функція майже ідентична depositERC20, але вона дозволяє надсилати ERC-20 на іншу адресу.
1 /*************************2 * Міжмережеві функції *3 *************************/45 /**6 * @dev Завершити виведення з L2 на L1 і зарахувати кошти на баланс одержувача7 * токена L1 ERC20.8 * Цей виклик не вдасться, якщо ініційоване виведення з L2 не було фіналізовано.9 *10 * @param _l1Token Адреса токена L1, для якого виконується finalizeWithdrawal.11 * @param _l2Token Адреса токена L2, де було ініційовано виведення.12 * @param _from Адреса L2, що ініціює переказ.13 * @param _to Адреса L1, на яку буде зараховано виведення коштів.14 * @param _amount Сума ERC20 для внесення.15 * @param _data Дані, надані відправником на L2. Ці дані надаються16 * виключно для зручності зовнішніх контрактів. Крім встановлення максимальної17 * довжини, ці контракти не дають жодних гарантій щодо їхнього вмісту.18 */19 function finalizeERC20Withdrawal(20 address _l1Token,21 address _l2Token,22 address _from,23 address _to,24 uint256 _amount,25 bytes calldata _data26 ) external;27}Показати всеВиведення коштів (та інші повідомлення з L2 на L1) в Optimism — це двоетапний процес:
- Ініціююча транзакція на L2.
- Фіналізуюча або підтверджуюча транзакція на L1. Ця транзакція має відбутися після завершення періоду оскарження помилок (opens in a new tab) для транзакції L2.
IL1StandardBridge
Цей інтерфейс визначено тут (opens in a new tab).
Цей файл містить визначення подій та функцій для ETH.
Ці визначення дуже схожі на визначені вище в IL1ERC20Bridge для ERC-20.
Інтерфейс мосту розділено на два файли, оскільки деякі токени ERC-20 вимагають спеціальної обробки і не можуть бути оброблені стандартним мостом.
Таким чином, спеціальний міст, який обробляє такий токен, може реалізувати IL1ERC20Bridge і не мусить також переказувати ETH через міст.
1// SPDX-License-Identifier: MIT2pragma solidity >0.5.0 <0.9.0;34import "./IL1ERC20Bridge.sol";56/**7 * @title IL1StandardBridge8 */9interface IL1StandardBridge is IL1ERC20Bridge {10 /**********11 * Події *12 **********/13 event ETHDepositInitiated(14 address indexed _from,15 address indexed _to,16 uint256 _amount,17 bytes _data18 );Показати всеЦя подія майже ідентична версії ERC-20 (ERC20DepositInitiated), за винятком відсутності адрес токенів L1 та L2.
Те саме стосується інших подій і функцій.
1 event ETHWithdrawalFinalized(2 .3 .4 .5 );67 /********************8 * Публічні функції *9 ********************/1011 /**12 * @dev Внести суму ETH на баланс викликаючого на L2.13 .14 .15 .16 */17 function depositETH(uint32 _l2Gas, bytes calldata _data) external payable;1819 /**20 * @dev Внести суму ETH на баланс одержувача на L2.21 .22 .23 .24 */25 function depositETHTo(26 address _to,27 uint32 _l2Gas,28 bytes calldata _data29 ) external payable;3031 /*************************32 * Міжмережеві функції *33 *************************/3435 /**36 * @dev Завершити виведення з L2 на L1 і зарахувати кошти на баланс одержувача37 * токена L1 ETH. Оскільки тільки xDomainMessenger може викликати цю функцію, вона ніколи не буде викликана38 * до фіналізації виведення.39 .40 .41 .42 */43 function finalizeETHWithdrawal(44 address _from,45 address _to,46 uint256 _amount,47 bytes calldata _data48 ) external;49}Показати всеCrossDomainEnabled
Цей контракт (opens in a new tab) успадковується обома мостами (L1 і L2) для надсилання повідомлень на інший рівень.
1// SPDX-License-Identifier: MIT2pragma solidity >0.5.0 <0.9.0;34/* Імпорт інтерфейсів */5import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";Цей інтерфейс (opens in a new tab) повідомляє контракту, як надсилати повідомлення на інший рівень за допомогою міждоменного месенджера. Цей міждоменний месенджер — це ціла окрема система, яка заслуговує на власну статтю, яку я сподіваюся написати в майбутньому.
1/**2 * @title CrossDomainEnabled3 * @dev Допоміжний контракт для контрактів, що виконують міждоменні комунікації4 *5 * Використаний компілятор: визначається контрактом, що успадковує6 */7contract CrossDomainEnabled {8 /*************9 * Змінні *10 *************/1112 // Контракт месенджера, що використовується для надсилання та отримання повідомлень з іншого домену.13 address public messenger;1415 /***************16 * Конструктор *17 ***************/1819 /**20 * @param _messenger Адреса CrossDomainMessenger на поточному рівні.21 */22 constructor(address _messenger) {23 messenger = _messenger;24 }Показати всеЄдиний параметр, який має знати контракт, — це адреса міждоменного месенджера на цьому рівні. Цей параметр встановлюється один раз у конструкторі й ніколи не змінюється.
12 /**********************3 * Модифікатори функцій *4 **********************/56 /**7 * Застосовує обмеження, щоб змінена функція могла викликатися лише з певного міждоменного облікового запису.8 * @param _sourceDomainAccount Єдиний обліковий запис у вихідному домені, який9 * автентифікований для виклику цієї функції.10 */11 modifier onlyFromCrossDomainAccount(address _sourceDomainAccount) {Показати всеМіждоменний обмін повідомленнями доступний будь-якому контракту в блокчейні, де він виконується (або в Ethereum Mainnet, або в Optimism). Але нам потрібно, щоб міст на кожній стороні довіряв лише тим повідомленням, які надходять від мосту з іншого боку.
1 require(2 msg.sender == address(getCrossDomainMessenger()),3 "OVM_XCHAIN: контракт месенджера не автентифікований"4 );Можна довіряти лише повідомленням із відповідного міждоменного месенджера (messenger, як ви побачите нижче).
12 require(3 getCrossDomainMessenger().xDomainMessageSender() == _sourceDomainAccount,4 "OVM_XCHAIN: неправильний відправник міждоменного повідомлення"5 );Спосіб, у який міждоменний месенджер надає адресу, що надіслала повідомлення з іншого рівня, — це функція .xDomainMessageSender() (opens in a new tab).
Доки вона викликається в транзакції, ініційованій повідомленням, вона може надавати цю інформацію.
Нам потрібно переконатися, що отримане повідомлення надійшло з іншого мосту.
12 _;3 }45 /**********************6 * Внутрішні функції *7 **********************/89 /**10 * Отримує месенджер, зазвичай зі сховища. Ця функція є відкритою на випадок, якщо дочірньому контракту11 * знадобиться її перевизначити.12 * @return Адреса контракту міждоменного месенджера, який слід використовувати.13 */14 function getCrossDomainMessenger() internal virtual returns (ICrossDomainMessenger) {15 return ICrossDomainMessenger(messenger);16 }Показати всеЦя функція повертає міждоменний месенджер.
Ми використовуємо функцію, а не змінну messenger, щоб дозволити контрактам, які успадковують від цього, використовувати алгоритм для визначення, який міждоменний месенджер використовувати.
12 /**3 * Надсилає повідомлення на обліковий запис в іншому домені4 * @param _crossDomainTarget Запланований одержувач у цільовому домені5 * @param _message Дані для надсилання цілі (зазвичай calldata для функції з6 * `onlyFromCrossDomainAccount()`)7 * @param _gasLimit gasLimit для отримання повідомлення в цільовому домені.8 */9 function sendCrossDomainMessage(10 address _crossDomainTarget,11 uint32 _gasLimit,12 bytes memory _messageПоказати всеНарешті, функція, яка надсилає повідомлення на інший рівень.
1 ) internal {2 // slither-disable-next-line reentrancy-events, reentrancy-benignSlither (opens in a new tab) — це статичний аналізатор, який Optimism запускає для кожного контракту, щоб знайти вразливості та інші потенційні проблеми. У цьому випадку наступний рядок викликає дві вразливості:
1 getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);2 }3}У цьому випадку ми не турбуємося про повторний вхід, оскільки знаємо, що getCrossDomainMessenger() повертає надійну адресу, навіть якщо Slither не може цього знати.
Контракт мосту L1
Вихідний код цього контракту тут (opens in a new tab).
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;Інтерфейси можуть бути частиною інших контрактів, тому вони мають підтримувати широкий діапазон версій Solidity. Але сам міст — це наш контракт, і ми можемо бути суворими щодо версії Solidity, яку він використовує.
1/* Імпорт інтерфейсів */2import { IL1StandardBridge } from "./IL1StandardBridge.sol";3import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";IL1ERC20Bridge та IL1StandardBridge пояснено вище.
1import { IL2ERC20Bridge } from "../../L2/messaging/IL2ERC20Bridge.sol";Цей інтерфейс (opens in a new tab) дозволяє нам створювати повідомлення для керування стандартним мостом на L2.
1import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";Цей інтерфейс (opens in a new tab) дозволяє нам керувати контрактами ERC-20. Детальніше про це можна прочитати тут.
1/* Імпорт бібліотек */2import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";Як пояснювалося вище, цей контракт використовується для міжрівневого обміну повідомленнями.
1import { Lib_PredeployAddresses } from "../../libraries/constants/Lib_PredeployAddresses.sol";Lib_PredeployAddresses (opens in a new tab) містить адреси контрактів L2, які завжди мають однакову адресу. Це включає стандартний міст на L2.
1import { Address } from "@openzeppelin/contracts/utils/Address.sol";Утиліти Address від OpenZeppelin (opens in a new tab). Він використовується для розрізнення адрес контрактів і тих, що належать зовнішнім обліковим записам (EOA).
Зауважте, що це не ідеальне рішення, оскільки немає способу розрізнити прямі виклики та виклики, зроблені з конструктора контракту, але принаймні це дозволяє нам виявляти та запобігати деяким поширеним помилкам користувачів.
1import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";Стандарт ERC-20 (opens in a new tab) підтримує два способи повідомлення контрактом про помилку:
- Revert
- Повернення
false
Обробка обох випадків ускладнила б наш код, тому замість цього ми використовуємо SafeERC20 від OpenZeppelin (opens in a new tab), який гарантує, що всі помилки призводять до revert (opens in a new tab).
1/**2 * @title L1StandardBridge3 * @dev Міст L1 для ETH та ERC20 — це контракт, який зберігає внесені кошти L1 та стандартні4 * токени, що використовуються на L2. Він синхронізує відповідний міст L2, інформуючи його про депозити5 * та очікуючи від нього повідомлень про нові фіналізовані виведення коштів.6 *7 */8contract L1StandardBridge is IL1StandardBridge, CrossDomainEnabled {9 using SafeERC20 for IERC20;Показати всеЦей рядок визначає використання обгортки SafeERC20 щоразу, коли ми використовуємо інтерфейс IERC20.
12 /********************************3 * Посилання на зовнішні контракти *4 ********************************/56 address public l2TokenBridge;Адреса L2StandardBridge.
12 // Відображає токен L1 на токен L2 до балансу внесеного токена L13 mapping(address => mapping(address => uint256)) public deposits;Подвійне відображення (mapping) (opens in a new tab) — це спосіб визначення двовимірного розрідженого масиву (opens in a new tab).
Значення в цій структурі даних ідентифікуються як deposit[адреса токена L1][адреса токена L2].
Значення за замовчуванням — нуль.
До сховища записуються лише ті комірки, яким встановлено інше значення.
12 /***************3 * Конструктор *4 ***************/56 // Цей контракт працює за проксі, тому параметри конструктора не будуть використовуватися.7 constructor() CrossDomainEnabled(address(0)) {}Щоб мати можливість оновити цей контракт без необхідності копіювати всі змінні в сховищі.
Для цього ми використовуємо Proxy (opens in a new tab), контракт, який використовує delegatecall (opens in a new tab) для переадресації викликів до окремого контракту, чия адреса зберігається в контракті проксі (під час оновлення ви повідомляєте проксі змінити цю адресу).
Коли ви використовуєте delegatecall, сховище залишається сховищем викликаючого контракту, тому значення всіх змінних стану контракту не змінюються.
Одним з наслідків цього шаблону є те, що сховище викликаного контракту delegatecall не використовується, і тому значення конструктора, передані йому, не мають значення.
Саме тому ми можемо надати безглузде значення конструктору CrossDomainEnabled.
Це також причина, чому наведена нижче ініціалізація відокремлена від конструктора.
1 /******************2 * Ініціалізація *3 ******************/45 /**6 * @param _l1messenger Адреса L1 Messenger, що використовується для міжмережевих комунікацій.7 * @param _l2TokenBridge Адреса стандартного мосту L2.8 */9 // slither-disable-next-line external-functionПоказати всеЦей тест Slither (opens in a new tab) виявляє функції, які не викликаються з коду контракту і тому можуть бути оголошені як external замість public.
Вартість газу для функцій external може бути нижчою, оскільки їм можна передавати параметри в calldata.
Функції, оголошені як public, мають бути доступні зсередини контракту.
Контракти не можуть змінювати власні calldata, тому параметри мають бути в пам’яті.
Коли така функція викликається ззовні, необхідно скопіювати calldata в пам’ять, що коштує газу.
У цьому випадку функція викликається лише один раз, тому неефективність для нас не має значення.
1 function initialize(address _l1messenger, address _l2TokenBridge) public {2 require(messenger == address(0), "Контракт уже ініціалізовано.");Функція initialize має викликатися лише один раз.
Якщо адреса міждоменного месенджера L1 або мосту токенів L2 змінюється, ми створюємо новий проксі та новий міст, який його викликає.
Це навряд чи станеться, за винятком випадків оновлення всієї системи, що буває дуже рідко.
Зауважте, що ця функція не має механізму, який обмежує, хто може її викликати.
Це означає, що теоретично зловмисник може почекати, доки ми розгорнемо проксі та першу версію мосту, а потім випередити (front-run) (opens in a new tab), щоб дістатися до функції initialize раніше, ніж це зробить легітимний користувач. Але є два способи запобігти цьому:
- Якщо контракти розгортаються не безпосередньо EOA, а у транзакції, яка змушує інший контракт створювати їх (opens in a new tab), весь процес може бути атомарним і завершитися до виконання будь-якої іншої транзакції.
- Якщо легітимний виклик
initializeне вдається, завжди можна проігнорувати щойно створений проксі та міст і створити нові.
1 messenger = _l1messenger;2 l2TokenBridge = _l2TokenBridge;3 }Це два параметри, які міст повинен знати.
12 /**************3 * Внесення *4 **************/56 /** @dev Модифікатор, що вимагає, щоб відправник був EOA. Цю перевірку може7 * обійти зловмисний контракт через initcode, але вона запобігає помилці користувача, якої ми хочемо уникнути.8 */9 modifier onlyEOA() {10 // Використовується для зупинки депозитів з контрактів (щоб уникнути випадкової втрати токенів)11 require(!Address.isContract(msg.sender), "Обліковий запис не є EOA");12 _;13 }Показати всеОсь чому нам знадобилися утиліти Address від OpenZeppelin.
1 /**2 * @dev Цю функцію можна викликати без даних3 * для внесення суми ETH на баланс викликаючого на L2.4 * Оскільки функція receive не приймає дані, консервативна5 * стандартна сума пересилається на L2.6 */7 receive() external payable onlyEOA {8 _initiateETHDeposit(msg.sender, msg.sender, 200_000, bytes(""));9 }Показати всеЦя функція існує для тестування. Зверніть увагу, що вона не з’являється у визначеннях інтерфейсу — вона не для звичайного використання.
1 /**2 * @inheritdoc IL1StandardBridge3 */4 function depositETH(uint32 _l2Gas, bytes calldata _data) external payable onlyEOA {5 _initiateETHDeposit(msg.sender, msg.sender, _l2Gas, _data);6 }78 /**9 * @inheritdoc IL1StandardBridge10 */11 function depositETHTo(12 address _to,13 uint32 _l2Gas,14 bytes calldata _data15 ) external payable {16 _initiateETHDeposit(msg.sender, _to, _l2Gas, _data);17 }Показати всеЦі дві функції є обгортками навколо _initiateETHDeposit, функції, яка обробляє фактичне внесення ETH.
1 /**2 * @dev Виконує логіку для депозитів, зберігаючи ETH та інформуючи шлюз L2 ETH про3 * депозит.4 * @param _from Обліковий запис, з якого буде взято депозит на L1.5 * @param _to Обліковий запис, на який буде зараховано депозит на L2.6 * @param _l2Gas Ліміт газу, необхідний для завершення депозиту на L2.7 * @param _data Необов’язкові дані для пересилання на L2. Ці дані надаються8 * виключно для зручності зовнішніх контрактів. Крім встановлення максимальної9 * довжини, ці контракти не дають жодних гарантій щодо їхнього вмісту.10 */11 function _initiateETHDeposit(12 address _from,13 address _to,14 uint32 _l2Gas,15 bytes memory _data16 ) internal {17 // Сконструювати calldata для виклику finalizeDeposit18 bytes memory message = abi.encodeWithSelector(Показати всеСпосіб роботи міждоменних повідомлень полягає в тому, що контракт призначення викликається з повідомленням як його calldata.
Контракти Solidity завжди інтерпретують свої calldata відповідно до
специфікацій ABI (opens in a new tab).
Функція Solidity abi.encodeWithSelector (opens in a new tab) створює ці calldata.
1 IL2ERC20Bridge.finalizeDeposit.selector,2 address(0),3 Lib_PredeployAddresses.OVM_ETH,4 _from,5 _to,6 msg.value,7 _data8 );Повідомлення тут — це виклик функції finalizeDeposit (opens in a new tab) з такими параметрами:
| Параметр | Значення | Значення |
|---|---|---|
| _l1Token | address(0) | Спеціальне значення для ETH (який не є токеном ERC-20) на L1 |
| _l2Token | Lib_PredeployAddresses.OVM_ETH | Контракт L2, який керує ETH на Optimism, 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (цей контракт призначений лише для внутрішнього використання Optimism) |
| _from | _from | Адреса на L1, яка надсилає ETH |
| _to | _to | Адреса на L2, яка отримує ETH |
| сума | msg.value | Кількість надісланих wei (які вже надіслано на міст) |
| _data | _data | Додаткові дані, які додаються до депозиту |
1 // Надіслати calldata на L22 // slither-disable-next-line reentrancy-events3 sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);Надішліть повідомлення через міждоменний месенджер.
1 // slither-disable-next-line reentrancy-events2 emit ETHDepositInitiated(_from, _to, msg.value, _data);3 }Надішліть подію, щоб повідомити будь-який децентралізований застосунок, який відстежує цей переказ.
1 /**2 * @inheritdoc IL1ERC20Bridge3 */4 function depositERC20(5 .6 .7 .8 ) external virtual onlyEOA {9 _initiateERC20Deposit(_l1Token, _l2Token, msg.sender, msg.sender, _amount, _l2Gas, _data);10 }1112 /**13 * @inheritdoc IL1ERC20Bridge14 */15 function depositERC20To(16 .17 .18 .19 ) external virtual {20 _initiateERC20Deposit(_l1Token, _l2Token, msg.sender, _to, _amount, _l2Gas, _data);21 }Показати всеЦі дві функції є обгортками навколо _initiateERC20Deposit, функції, яка обробляє фактичне внесення ERC-20.
1 /**2 * @dev Виконує логіку для депозитів, інформуючи токен L2 Deposited Token3 * про депозит і викликаючи обробник для блокування коштів L1. (наприклад, transferFrom)4 *5 * @param _l1Token Адреса L1 ERC20, який ми вносимо6 * @param _l2Token Адреса відповідного L2 ERC20 на L17 * @param _from Обліковий запис, з якого буде взято депозит на L18 * @param _to Обліковий запис, на який буде зараховано депозит на L29 * @param _amount Сума ERC20 для внесення.10 * @param _l2Gas Ліміт газу, необхідний для завершення депозиту на L2.11 * @param _data Необов’язкові дані для пересилання на L2. Ці дані надаються12 * виключно для зручності зовнішніх контрактів. Крім встановлення максимальної13 * довжини, ці контракти не дають жодних гарантій щодо їхнього вмісту.14 */15 function _initiateERC20Deposit(16 address _l1Token,17 address _l2Token,18 address _from,19 address _to,20 uint256 _amount,21 uint32 _l2Gas,22 bytes calldata _data23 ) internal {Показати всеЦя функція подібна до _initiateETHDeposit вище, з кількома важливими відмінностями.
Перша відмінність полягає в тому, що ця функція отримує адреси токенів і суму для переказу як параметри.
У випадку з ETH виклик мосту вже включає переказ активу на рахунок мосту (msg.value).
1 // Коли депозит ініціюється на L1, міст L1 переказує кошти на свій рахунок для майбутніх2 // виведень. safeTransferFrom також перевіряє, чи є у контракту код, тому це не спрацює, якщо3 // _from є EOA або address(0).4 // slither-disable-next-line reentrancy-events, reentrancy-benign5 IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);Перекази токенів ERC-20 відбуваються за іншим процесом, ніж ETH:
- Користувач (
_from) надає дозвіл мосту на переказ відповідних токенів. - Користувач викликає міст з адресою контракту токена, сумою тощо.
- Міст переказує токени (собі) у рамках процесу внесення.
Перший крок може відбуватися в окремій транзакції від останніх двох.
Однак випередження (front-running) не є проблемою, оскільки дві функції, які викликають _initiateERC20Deposit (depositERC20 і depositERC20To), викликають цю функцію лише з msg.sender як параметром _from.
1 // Сконструювати calldata для _l2Token.finalizeDeposit(_to, _amount)2 bytes memory message = abi.encodeWithSelector(3 IL2ERC20Bridge.finalizeDeposit.selector,4 _l1Token,5 _l2Token,6 _from,7 _to,8 _amount,9 _data10 );1112 // Надіслати calldata на L213 // slither-disable-next-line reentrancy-events, reentrancy-benign14 sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);1516 // slither-disable-next-line reentrancy-benign17 deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] + _amount;Показати всеДодайте внесену кількість токенів до структури даних deposits.
На L2 може бути кілька адрес, які відповідають одному токену L1 ERC-20, тому для відстеження депозитів недостатньо використовувати баланс токена L1 ERC-20 на мосту.
12 // slither-disable-next-line reentrancy-events3 emit ERC20DepositInitiated(_l1Token, _l2Token, _from, _to, _amount, _data);4 }56 /*************************7 * Міжмережеві функції *8 *************************/910 /**11 * @inheritdoc IL1StandardBridge12 */13 function finalizeETHWithdrawal(14 address _from,15 address _to,16 uint256 _amount,17 bytes calldata _dataПоказати всеМіст L2 надсилає повідомлення до міждоменного месенджера L2, який змушує міждоменний месенджер L1 викликати цю функцію (звичайно, коли транзакція, яка завершує повідомлення (opens in a new tab), надсилається на L1).
1 ) external onlyFromCrossDomainAccount(l2TokenBridge) {Переконайтеся, що це легітимне повідомлення, яке надходить від міждоменного месенджера та походить від мосту токенів L2. Ця функція використовується для виведення ETH з мосту, тому ми повинні переконатися, що її викликає лише авторизований викликаючий.
1 // slither-disable-next-line reentrancy-events2 (bool success, ) = _to.call{ value: _amount }(new bytes(0));Спосіб переказу ETH полягає у виклику одержувача із сумою wei в msg.value.
1 require(success, "TransferHelper::safeTransferETH: не вдалося переказати ETH");23 // slither-disable-next-line reentrancy-events4 emit ETHWithdrawalFinalized(_from, _to, _amount, _data);Випромінюйте подію про виведення коштів.
1 }23 /**4 * @inheritdoc IL1ERC20Bridge5 */6 function finalizeERC20Withdrawal(7 address _l1Token,8 address _l2Token,9 address _from,10 address _to,11 uint256 _amount,12 bytes calldata _data13 ) external onlyFromCrossDomainAccount(l2TokenBridge) {Показати всеЦя функція подібна до finalizeETHWithdrawal вище, з необхідними змінами для токенів ERC-20.
1 deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] - _amount;Оновіть структуру даних deposits.
12 // Коли виведення фіналізується на L1, міст L1 переказує кошти тому, хто виводить3 // slither-disable-next-line reentrancy-events4 IERC20(_l1Token).safeTransfer(_to, _amount);56 // slither-disable-next-line reentrancy-events7 emit ERC20WithdrawalFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);8 }91011 /*****************************12 * Тимчасово - міграція ETH *13 *****************************/1415 /**16 * @dev Додає баланс ETH до облікового запису. Це призначено для того, щоб дозволити міграцію ETH17 * зі старого шлюзу на новий.18 * ПРИМІТКА: Це залишено лише для одного оновлення, щоб ми могли отримати мігрований ETH зі19 * старого контракту20 */21 function donateETH() external payable {}22}Показати всеІснувала рання реалізація мосту.
Коли ми переходили від тієї реалізації до цієї, нам довелося перемістити всі активи.
Токени ERC-20 можна просто перемістити.
Однак для переказу ETH до контракту потрібне схвалення цього контракту, що нам і надає donateETH.
Токени ERC-20 на L2
Щоб токен ERC-20 підходив до стандартного мосту, він має дозволяти стандартному мосту, і лише стандартному мосту, карбувати токен. Це необхідно, оскільки мости повинні гарантувати, що кількість токенів, що обертаються на Optimism, дорівнює кількості токенів, заблокованих у контракті мосту L1. Якщо на L2 буде занадто багато токенів, деякі користувачі не зможуть переказати свої активи назад на L1 через міст. Замість надійного мосту ми, по суті, відтворимо банківську систему з частковим резервуванням (opens in a new tab). Якщо на L1 забагато токенів, деякі з них назавжди залишаться заблокованими в бридж-контракті, оскільки їх неможливо звільнити, не спаливши токени L2.
IL2StandardERC20
Кожен токен ERC-20 на L2, який використовує стандартний міст, повинен надавати цей інтерфейс (opens in a new tab), який має функції та події, необхідні стандартному мосту.
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";Стандартний інтерфейс ERC-20 (opens in a new tab) не включає функції mint та burn.
Ці методи не вимагаються стандартом ERC-20 (opens in a new tab), який не визначає механізми створення та знищення токенів.
1import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol";Інтерфейс ERC-165 (opens in a new tab) використовується для визначення того, які функції надає контракт. Стандарт можна прочитати тут (opens in a new tab).
1interface IL2StandardERC20 is IERC20, IERC165 {2 function l1Token() external returns (address);Ця функція надає адресу токена L1, який переказується на цей контракт через міст. Зауважте, що у нас немає подібної функції у зворотному напрямку. Нам потрібно мати можливість переказувати будь-який токен L1 через міст, незалежно від того, чи планувалася підтримка L2 під час його впровадження.
12 function mint(address _to, uint256 _amount) external;34 function burn(address _from, uint256 _amount) external;56 event Mint(address indexed _account, uint256 _amount);7 event Burn(address indexed _account, uint256 _amount);8}Функції та події для карбування (створення) і спалювання (знищення) токенів. Міст має бути єдиною сутністю, яка може запускати ці функції, щоб переконатися, що кількість токенів правильна (дорівнює кількості токенів, заблокованих на L1).
L2StandardERC20
Це наша реалізація інтерфейсу IL2StandardERC20 (opens in a new tab).
Якщо вам не потрібна якась спеціальна логіка, ви повинні використовувати цю.
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";Контракт ERC-20 від OpenZeppelin (opens in a new tab). Optimism не вірить у винахід колеса, особливо коли колесо добре перевірено та має бути достатньо надійним, щоб зберігати активи.
1import "./IL2StandardERC20.sol";23contract L2StandardERC20 is IL2StandardERC20, ERC20 {4 address public l1Token;5 address public l2Bridge;Це два додаткові параметри конфігурації, які потрібні нам, а ERC-20 зазвичай не вимагає.
12 /**3 * @param _l2Bridge Адреса стандартного мосту L2.4 * @param _l1Token Адреса відповідного токена L1.5 * @param _name Назва ERC20.6 * @param _symbol Символ ERC20.7 */8 constructor(9 address _l2Bridge,10 address _l1Token,11 string memory _name,12 string memory _symbol13 ) ERC20(_name, _symbol) {14 l1Token = _l1Token;15 l2Bridge = _l2Bridge;16 }Показати всеСпочатку викликаємо конструктор для контракту, від якого ми успадковуємо (ERC20(_name, _symbol)), а потім встановлюємо наші власні змінні.
12 modifier onlyL2Bridge() {3 require(msg.sender == l2Bridge, "Тільки міст L2 може карбувати та спалювати");4 _;5 }678 // slither-disable-next-line external-function9 function supportsInterface(bytes4 _interfaceId) public pure returns (bool) {10 bytes4 firstSupportedInterface = bytes4(keccak256("supportsInterface(bytes4)")); // ERC16511 bytes4 secondSupportedInterface = IL2StandardERC20.l1Token.selector ^12 IL2StandardERC20.mint.selector ^13 IL2StandardERC20.burn.selector;14 return _interfaceId == firstSupportedInterface || _interfaceId == secondSupportedInterface;15 }Показати всеСаме так працює ERC-165 (opens in a new tab). Кожен інтерфейс — це набір підтримуваних функцій, що ідентифікується як виключне АБО (opens in a new tab) селекторів функцій ABI (opens in a new tab) цих функцій.
Міст L2 використовує ERC-165 як перевірку на адекватність, щоб переконатися, що контракт ERC-20, на який він надсилає активи, є IL2StandardERC20.
Примітка: ніщо не заважає зловмисному контракту надавати неправдиві відповіді на supportsInterface, тому це механізм перевірки на адекватність, а не механізм безпеки.
1 // slither-disable-next-line external-function2 function mint(address _to, uint256 _amount) public virtual onlyL2Bridge {3 _mint(_to, _amount);45 emit Mint(_to, _amount);6 }78 // slither-disable-next-line external-function9 function burn(address _from, uint256 _amount) public virtual onlyL2Bridge {10 _burn(_from, _amount);1112 emit Burn(_from, _amount);13 }14}Показати всеЛише на мосту L2 дозволено карбувати та спалювати активи.
_mint та _burn насправді визначені в контракті ERC-20 від OpenZeppelin.
Цей контракт просто не робить їх доступними ззовні, оскільки умови для карбування та спалювання токенів настільки ж різноманітні, як і кількість способів використання ERC-20.
Код мосту L2
Це код, який запускає міст у Optimism. Вихідний код цього контракту тут (opens in a new tab).
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.9;34/* Імпорт інтерфейсів */5import { IL1StandardBridge } from "../../L1/messaging/IL1StandardBridge.sol";6import { IL1ERC20Bridge } from "../../L1/messaging/IL1ERC20Bridge.sol";7import { IL2ERC20Bridge } from "./IL2ERC20Bridge.sol";Інтерфейс IL2ERC20Bridge (opens in a new tab) дуже схожий на еквівалент L1, який ми бачили вище. Є дві істотні відмінності:
- На L1 ви ініціюєте внесення та фіналізуєте виведення коштів. Тут ви ініціюєте виведення та фіналізуєте внесення коштів.
- На L1 необхідно розрізняти токени ETH і ERC-20. На L2 ми можемо використовувати однакові функції для обох, оскільки внутрішні баланси ETH на Optimism обробляються як токен ERC-20 з адресою 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (opens in a new tab).
1/* Імпорт бібліотек */2import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol";3import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";4import { Lib_PredeployAddresses } from "../../libraries/constants/Lib_PredeployAddresses.sol";56/* Імпорт контрактів */7import { IL2StandardERC20 } from "../../standards/IL2StandardERC20.sol";89/**10 * @title L2StandardBridge11 * @dev Стандартний міст L2 — це контракт, який працює разом зі стандартним мостом L1, щоб12 * забезпечити перекази ETH та ERC20 між L1 та L2.13 * Цей контракт діє як мінтер для нових токенів, коли він отримує інформацію про депозити в стандартний міст14 * L1.15 * Цей контракт також діє як спалювач токенів, призначених для виведення, інформуючи міст L116 * про необхідність вивільнення коштів L1.17 */18contract L2StandardBridge is IL2ERC20Bridge, CrossDomainEnabled {19 /********************************20 * Посилання на зовнішні контракти *21 ********************************/2223 address public l1TokenBridge;Показати всеВідстежуйте адресу мосту L1. Зверніть увагу, що на відміну від еквівалента L1, тут нам потрібна ця змінна. Адреса мосту L1 заздалегідь невідома.
12 /***************3 * Конструктор *4 ***************/56 /**7 * @param _l2CrossDomainMessenger Міждоменний месенджер, що використовується цим контрактом.8 * @param _l1TokenBridge Адреса мосту L1, розгорнутого в основній мережі.9 */10 constructor(address _l2CrossDomainMessenger, address _l1TokenBridge)11 CrossDomainEnabled(_l2CrossDomainMessenger)12 {13 l1TokenBridge = _l1TokenBridge;14 }1516 /***************17 * Виведення *18 ***************/1920 /**21 * @inheritdoc IL2ERC20Bridge22 */23 function withdraw(24 address _l2Token,25 uint256 _amount,26 uint32 _l1Gas,27 bytes calldata _data28 ) external virtual {29 _initiateWithdrawal(_l2Token, msg.sender, msg.sender, _amount, _l1Gas, _data);30 }3132 /**33 * @inheritdoc IL2ERC20Bridge34 */35 function withdrawTo(36 address _l2Token,37 address _to,38 uint256 _amount,39 uint32 _l1Gas,40 bytes calldata _data41 ) external virtual {42 _initiateWithdrawal(_l2Token, msg.sender, _to, _amount, _l1Gas, _data);43 }Показати всеЦі дві функції ініціюють виведення коштів. Зверніть увагу, що немає необхідності вказувати адресу токена L1. Очікується, що токени L2 повідомлять нам адресу еквівалента L1.
12 /**3 * @dev Виконує логіку для виведення, спалюючи токен та інформуючи4 * шлюз токенів L1 про виведення.5 * @param _l2Token Адреса токена L2, де ініційовано виведення.6 * @param _from Обліковий запис, з якого буде взято виведення на L2.7 * @param _to Обліковий запис, на який буде зараховано виведення на L1.8 * @param _amount Кількість токена для виведення.9 * @param _l1Gas Не використовується, але включено для можливих міркувань щодо майбутньої сумісності.10 * @param _data Необов’язкові дані для пересилання на L1. Ці дані надаються11 * виключно для зручності зовнішніх контрактів. Крім встановлення максимальної12 * довжини, ці контракти не дають жодних гарантій щодо їхнього вмісту.13 */14 function _initiateWithdrawal(15 address _l2Token,16 address _from,17 address _to,18 uint256 _amount,19 uint32 _l1Gas,20 bytes calldata _data21 ) internal {22 // Коли виведення ініціюється, ми спалюємо кошти того, хто виводить, щоб запобігти подальшому використанню на L223 // slither-disable-next-line reentrancy-events24 IL2StandardERC20(_l2Token).burn(msg.sender, _amount);Показати всеЗауважте, що ми покладаємося не на параметр _from, а на msg.sender, який набагато важче підробити (наскільки мені відомо, неможливо).
12 // Сконструювати calldata для l1TokenBridge.finalizeERC20Withdrawal(_to, _amount)3 // slither-disable-next-line reentrancy-events4 address l1Token = IL2StandardERC20(_l2Token).l1Token();5 bytes memory message;67 if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {На L1 необхідно розрізняти ETH і ERC-20.
1 message = abi.encodeWithSelector(2 IL1StandardBridge.finalizeETHWithdrawal.selector,3 _from,4 _to,5 _amount,6 _data7 );8 } else {9 message = abi.encodeWithSelector(10 IL1ERC20Bridge.finalizeERC20Withdrawal.selector,11 l1Token,12 _l2Token,13 _from,14 _to,15 _amount,16 _data17 );18 }1920 // Надіслати повідомлення на міст L121 // slither-disable-next-line reentrancy-events22 sendCrossDomainMessage(l1TokenBridge, _l1Gas, message);2324 // slither-disable-next-line reentrancy-events25 emit WithdrawalInitiated(l1Token, _l2Token, msg.sender, _to, _amount, _data);26 }2728 /************************************29 * Міжмережева функція: внесення *30 ************************************/3132 /**33 * @inheritdoc IL2ERC20Bridge34 */35 function finalizeDeposit(36 address _l1Token,37 address _l2Token,38 address _from,39 address _to,40 uint256 _amount,41 bytes calldata _dataПоказати всеЦя функція викликається L1StandardBridge.
1 ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {Переконайтеся, що джерело повідомлення є легітимним.
Це важливо, оскільки ця функція викликає _mint і може бути використана для надання токенів, які не забезпечені токенами, якими володіє міст на L1.
1 // Перевірити, чи є цільовий токен сумісним, і2 // перевірити, чи відповідає внесений токен на L1 представленню токена на L2 тут3 if (4 // slither-disable-next-line reentrancy-events5 ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&6 _l1Token == IL2StandardERC20(_l2Token).l1Token()Перевірки на адекватність:
- Підтримується правильний інтерфейс
- Адреса L1 контракту L2 ERC-20 відповідає джерелу L1 токенів
1 ) {2 // Коли депозит фіналізується, ми зараховуємо на рахунок на L2 таку ж кількість3 // токенів.4 // slither-disable-next-line reentrancy-events5 IL2StandardERC20(_l2Token).mint(_to, _amount);6 // slither-disable-next-line reentrancy-events7 emit DepositFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);Якщо перевірки на адекватність проходять, фіналізуйте депозит:
- Викарбуйте токени
- Випромінюйте відповідну подію
1 } else {2 // Або токен L2, на який вноситься депозит, не погоджується з правильною адресою3 // свого токена L1, або не підтримує правильний інтерфейс.4 // Це має відбуватися лише у випадку зловмисного токена L2, або якщо користувач якимось чином5 // вказав неправильну адресу токена L2 для депозиту.6 // У будь-якому випадку, ми зупиняємо процес тут і створюємо повідомлення про7 // виведення, щоб користувачі могли в деяких випадках повернути свої кошти.8 // Немає способу повністю запобігти зловмисним контрактам токенів, але це обмежує9 // помилки користувачів і пом'якшує деякі форми зловмисної поведінки контрактів.Показати всеЯкщо користувач зробив помилку, яку можна виявити, використавши неправильну адресу токена L2, ми хочемо скасувати депозит і повернути токени на L1. Єдиний спосіб зробити це з L2 — надіслати повідомлення, яке має чекати періоду оскарження помилки, але це набагато краще для користувача, ніж остаточна втрата токенів.
1 bytes memory message = abi.encodeWithSelector(2 IL1ERC20Bridge.finalizeERC20Withdrawal.selector,3 _l1Token,4 _l2Token,5 _to, // тут поміняли _to та _from, щоб повернути депозит відправнику6 _from,7 _amount,8 _data9 );1011 // Надіслати повідомлення на міст L112 // slither-disable-next-line reentrancy-events13 sendCrossDomainMessage(l1TokenBridge, 0, message);14 // slither-disable-next-line reentrancy-events15 emit DepositFailed(_l1Token, _l2Token, _from, _to, _amount, _data);16 }17 }18}Показати всеВисновок
Стандартний міст є найбільш гнучким механізмом для переказу активів. Однак, оскільки він настільки загальний, це не завжди найпростіший механізм для використання. Особливо для виведення коштів, більшість користувачів вважають за краще використовувати сторонні мости (opens in a new tab), які не чекають періоду оскарження і не вимагають доказу Merkle для завершення виведення.
Ці мости зазвичай працюють, маючи активи на L1, які вони надають негайно за невелику плату (часто меншу, ніж вартість газу для стандартного виведення через міст). Коли міст (або люди, які ним керують) очікує брак активів L1, він переказує достатню кількість активів з L2. Оскільки це дуже великі виведення, вартість виведення амортизується на велику суму та становить набагато менший відсоток.
Сподіваюся, ця стаття допомогла вам більше зрозуміти, як працює рівень 2 і як написати чіткий і безпечний код Solidity.
Більше моїх робіт дивіться тут (opens in a new tab).
Останні оновлення сторінки: 22 жовтня 2025 р.