تخطي إلى المحتوى الرئيسي

شرح تفصيلي لعقد جسر أوبتيميزم القياسي

Solidity
جسر
الطبقة الثانية
المستوى المتوسط
أوري بوميرانتز
30 مارس 2022
29 دقيقة قراءة

أوبتيميزم (opens in a new tab) هو رول أب تفاؤلي. يمكن لعمليات الرول أب التفاؤلية معالجة المعاملات بسعر أقل بكثير من شبكة إيثريوم الرئيسية (المعروفة أيضًا باسم الطبقة 1 أو L1) لأن المعاملات تتم معالجتها فقط بواسطة عدد قليل من العُقد، بدلاً من كل عقدة على الشبكة. في الوقت نفسه، تُكتب جميع البيانات على L1 حتى يمكن إثبات كل شيء وإعادة بنائه مع جميع ضمانات السلامة والتوافر الخاصة بالشبكة الرئيسية.

لاستخدام أصول L1 على أوبتيميزم (أو أي L2 آخر)، يجب نقل الأصول عبر جسر. إحدى طرق تحقيق ذلك هي أن يقوم المستخدمون بقفل الأصول (عملات ETH ورموز ERC-20 هي الأكثر شيوعًا) على L1، والحصول على أصول معادلة لاستخدامها على L2. في النهاية، قد يرغب من ينتهي به المطاف بهذه الأصول في إعادتها عبر الجسر إلى L1. عند القيام بذلك، يتم حرق الأصول على L2 ثم إعادتها إلى المستخدم على L1.

هذه هي الطريقة التي يعمل بها جسر أوبتيميزم القياسي (opens in a new tab). في هذه المقالة، نستعرض الكود المصدري لهذا الجسر لنرى كيف يعمل وندرسه كمثال لكود سوليديتي مكتوب جيدًا.

تدفقات التحكم

للجسر تدفقان رئيسيان:

  • الإيداع (من L1 إلى L2)
  • السحب (من L2 إلى L1)

تدفق الإيداع

الطبقة 1

  1. في حالة إيداع رمز ERC-20، يمنح المودِع الجسر إذنًا لإنفاق المبلغ الذي يتم إيداعه
  2. يستدعي المودِع جسر L1 (depositERC20 أو depositERC20To أو depositETH أو depositETHTo)
  3. يستحوذ جسر L1 على الأصل المنقول عبر الجسر
    • ETH: يتم تحويل الأصل من قِبل المودِع كجزء من الاستدعاء
    • ERC-20: يتم تحويل الأصل بواسطة الجسر إلى نفسه باستخدام الإذن المقدم من المودِع
  4. يستخدم جسر L1 آلية الرسائل عبر النطاقات لاستدعاء finalizeDeposit على جسر L2

الطبقة 2

  1. يتحقق جسر L2 من أن استدعاء finalizeDeposit شرعي:
    • أنه أتى من عقد الرسائل عبر النطاقات
    • كان في الأصل من الجسر على L1
  2. يتحقق جسر L2 مما إذا كان عقد رمز ERC-20 على L2 هو الصحيح:
  3. إذا كان عقد L2 هو الصحيح، يتم استدعاؤه لسك العدد المناسب من الرموز إلى العنوان المناسب. إذا لم يكن كذلك، تبدأ عملية سحب للسماح للمستخدم بالمطالبة بالرموز على L1.

تدفق السحب

الطبقة 2

  1. يستدعي الساحب جسر L2 (withdraw أو withdrawTo)
  2. يحرق جسر L2 العدد المناسب من الرموز التابعة لـ msg.sender
  3. يستخدم جسر L2 آلية الرسائل عبر النطاقات لاستدعاء finalizeETHWithdrawal أو finalizeERC20Withdrawal على جسر L1

الطبقة 1

  1. يتحقق جسر L1 من أن استدعاء finalizeETHWithdrawal أو finalizeERC20Withdrawal شرعي:
    • أنه أتى من آلية الرسائل عبر النطاقات
    • كان في الأصل من الجسر على L2
  2. ينقل جسر L1 الأصل المناسب (ETH أو ERC-20) إلى العنوان المناسب

كود الطبقة 1

هذا هو الكود الذي يعمل على L1، شبكة إيثريوم الرئيسية.

IL1ERC20Bridge

هذه الواجهة معرّفة هنا (opens in a new tab). تتضمن وظائف وتعاريف مطلوبة لنقل رموز ERC-20 عبر الجسر.

