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

واجهات ⁦ABIs⁩ القصيرة لتحسين بيانات الاستدعاء

طبقة 2 (L2)
متوسط
أوري بوميرانتس
1 أبريل 2022
14 دقيقة للقراءة

مقدمة

في هذه المقالة، ستتعرف على تجميعات ⁦Optimistic⁩، وتكلفة المعاملات عليها، وكيف يتطلب منا هيكل التكلفة المختلف هذا تحسين أشياء مختلفة عما هو موجود على شبكة إيثيريوم الرئيسية. ستتعلم أيضًا كيفية تنفيذ هذا التحسين.

إفصاح كامل

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

المصطلحات

عند مناقشة التجميعات، يُستخدم مصطلح 'طبقة 1 (L1)' للإشارة إلى الشبكة الرئيسية، وهي شبكة إيثيريوم الإنتاجية. يُستخدم مصطلح 'طبقة 2 (L2)' للإشارة إلى التجميع أو أي نظام آخر يعتمد على طبقة 1 (L1) في الأمان ولكنه يقوم بمعظم معالجته خارج السلسلة.

كيف يمكننا تقليل تكلفة معاملات طبقة 2 (L2) بشكل أكبر؟

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

تكلفة معاملات طبقة 2 (L2)

تتكون تكلفة معاملات طبقة 2 (L2) من مكونين:

  1. معالجة طبقة 2 (L2)، والتي عادة ما تكون رخيصة للغاية
  2. تخزين طبقة 1 (L1)، والذي يرتبط بتكاليف غاز الشبكة الرئيسية

أثناء كتابتي لهذا، تبلغ تكلفة غاز طبقة 2 (L2) على أوبتيميزم 0.001 Gwei. من ناحية أخرى، تبلغ تكلفة غاز طبقة 1 (L1) حوالي 40 gwei. يمكنك رؤية الأسعار الحالية هنا (يفتح في علامة تبويب جديدة).

تبلغ تكلفة البايت الواحد من بيانات الاستدعاء إما 4 غاز (إذا كان صفرًا) أو 16 غاز (إذا كان أي قيمة أخرى). تعد الكتابة في التخزين واحدة من أغلى العمليات على جهاز إيثيريوم الظاهري (EVM). الحد الأقصى لتكلفة كتابة كلمة بحجم 32-byte في التخزين على طبقة 2 (L2) هو 22,100 غاز. حاليًا، هذا يعادل 22.1 gwei. لذا، إذا تمكنا من توفير بايت صفري واحد من بيانات الاستدعاء، فسنتمكن من كتابة حوالي 200 بايت في التخزين مع الاستمرار في تحقيق مكاسب.

واجهة برمجة التطبيقات (ABI)

تصل الغالبية العظمى من المعاملات إلى عقد من حساب مملوك خارجيًا. تُكتب معظم العقود بلغة Solidity وتفسر حقل بياناتها وفقًا لـ واجهة التطبيق الثنائية (ABI) (يفتح في علامة تبويب جديدة).

ومع ذلك، تم تصميم واجهة ⁦ABI⁩ لطبقة 1 (L1)، حيث تكلف بايت واحد من بيانات الاستدعاء تقريبًا نفس تكلفة أربع عمليات حسابية، وليس لطبقة 2 (L2) حيث تكلف بايت واحد من بيانات الاستدعاء أكثر من ألف عملية حسابية. تُقسم بيانات الاستدعاء على النحو التالي:

القسمالطولالبايتاتالبايتات المهدرةالغاز المهدرالبايتات الضروريةالغاز الضروري
محدد الدالة40-3348116
الأصفار124-15124800
عنوان الوجهة2016-350020320
المبلغ3236-67176415240
الإجمالي68160576

الشرح:

  • محدد الدالة: يحتوي العقد على أقل من 256 دالة، لذا يمكننا التمييز بينها باستخدام بايت واحد. عادةً ما تكون هذه البايتات غير صفرية وبالتالي تكلف ستة عشر غازًا (يفتح في علامة تبويب جديدة).
  • الأصفار: هذه البايتات دائمًا ما تكون صفرًا لأن العنوان المكون من عشرين بايت لا يتطلب كلمة مكونة من اثنين وثلاثين بايت لاحتوائه. البايتات التي تحتوي على صفر تكلف أربعة غاز (انظر الورقة الصفراء (يفتح في علامة تبويب جديدة)، الملحق G، ص 27، قيمة Gtxdatazero).
  • المبلغ: إذا افترضنا أن decimals في هذا العقد هو ثمانية عشر (القيمة العادية) وأن الحد الأقصى لعدد الرموز المميزة التي نقوم بتحويلها سيكون 1018، فسنحصل على حد أقصى قدره 1036. 25615 > 1036، لذا فإن خمسة عشر بايتًا كافية.