// SPDX-License-Identifier: MIT

يتم إصدار معظم كود أوبتيميزم بموجب رخصة MIT (opens in a new tab).

pragma solidity >0.5.0 <0.9.0;

في وقت كتابة هذا التقرير، أحدث إصدار من سوليديتي هو 0.8.12. حتى يتم إصدار الإصدار 0.9.0، لا نعرف ما إذا كان هذا الكود متوافقًا معه أم لا.

في مصطلحات جسر أوبتيميزم، يعني الإيداع النقل من L1 إلى L2، ويعني السحب النقل من L2 إلى L1.

        address indexed _l1Token,
        address indexed _l2Token,

في معظم الحالات، لا يكون عنوان ERC-20 على L1 هو نفسه عنوان ERC-20 المكافئ على L2. يمكنك رؤية قائمة عناوين الرموز هنا (opens in a new tab). العنوان ذو chainId 1 موجود على L1 (الشبكة الرئيسية) والعنوان ذو chainId 10 موجود على L2 (أوبتيميزم). قيمتا chainId الأخريان هما لشبكة اختبار Kovan (42) وشبكة اختبار Optimistic Kovan (69).

        address indexed _from,
        address _to,
        uint256 _amount,
        bytes _data
    );

من الممكن إضافة ملاحظات إلى التحويلات، وفي هذه الحالة تضاف إلى الأحداث التي تبلغ عنها.

    event ERC20WithdrawalFinalized(
        address indexed _l1Token,
        address indexed _l2Token,
        address indexed _from,
        address _to,
        uint256 _amount,
        bytes _data
    );

نفس عقد الجسر يعالج التحويلات في كلا الاتجاهين. في حالة جسر L1، يعني هذا بدء الإيداعات وإنهاء عمليات السحب.

هذه الوظيفة ليست ضرورية حقًا، لأنه على L2 هو عقد منشور مسبقًا، لذلك يكون دائمًا على العنوان 0x4200000000000000000000000000000000000010. إنها هنا للتناظر مع جسر L2، لأن عنوان جسر L1 ليس من السهل معرفته.

المعلمة _l2Gas هي كمية غاز L2 المسموح للمعاملة بإنفاقها. حتى حد معين (مرتفع)، يكون هذا مجانيًا (opens in a new tab)، لذلك ما لم يفعل عقد ERC-20 شيئًا غريبًا حقًا عند السك، فلا ينبغي أن تكون هناك مشكلة. تتولى هذه الوظيفة السيناريو الشائع، حيث ينقل مستخدم الأصول عبر جسر إلى نفس العنوان على بلوكشين مختلف.

هذه الوظيفة مطابقة تقريبًا لـ depositERC20، لكنها تتيح لك إرسال ERC-20 إلى عنوان مختلف.

عمليات السحب (والرسائل الأخرى من L2 إلى L1) في أوبتيميزم هي عملية من خطوتين:

  1. معاملة بدء على L2.
  2. معاملة إنهاء أو مطالبة على L1. يجب أن تتم هذه المعاملة بعد انتهاء فترة الطعن في الخطأ (opens in a new tab) لمعاملة L2.

IL1StandardBridge

هذه الواجهة معرّفة هنا (opens in a new tab). يحتوي هذا الملف على تعريفات الأحداث والوظائف لـ ETH. هذه التعريفات مشابهة جدًا لتلك المعرفة في IL1ERC20Bridge أعلاه لـ ERC-20.

تنقسم واجهة الجسر بين ملفين لأن بعض رموز ERC-20 تتطلب معالجة مخصصة ولا يمكن معالجتها بواسطة الجسر القياسي. بهذه الطريقة يمكن للجسر المخصص الذي يعالج مثل هذا الرمز أن يطبق IL1ERC20Bridge ولا يضطر أيضًا إلى نقل ETH عبر الجسر.

هذا الحدث مطابق تقريبًا لإصدار ERC-20 (ERC20DepositInitiated)، باستثناء عدم وجود عناوين رموز L1 و L2. وينطبق الشيء نفسه على الأحداث والوظائف الأخرى.

CrossDomainEnabled

هذا العقد (opens in a new tab) موروث من قبل كلا الجسرين (L1 و L2) لإرسال رسائل إلى الطبقة الأخرى.

// SPDX-License-Identifier: MIT
pragma solidity >0.5.0 <0.9.0;

/* Interface Imports */
import { ICrossDomainMessenger } from "./ICrossDomainMessenger.sol";

تخبر هذه الواجهة (opens in a new tab) العقد كيفية إرسال رسائل إلى الطبقة الأخرى، باستخدام مرسل الرسائل عبر النطاقات. مرسل الرسائل عبر النطاقات هذا هو نظام آخر تمامًا، ويستحق مقالًا خاصًا به، وآمل أن أكتبه في المستقبل.

المعلمة الوحيدة التي يحتاج العقد إلى معرفتها، هي عنوان مرسل الرسائل عبر النطاقات على هذه الطبقة. يتم تعيين هذه المعلمة مرة واحدة، في المُنشئ، ولا تتغير أبدًا.

يمكن الوصول إلى المراسلة عبر النطاقات من قبل أي عقد على البلوكشين حيث يتم تشغيلها (إما شبكة إيثريوم الرئيسية أو أوبتيميزم). لكننا نحتاج إلى أن يثق الجسر على كل جانب فقط برسائل معينة إذا كانت تأتي من الجسر على الجانب الآخر.

        require(
            msg.sender == address(getCrossDomainMessenger()),
            "OVM_XCHAIN: messenger contract unauthenticated"
        );

يمكن الوثوق فقط بالرسائل الواردة من مرسل الرسائل عبر النطاقات المناسب (messenger، كما ترى أدناه).


        require(
            getCrossDomainMessenger().xDomainMessageSender() == _sourceDomainAccount,
            "OVM_XCHAIN: wrong sender of cross-domain message"
        );

الطريقة التي يوفر بها مرسل الرسائل عبر النطاقات العنوان الذي أرسل رسالة بالطبقة الأخرى هي وظيفة .xDomainMessageSender() (opens in a new tab). طالما يتم استدعاؤها في المعاملة التي بدأتها الرسالة، يمكنها توفير هذه المعلومات.

نحتاج إلى التأكد من أن الرسالة التي تلقيناها جاءت من الجسر الآخر.

تعيد هذه الوظيفة مرسل الرسائل عبر النطاقات. نستخدم وظيفة بدلاً من المتغير messenger للسماح للعقود التي ترث من هذا العقد باستخدام خوارزمية لتحديد أي مرسل رسائل عبر النطاقات يجب استخدامه.

أخيرًا، الوظيفة التي ترسل رسالة إلى الطبقة الأخرى.

    ) internal {
        // slither-disable-next-line reentrancy-events, reentrancy-benign

سليذر (opens in a new tab) هو محلل ثابت يقوم أوبتيميزم بتشغيله على كل عقد للبحث عن الثغرات والمشاكل المحتملة الأخرى. في هذه الحالة، يثير السطر التالي ثغرتين أمنيتين:

  1. ثغرات إعادة الدخول (Reentrancy) (opens in a new tab)
  2. إعادة الدخول الحميدة (Benign reentrancy) (opens in a new tab)
        getCrossDomainMessenger().sendMessage(_crossDomainTarget, _message, _gasLimit);
    }
}

في هذه الحالة، لا نقلق بشأن إعادة الدخول، فنحن نعلم أن getCrossDomainMessenger() يعيد عنوانًا جديرًا بالثقة، حتى لو لم يكن لدى سليذر طريقة لمعرفة ذلك.

عقد جسر L1

الكود المصدري لهذا العقد موجود هنا (opens in a new tab).

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

يمكن أن تكون الواجهات جزءًا من عقود أخرى، لذا يجب أن تدعم مجموعة واسعة من إصدارات سوليديتي. لكن الجسر نفسه هو عقدنا، ويمكننا أن نكون صارمين بشأن إصدار سوليديتي الذي يستخدمه.

/* Interface Imports */
import { IL1StandardBridge } from "./IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "./IL1ERC20Bridge.sol";

تم شرح IL1ERC20Bridge و IL1StandardBridge أعلاه.

import { IL2ERC20Bridge } from "../../L2/messaging/IL2ERC20Bridge.sol";

تتيح لنا هذه الواجهة (opens in a new tab) إنشاء رسائل للتحكم في الجسر القياسي على L2.

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

تتيح لنا هذه الواجهة (opens in a new tab) التحكم في عقود ERC-20. يمكنك قراءة المزيد عنها هنا.

/* Library Imports */
import { CrossDomainEnabled } from "../../libraries/bridge/CrossDomainEnabled.sol";

كما هو موضح أعلاه، يتم استخدام هذا العقد للمراسلة بين الطبقات.

import { Lib_PredeployAddresses } from "../../libraries/constants/Lib_PredeployAddresses.sol";