عادةً ما يكون إهدار 160 غاز على طبقة 1 (L1) ضئيلًا. تكلف المعاملة ما لا يقل عن 21,000 غاز (يفتح في علامة تبويب جديدة)، لذا فإن زيادة بنسبة 0.8% لا تهم. ومع ذلك، تختلف الأمور على طبقة 2 (L2). حيث أن التكلفة الإجمالية للمعاملة تقريبًا تكمن في كتابتها على طبقة 1 (L1). بالإضافة إلى بيانات الاستدعاء الخاصة بالمعاملة، هناك 109 بايت من ترويسة المعاملة (عنوان الوجهة، التوقيع، إلخ). وبالتالي فإن التكلفة الإجمالية هي 109*16+576+160=2480، ونحن نهدر حوالي 6.5% من ذلك.

تقليل التكاليف عندما لا تتحكم في الوجهة

بافتراض أنك لا تتحكم في عقد الوجهة، لا يزال بإمكانك استخدام حل مشابه لـ هذا الحل (يفتح في علامة تبويب جديدة). دعونا نراجع الملفات ذات الصلة.

Token.sol

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

    /**
     * @dev يمنح المستدعي 1000 رمز مميز للعب بها
     */
    function faucet() external {
        _mint(msg.sender, 1000);
    }   // function faucet

CalldataInterpreter.sol

هذا هو العقد الذي يُفترض أن تستدعيه المعاملات ببيانات استدعاء أقصر (يفتح في علامة تبويب جديدة). دعونا نراجعه سطرًا بسطر.

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;


import { OrisUselessToken } from "./Token.sol";

نحتاج إلى دالة الرمز المميز لمعرفة كيفية استدعائها.

contract CalldataInterpreter {
    OrisUselessToken public immutable token;

عنوان الرمز المميز الذي نعمل كوكيل له.

عنوان الرمز المميز هو المعلمة الوحيدة التي نحتاج إلى تحديدها.

    function calldataVal(uint startByte, uint length)
        private pure returns (uint) {

قراءة قيمة من بيانات الاستدعاء.

        uint _retVal;

        require(length < 0x21,
            "calldataVal length limit is 32 bytes");

        require(length + startByte <= msg.data.length,
            "calldataVal trying to read beyond calldatasize");

سنقوم بتحميل كلمة واحدة بحجم 32-byte (256-bit) إلى الذاكرة وإزالة البايتات التي ليست جزءًا من الحقل الذي نريده. لا تعمل هذه الخوارزمية مع القيم التي يزيد طولها عن 32 بايت، وبالطبع لا يمكننا القراءة بعد نهاية بيانات الاستدعاء. على طبقة 1 (L1)، قد يكون من الضروري تخطي هذه الاختبارات لتوفير الغاز، ولكن على طبقة 2 (L2) يكون الغاز رخيصًا للغاية، مما يتيح لنا إجراء أي فحوصات سلامة يمكننا التفكير فيها.

        assembly {
            _retVal := calldataload(startByte)
        }

كان بإمكاننا نسخ البيانات من الاستدعاء إلى fallback() (انظر أدناه)، ولكن من الأسهل استخدام Yul (يفتح في علامة تبويب جديدة)، وهي لغة التجميع الخاصة بجهاز إيثيريوم الظاهري (EVM).

هنا نستخدم رمز التشغيل CALLDATALOAD (يفتح في علامة تبويب جديدة) لقراءة البايتات من startByte إلى startByte+31 في المكدس. بشكل عام، تكون صيغة رمز التشغيل في Yul هي <opcode name>(<first stack value, if any>,<second stack value, if any>...).


        _retVal = _retVal >> (256-length*8);

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


        return _retVal;
    }


    fallback() external {

عندما لا يتطابق استدعاء لعقد Solidity مع أي من تواقيع الدوال، فإنه يستدعي دالة fallback() (يفتح في علامة تبويب جديدة) (بافتراض وجود واحدة). في حالة CalldataInterpreter، يصل أي استدعاء إلى هنا لأنه لا توجد دوال external أو public أخرى.

        uint _func;

        _func = calldataVal(0, 1);

قراءة البايت الأول من بيانات الاستدعاء، والذي يخبرنا بالدالة. هناك سببان لعدم توفر دالة هنا:

  1. الدوال التي تكون pure أو view لا تغير الحالة ولا تكلف غازًا (عند استدعائها خارج السلسلة). ليس من المنطقي محاولة تقليل تكلفة الغاز الخاصة بها.
  2. الدوال التي تعتمد على msg.sender (يفتح في علامة تبويب جديدة). ستكون قيمة msg.sender هي عنوان CalldataInterpreter، وليس المستدعي.

لسوء الحظ، بالنظر إلى مواصفات ERC-20 (يفتح في علامة تبويب جديدة)، يترك هذا دالة واحدة فقط، وهي transfer. هذا يترك لنا دالتين فقط: transfer (لأنه يمكننا استدعاء transferFrom) و faucet (لأنه يمكننا تحويل الرموز المميزة مرة أخرى إلى من استدعانا).


        // استدعاء دوال تغيير حالة الرمز المميز باستخدام
        // المعلومات من بيانات الاستدعاء

        // faucet
        if (_func == 1) {

استدعاء لـ faucet()، والذي لا يحتوي على معلمات.

            token.faucet();
            token.transfer(msg.sender,
                token.balanceOf(address(this)));
        }

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

        // تحويل (بافتراض أن لدينا سماحية لذلك)
        if (_func == 2) {

يتطلب تحويل الرموز المميزة معلمتين: عنوان الوجهة والمبلغ.

            token.transferFrom(
                msg.sender,

نحن نسمح للمستدعين فقط بتحويل الرموز المميزة التي يمتلكونها

                address(uint160(calldataVal(1, 20))),

يبدأ عنوان الوجهة عند البايت رقم 1 (البايت رقم 0 هو الدالة). كعنوان، يبلغ طوله 20 بايت.

                calldataVal(21, 2)

بالنسبة لهذا العقد تحديدًا، نفترض أن الحد الأقصى لعدد الرموز المميزة التي قد يرغب أي شخص في تحويلها يتسع في بايتين (أقل من 65536).

            );
        }

بشكل عام، يستغرق التحويل 35 بايت من بيانات الاستدعاء:

القسمالطولالبايتات
محدد الدالة10
عنوان الوجهة321-32
المبلغ233-34
    }   // fallback

}       // contract CalldataInterpreter

test.js

يوضح لنا اختبار الوحدة هذا بلغة JavaScript (يفتح في علامة تبويب جديدة) كيفية استخدام هذه الآلية (وكيفية التحقق من أنها تعمل بشكل صحيح). سأفترض أنك تفهم chai (يفتح في علامة تبويب جديدة) و ethers (يفتح في علامة تبويب جديدة) وسأشرح فقط الأجزاء التي تنطبق تحديدًا على العقد.

نبدأ بنشر كلا العقدين.

    // الحصول على رموز مميزة للعب بها
    const faucetTx = {

لا يمكننا استخدام الدوال عالية المستوى التي نستخدمها عادةً (مثل token.faucet()) لإنشاء المعاملات، لأننا لا نتبع واجهة ⁦ABI⁩. بدلاً من ذلك، يتعين علينا بناء المعاملة بأنفسنا ثم إرسالها.

      to: cdi.address,
      data: "0x01"

هناك معلمتان نحتاج إلى توفيرهما للمعاملة:

  1. to، عنوان الوجهة. هذا هو عقد مفسر بيانات الاستدعاء.
  2. data، بيانات الاستدعاء المراد إرسالها. في حالة استدعاء الصنبور، تكون البيانات عبارة عن بايت واحد، 0x01.

    }
    await (await signer.sendTransaction(faucetTx)).wait()

نستدعي طريقة sendTransaction الخاصة بالمُوقّع (يفتح في علامة تبويب جديدة) لأننا حددنا الوجهة بالفعل (faucetTx.to) ونحتاج إلى توقيع المعاملة.

// التحقق من أن faucet يوفر الرموز المميزة بشكل صحيح
expect(await token.balanceOf(signer.address)).to.equal(1000)

هنا نتحقق من الرصيد. ليست هناك حاجة لتوفير الغاز في دوال view، لذا نقوم بتشغيلها بشكل طبيعي.

// إعطاء CDI سماحية (لا يمكن توكيل الموافقات)
const approveTX = await token.approve(cdi.address, 10000)
await approveTX.wait()
expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

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

// تحويل الرموز المميزة
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
  to: cdi.address,
  data: "0x02" + destAddr.slice(2, 42) + "0100",
}

إنشاء معاملة تحويل. البايت الأول هو "0x02"، يليه عنوان الوجهة، وأخيرًا المبلغ (0x0100، وهو 256 بالنظام العشري).

تقليل التكلفة عندما تتحكم في عقد الوجهة