يحتوي Lib_PredeployAddresses (opens in a new tab) على عناوين عقود L2 التي لها دائمًا نفس العنوان. وهذا يشمل الجسر القياسي على L2.

import { Address } from "@openzeppelin/contracts/utils/Address.sol";

أدوات العنوان من أوبن زبلين (opens in a new tab). يُستخدم للتمييز بين عناوين العقود وتلك التي تنتمي إلى حسابات مملوكة خارجيًا (EOA).

لاحظ أن هذا ليس حلاً مثاليًا، لأنه لا توجد طريقة للتمييز بين الاستدعاءات المباشرة والاستدعاءات التي تتم من مُنشئ العقد، ولكن على الأقل يتيح لنا هذا تحديد ومنع بعض أخطاء المستخدم الشائعة.

import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

يدعم معيار ERC-20 (opens in a new tab) طريقتين للإبلاغ عن فشل العقد:

  1. Revert (التراجع)
  2. إرجاع false (خطأ)

إن التعامل مع كلتا الحالتين سيجعل الكود الخاص بنا أكثر تعقيدًا، لذلك بدلاً من ذلك نستخدم SafeERC20 من أوبن زبلين (opens in a new tab)، والذي يضمن أن تؤدي جميع حالات الفشل إلى تراجع (opens in a new tab).

هذا هو السطر الذي نحدد به استخدام غلاف SafeERC20 في كل مرة نستخدم فيها واجهة IERC20.


    /********************************
     * مراجع العقود الخارجية *
     ********************************/

    address public l2TokenBridge;

عنوان L2StandardBridge.


    // يربط رمز L1 برمز L2 برصيد رمز L1 المودع
    mapping(address => mapping(address => uint256)) public deposits;

تخطيط (opens in a new tab) مزدوج مثل هذا هو الطريقة التي تُعرّف بها مصفوفة متناثرة ثنائية الأبعاد (opens in a new tab). يتم تحديد القيم في بنية البيانات هذه على أنها deposit[عنوان رمز L1][عنوان رمز L2]. القيمة الافتراضية هي صفر. يتم كتابة الخلايا التي تم تعيينها إلى قيمة مختلفة فقط في التخزين.


    /***************
     * المُنشئ *
     ***************/

    // يعيش هذا العقد خلف وكيل، لذا لن تُستخدم معلمات المُنشئ.
    constructor() CrossDomainEnabled(address(0)) {}

لكي نكون قادرين على ترقية هذا العقد دون الحاجة إلى نسخ جميع المتغيرات في التخزين. للقيام بذلك نستخدم Proxy (opens in a new tab)، وهو عقد يستخدم delegatecall (opens in a new tab) لنقل الاستدعاءات إلى عقد منفصل يتم تخزين عنوانه بواسطة عقد الوكيل (عند الترقية تخبر الوكيل بتغيير هذا العنوان). عند استخدام delegatecall، يظل التخزين هو تخزين العقد المستدعي، لذا تظل قيم جميع متغيرات حالة العقد غير متأثرة.

أحد تأثيرات هذا النمط هو أن تخزين العقد الذي هو المستدعى لـ delegatecall لا يُستخدم وبالتالي فإن قيم المُنشئ التي يتم تمريرها إليه لا تهم. هذا هو السبب في أنه يمكننا تقديم قيمة غير منطقية لمُنشئ CrossDomainEnabled. وهذا هو أيضًا سبب فصل التهيئة أدناه عن المُنشئ.

يحدد اختبار سليذر (opens in a new tab) الوظائف التي لا يتم استدعاؤها من كود العقد وبالتالي يمكن إعلانها external بدلاً من public. يمكن أن تكون تكلفة الغاز للوظائف external أقل، لأنه يمكن تزويدها بالمعلمات في calldata. يجب أن تكون الوظائف المعلنة public قابلة للوصول من داخل العقد. لا يمكن للعقود تعديل calldata الخاصة بها، لذا يجب أن تكون المعلمات في الذاكرة. عندما يتم استدعاء مثل هذه الوظيفة خارجيًا، من الضروري نسخ calldata إلى الذاكرة، مما يكلف الغاز. في هذه الحالة، يتم استدعاء الوظيفة مرة واحدة فقط، لذا فإن عدم الكفاءة لا يهمنا.

    function initialize(address _l1messenger, address _l2TokenBridge) public {
        require(messenger == address(0), "Contract has already been initialized.");

يجب استدعاء وظيفة initialize مرة واحدة فقط. إذا تغير عنوان مرسل الرسائل عبر النطاقات L1 أو جسر رموز L2، فإننا ننشئ وكيلًا جديدًا وجسرًا جديدًا يستدعيه. من غير المرجح أن يحدث هذا إلا عند ترقية النظام بأكمله، وهو حدث نادر جدًا.

لاحظ أن هذه الوظيفة لا تحتوي على أي آلية تقيد من يمكنه استدعاؤها. هذا يعني أنه من الناحية النظرية يمكن للمهاجم الانتظار حتى ننشر الوكيل والإصدار الأول من الجسر ثم يقوم بتشغيل أمامي (opens in a new tab) للوصول إلى وظيفة initialize قبل المستخدم الشرعي. ولكن هناك طريقتان لمنع هذا:

  1. إذا لم يتم نشر العقود مباشرة من قبل EOA ولكن في معاملة بها عقد آخر يقوم بإنشائها (opens in a new tab) يمكن أن تكون العملية بأكملها ذرية، وتنتهي قبل تنفيذ أي معاملة أخرى.
  2. إذا فشل الاستدعاء الشرعي لـ initialize، فمن الممكن دائمًا تجاهل الوكيل والجسر المنشأين حديثًا وإنشاء وكيل وجسر جديدين.
        messenger = _l1messenger;
        l2TokenBridge = _l2TokenBridge;
    }

هذه هي المعلمتان اللتان يحتاج الجسر إلى معرفتهما.

هذا هو السبب في أننا بحاجة إلى أدوات Address من أوبن زبلين.

هذه الوظيفة موجودة لأغراض الاختبار. لاحظ أنها لا تظهر في تعريفات الواجهة - فهي ليست للاستخدام العادي.

هاتان الوظيفتان هما أغلفة حول _initiateETHDeposit، الوظيفة التي تتعامل مع إيداع ETH الفعلي.

طريقة عمل الرسائل عبر النطاقات هي أنه يتم استدعاء عقد الوجهة مع الرسالة كـ calldata الخاصة به. تقوم عقود سوليديتي دائمًا بتفسير calldata الخاصة بها وفقًا لمواصفات ABI (opens in a new tab). تقوم وظيفة سوليديتي abi.encodeWithSelector (opens in a new tab) بإنشاء تلك calldata.

            IL2ERC20Bridge.finalizeDeposit.selector,
            address(0),
            Lib_PredeployAddresses.OVM_ETH,
            _from,
            _to,
            msg.value,
            _data
        );

الرسالة هنا هي استدعاء وظيفة finalizeDeposit (opens in a new tab) بهذه المعلمات:

ParameterValueالمعنى
_l1Tokenaddress(0)قيمة خاصة لتمثيل ETH (والذي ليس رمز ERC-20) على L1
_l2TokenLib_PredeployAddresses.OVM_ETHعقد L2 الذي يدير ETH على أوبتيميزم، 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (هذا العقد للاستخدام الداخلي لـ أوبتيميزم فقط)
_from_fromالعنوان على L1 الذي يرسل ETH
_to_toالعنوان على L2 الذي يستقبل ETH
المبلغmsg.valueكمية الـ wei المرسلة (والتي تم إرسالها بالفعل إلى الجسر)
_data_dataبيانات إضافية لإرفاقها بالإيداع
        // إرسال calldata إلى L2
        // slither-disable-next-line reentrancy-events
        sendCrossDomainMessage(l2TokenBridge, _l2Gas, message);

إرسال الرسالة من خلال مرسل الرسائل عبر النطاقات.

        // slither-disable-next-line reentrancy-events
        emit ETHDepositInitiated(_from, _to, msg.value, _data);
    }

إصدار حدث لإبلاغ أي تطبيق لامركزي يستمع بهذا التحويل.

هاتان الوظيفتان هما أغلفة حول _initiateERC20Deposit، الوظيفة التي تتعامل مع إيداع ERC-20 الفعلي.

هذه الوظيفة مشابهة لـ _initiateETHDeposit أعلاه، مع بعض الاختلافات المهمة. الاختلاف الأول هو أن هذه الوظيفة تتلقى عناوين الرموز والمبلغ المطلوب تحويله كمعلمات. في حالة ETH، يتضمن الاستدعاء إلى الجسر بالفعل نقل الأصل إلى حساب الجسر (msg.value).

        // عند بدء إيداع على L1، ينقل جسر L1 الأموال إلى نفسه لعمليات
        // السحب المستقبلية. يتحقق safeTransferFrom أيضًا مما إذا كان العقد يحتوي على كود، لذلك سيفشل هذا
        // إذا كان _from حسابًا مملوكًا خارجيًا (EOA) أو address(0).
        // slither-disable-next-line reentrancy-events, reentrancy-benign
        IERC20(_l1Token).safeTransferFrom(_from, address(this), _amount);