إذا كنت تتحكم في عقد الوجهة، يمكنك إنشاء دوال تتجاوز فحوصات msg.sender لأنها تثق في مفسر بيانات الاستدعاء. يمكنك رؤية مثال على كيفية عمل ذلك هنا، في فرع control-contract (يفتح في علامة تبويب جديدة).

إذا كان العقد يستجيب فقط للمعاملات الخارجية، فيمكننا الاكتفاء بوجود عقد واحد فقط. ومع ذلك، فإن ذلك من شأنه أن يكسر قابلية التركيب. من الأفضل بكثير أن يكون لديك عقد يستجيب لاستدعاءات ERC-20 العادية، وعقد آخر يستجيب للمعاملات ذات بيانات الاستدعاء القصيرة.

Token.sol

في هذا المثال يمكننا تعديل Token.sol. يتيح لنا هذا الحصول على عدد من الدوال التي لا يجوز إلا للوكيل استدعاؤها. إليك الأجزاء الجديدة:

    // العنوان الوحيد المسموح له بتحديد عنوان CalldataInterpreter
    address owner;

    // عنوان CalldataInterpreter
    address proxy = address(0);

يحتاج عقد ERC-20 إلى معرفة هوية الوكيل المعتمد. ومع ذلك، لا يمكننا تعيين هذا المتغير في المُنشئ، لأننا لا نعرف القيمة بعد. يتم إنشاء مثيل لهذا العقد أولاً لأن الوكيل يتوقع عنوان الرمز المميز في المُنشئ الخاص به.

    /**
     * @dev يستدعي مُنشئ ERC20.
     */
    constructor(
    ) ERC20("Oris useless token-2", "OUT-2") {
        owner = msg.sender;
    }

يتم تخزين عنوان المنشئ (المسمى owner) هنا لأن هذا هو العنوان الوحيد المسموح له بتعيين الوكيل.

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

    /**
     * @dev بعض الدوال يمكن استدعاؤها فقط من قبل الوكيل.
     */
    modifier onlyProxy {

هذه دالة modifier (يفتح في علامة تبويب جديدة)، وهي تعدل طريقة عمل الدوال الأخرى.

      require(msg.sender == proxy);

أولاً، تحقق من أننا تلقينا استدعاءً من الوكيل وليس من أي شخص آخر. إذا لم يكن الأمر كذلك، revert.

      _;
    }

إذا كان الأمر كذلك، فقم بتشغيل الدالة التي نقوم بتعديلها.

هذه ثلاث عمليات تتطلب عادةً أن تأتي الرسالة مباشرة من الكيان الذي يقوم بتحويل الرموز المميزة أو الموافقة على سماحية. هنا لدينا نسخة وكيل من هذه العمليات والتي:

  1. تم تعديلها بواسطة onlyProxy() بحيث لا يُسمح لأي شخص آخر بالتحكم فيها.
  2. تحصل على العنوان الذي سيكون عادةً msg.sender كمعلمة إضافية.

CalldataInterpreter.sol

مفسر بيانات الاستدعاء مطابق تقريبًا للمفسر المذكور أعلاه، باستثناء أن الدوال الموكلة تتلقى معلمة msg.sender وليست هناك حاجة لسماحية لـ transfer.

Test.js

هناك بعض التغييرات بين كود الاختبار السابق وهذا الكود.

const Cdi = await ethers.getContractFactory("CalldataInterpreter")
const cdi = await Cdi.deploy(token.address)
await cdi.deployed()
await token.setProxy(cdi.address)

نحتاج إلى إخبار عقد ERC-20 بالوكيل الذي يجب الوثوق به

console.log("CalldataInterpreter addr:", cdi.address)

// نحتاج إلى موقعين للتحقق من السماحيات
const signers = await ethers.getSigners()
const signer = signers[0]
const poorSigner = signers[1]

للتحقق من approve() و transferFrom() نحتاج إلى مُوقّع ثانٍ. نسميه poorSigner لأنه لا يحصل على أي من رموزنا المميزة (يحتاج إلى امتلاك ETH بالطبع).

// تحويل الرموز المميزة
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
  to: cdi.address,
  data: "0x02" + destAddr.slice(2, 42) + "0100",
}
await (await signer.sendTransaction(transferTx)).wait()

نظرًا لأن عقد ERC-20 يثق في الوكيل (cdi)، فإننا لا نحتاج إلى سماحية لترحيل التحويلات.

اختبار الدالتين الجديدتين. لاحظ أن transferFromTx يتطلب معلمتي عنوان: مانح السماحية والمستلم.

الخاتمة

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

انظر هنا للمزيد من أعمالي (يفتح في علامة تبويب جديدة).