تتبع عمليات نقل رموز ERC-20 عملية مختلفة عن ETH:

  1. يمنح المستخدم (_from) إذنًا للجسر لنقل الرموز المناسبة.
  2. يستدعي المستخدم الجسر مع عنوان عقد الرمز، والمبلغ، إلخ.
  3. ينقل الجسر الرموز (إلى نفسه) كجزء من عملية الإيداع.

قد تحدث الخطوة الأولى في معاملة منفصلة عن الخطوتين الأخيرتين. ومع ذلك، فإن التشغيل الأمامي ليس مشكلة لأن الوظيفتين اللتين تستدعيان _initiateERC20Deposit (depositERC20 و depositERC20To) تستدعيان هذه الوظيفة فقط مع msg.sender كمعلمة _from.

أضف المبلغ المودع من الرموز إلى بنية بيانات deposits. قد تكون هناك عناوين متعددة على L2 تتوافق مع نفس رمز L1 ERC-20، لذلك لا يكفي استخدام رصيد الجسر من رمز L1 ERC-20 لتتبع الإيداعات.

يرسل جسر L2 رسالة إلى مرسل الرسائل عبر النطاقات L2 مما يتسبب في استدعاء مرسل الرسائل عبر النطاقات L1 لهذه الوظيفة (بمجرد إرسال المعاملة التي تنهي الرسالة (opens in a new tab) على L1، بالطبع).

    ) external onlyFromCrossDomainAccount(l2TokenBridge) {

تأكد من أن هذه رسالة شرعية، قادمة من مرسل الرسائل عبر النطاقات ومنشؤها جسر رموز L2. تُستخدم هذه الوظيفة لسحب ETH من الجسر، لذا يجب علينا التأكد من أنها لا تُستدعى إلا من قِبل المتصل المصرّح له.

        // slither-disable-next-line reentrancy-events
        (bool success, ) = _to.call{ value: _amount }(new bytes(0));

طريقة تحويل ETH هي استدعاء المستلم بكمية wei في msg.value.

        require(success, "TransferHelper::safeTransferETH: ETH transfer failed");

        // slither-disable-next-line reentrancy-events
        emit ETHWithdrawalFinalized(_from, _to, _amount, _data);

إصدار حدث حول السحب.

هذه الوظيفة مشابهة لـ finalizeETHWithdrawal أعلاه، مع التغييرات اللازمة لرموز ERC-20.

        deposits[_l1Token][_l2Token] = deposits[_l1Token][_l2Token] - _amount;

تحديث بنية بيانات deposits.

كان هناك تطبيق سابق للجسر. عندما انتقلنا من التطبيق إلى هذا التطبيق، كان علينا نقل جميع الأصول. يمكن نقل رموز ERC-20 بسهولة. ومع ذلك، لنقل ETH إلى عقد ما، تحتاج إلى موافقة هذا العقد، وهو ما توفره لنا donateETH.

رموز ERC-20 على L2

لكي يتناسب رمز ERC-20 مع الجسر القياسي، يجب أن يسمح للجسر القياسي، و فقط الجسر القياسي، بسك الرمز. هذا ضروري لأن الجسور تحتاج إلى التأكد من أن عدد الرموز المتداولة على أوبتيميزم يساوي عدد الرموز المقفلة داخل عقد جسر L1. إذا كان هناك عدد كبير جدًا من الرموز على L2، فلن يتمكن بعض المستخدمين من إعادة أصولهم إلى L1 عبر الجسر. بدلاً من جسر موثوق به، سنعيد إنشاء الخدمات المصرفية الاحتياطية الجزئية (opens in a new tab) بشكل أساسي. إذا كان هناك عدد كبير جدًا من الرموز على L1، فستبقى بعض هذه الرموز مقفلة داخل عقد الجسر إلى الأبد لأنه لا توجد طريقة لإطلاقها دون حرق رموز L2.

IL2StandardERC20

يجب على كل رمز ERC-20 على L2 يستخدم الجسر القياسي توفير هذه الواجهة (opens in a new tab)، التي تحتوي على الوظائف والأحداث التي يحتاجها الجسر القياسي.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

لا تتضمن واجهة ERC-20 القياسية (opens in a new tab) وظائف mint و burn. هذه الطرق غير مطلوبة من قبل معيار ERC-20 (opens in a new tab)، والذي يترك آليات إنشاء وتدمير الرموز غير محددة.

import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol";

تُستخدم واجهة ERC-165 (opens in a new tab) لتحديد الوظائف التي يوفرها العقد. يمكنك قراءة المعيار هنا (opens in a new tab).

interface IL2StandardERC20 is IERC20, IERC165 {
    function l1Token() external returns (address);

توفر هذه الوظيفة عنوان رمز L1 الذي يتم نقله عبر الجسر إلى هذا العقد. لاحظ أنه ليس لدينا وظيفة مماثلة في الاتجاه المعاكس. نحتاج إلى أن نكون قادرين على نقل أي رمز L1 عبر الجسر، بغض النظر عما إذا كان دعم L2 مخططًا له عند تنفيذه أم لا.


    function mint(address _to, uint256 _amount) external;

    function burn(address _from, uint256 _amount) external;

    event Mint(address indexed _account, uint256 _amount);
    event Burn(address indexed _account, uint256 _amount);
}

وظائف وأحداث لسك (إنشاء) وحرق (تدمير) الرموز. يجب أن يكون الجسر هو الكيان الوحيد الذي يمكنه تشغيل هذه الوظائف لضمان صحة عدد الرموز (يساوي عدد الرموز المقفلة على L1).

L2StandardERC20

هذا هو تنفيذنا لواجهة IL2StandardERC20 (opens in a new tab). ما لم تكن بحاجة إلى نوع من المنطق المخصص، يجب عليك استخدام هذا.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

عقد ERC-20 من أوبن زبلين (opens in a new tab). لا تؤمن أوبتيميزم بإعادة اختراع العجلة، خاصة عندما تكون العجلة مدققة جيدًا وتحتاج إلى أن تكون جديرة بالثقة بما يكفي لحمل الأصول.

import "./IL2StandardERC20.sol";

contract L2StandardERC20 is IL2StandardERC20, ERC20 {
    address public l1Token;
    address public l2Bridge;

هاتان هما المعلمتان الإضافيتان للتكوين اللتان نطلبهما ولا يطلبهما ERC-20 عادةً.

أولاً، استدعِ مُنشئ العقد الذي نرث منه (ERC20(_name, _symbol)) ثم عيّن متغيراتنا الخاصة.

هذه هي طريقة عمل ERC-165 (opens in a new tab). كل واجهة هي عدد من الوظائف المدعومة، ويتم تحديدها على أنها XOR (opens in a new tab) لـ مُحدِّدات وظائف ABI (opens in a new tab) لتلك الوظائف.

يستخدم جسر L2 ERC-165 كفحص سلامة للتأكد من أن عقد ERC-20 الذي يرسل إليه الأصول هو IL2StandardERC20.

ملاحظة: لا يوجد ما يمنع عقدًا خبيثًا من تقديم إجابات خاطئة لـ supportsInterface، لذا فهذه آلية فحص سلامة، وليست آلية أمان.

يُسمح فقط لجسر L2 بسك وحرق الأصول.

_mint و _burn مُعرَّفتان فعليًا في عقد ERC-20 من أوبن زبلين. هذا العقد لا يكشفهما خارجيًا، لأن شروط سك وحرق الرموز متنوعة مثل عدد طرق استخدام ERC-20.

كود جسر L2

هذا هو الكود الذي يشغل الجسر على أوبتيميزم. مصدر هذا العقد موجود هنا (opens in a new tab).

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

/* Interface Imports */
import { IL1StandardBridge } from "../../L1/messaging/IL1StandardBridge.sol";
import { IL1ERC20Bridge } from "../../L1/messaging/IL1ERC20Bridge.sol";
import { IL2ERC20Bridge } from "./IL2ERC20Bridge.sol";

واجهة IL2ERC20Bridge (opens in a new tab) مشابهة جدًا لمكافئ L1 الذي رأيناه أعلاه. هناك اختلافان كبيران:

  1. على L1، تبدأ الإيداعات وتنهي عمليات السحب. هنا تبدأ عمليات السحب وتنهي الإيداعات.
  2. على L1، من الضروري التمييز بين ETH ورموز ERC-20. على L2، يمكننا استخدام نفس الوظائف لكليهما لأنه داخليًا تتم معالجة أرصدة ETH على أوبتيميزم كرمز ERC-20 بالعنوان 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 (opens in a new tab).

تتبع عنوان جسر L1. لاحظ أنه على عكس مكافئ L1، فإننا نحتاج هنا إلى هذا المتغير. عنوان جسر L1 غير معروف مسبقًا.

هاتان الوظيفتان تبدآن عمليات السحب. لاحظ أنه لا توجد حاجة لتحديد عنوان رمز L1. من المتوقع أن تخبرنا رموز L2 بعنوان مكافئ L1.

لاحظ أننا لا نعتمد على معلمة _from بل على msg.sender وهو أصعب بكثير في التزييف (مستحيل، على حد علمي).


        // إنشاء calldata لـ l1TokenBridge.finalizeERC20Withdrawal(_to, _amount)
        // slither-disable-next-line reentrancy-events
        address l1Token = IL2StandardERC20(_l2Token).l1Token();
        bytes memory message;

        if (_l2Token == Lib_PredeployAddresses.OVM_ETH) {

على L1، من الضروري التمييز بين ETH و ERC-20.

يتم استدعاء هذه الوظيفة بواسطة L1StandardBridge.

    ) external virtual onlyFromCrossDomainAccount(l1TokenBridge) {

تأكد من أن مصدر الرسالة شرعي. هذا مهم لأن هذه الوظيفة تستدعي _mint ويمكن استخدامها لإعطاء رموز غير مغطاة بالرموز التي يمتلكها الجسر على L1.

        // تحقق من أن الرمز الهدف متوافق و
        // تحقق من أن الرمز المودع على L1 يطابق تمثيل الرمز المودع على L2 هنا
        if (
            // slither-disable-next-line reentrancy-events
            ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) &&
            _l1Token == IL2StandardERC20(_l2Token).l1Token()

فحوصات السلامة:

  1. الواجهة الصحيحة مدعومة
  2. عنوان L1 لعقد L2 ERC-20 يطابق مصدر L1 للرموز
        ) {
            // عند إنهاء الإيداع، نقوم بإيداع نفس المبلغ من
            // الرموز في حساب L2.
            // slither-disable-next-line reentrancy-events
            IL2StandardERC20(_l2Token).mint(_to, _amount);
            // slither-disable-next-line reentrancy-events
            emit DepositFinalized(_l1Token, _l2Token, _from, _to, _amount, _data);

إذا نجحت فحوصات السلامة، قم بإنهاء الإيداع:

  1. سك الرموز
  2. إصدار الحدث المناسب

إذا ارتكب المستخدم خطأ يمكن اكتشافه باستخدام عنوان رمز L2 الخاطئ، فنحن نريد إلغاء الإيداع وإعادة الرموز على L1. الطريقة الوحيدة التي يمكننا من خلالها القيام بذلك من L2 هي إرسال رسالة سيتعين عليها الانتظار فترة الطعن في الخطأ، ولكن هذا أفضل بكثير للمستخدم من فقدان الرموز بشكل دائم.

الخلاصة

الجسر القياسي هو الآلية الأكثر مرونة لعمليات نقل الأصول. ومع ذلك، نظرًا لأنه عام جدًا، فإنه ليس دائمًا أسهل آلية للاستخدام. خاصة بالنسبة لعمليات السحب، يفضل معظم المستخدمين استخدام جسور الطرف الثالث (opens in a new tab) التي لا تنتظر فترة التحدي ولا تتطلب إثبات ميركل لإنهاء السحب.

تعمل هذه الجسور عادةً من خلال امتلاك أصول على L1، والتي توفرها على الفور مقابل رسوم رمزية (غالبًا ما تكون أقل من تكلفة الغاز للسحب عبر الجسر القياسي). عندما يتوقع الجسر (أو الأشخاص الذين يديرونه) نقصًا في أصول L1، فإنه ينقل أصولًا كافية من L2. بما أن هذه عمليات سحب كبيرة جدًا، يتم إطفاء تكلفة السحب على مبلغ كبير وتكون نسبة مئوية أصغر بكثير.

نأمل أن تكون هذه المقالة قد ساعدتك على فهم المزيد حول كيفية عمل الطبقة 2، وكيفية كتابة كود سوليديتي واضح وآمن.

انظر هنا لمزيد من أعمالي (opens in a new tab).

آخر تحديث للصفحة: 3 أبريل 2026

هل كان هذا البرنامج التعليمي مفيداً؟