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

واجهات تطبيقات ثنائية قصيرة لتحسين بيانات الاستدعاء

الطبقة الثانية
المستوى المتوسط
أوري بوميرانتز
1 أبريل 2022
13 دقيقة قراءة

مقدمة

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

إفصاح كامل

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

المصطلحات

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

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

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

تكلفة معاملات الطبقة الثانية L2

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

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

في وقت كتابة هذا التقرير، تبلغ تكلفة غاز الطبقة الثانية L2 على أوبتيميزم 0.001 Gwei. من ناحية أخرى، تبلغ تكلفة غاز الطبقة الأولى L1 حوالي 40 غوي. يمكنك رؤية الأسعار الحالية هنا (opens in a new tab).

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

واجهة التطبيق الثنائية (ABI)

تتم الغالبية العظمى من المعاملات من خلال حساب مملوك خارجيًا. تتم كتابة معظم العقود بلغة سوليديتي وتفسر حقل بياناتها وفقًا لـ واجهة التطبيق الثنائية (ABI) (opens in a new tab).

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

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

توضيح:

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

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

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

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

Token.sol

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

1 /**
2 * @dev يمنح المتصل 1000 رمز للتعامل بها
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet

CalldataInterpreter.sol

هذا هو العقد الذي من المفترض أن تستدعيه المعاملات باستخدام بيانات استدعاء أقصر (opens in a new tab). دعنا نراجعه سطراً بسطر.

1//SPDX-License-Identifier: Unlicense
2pragma solidity ^0.8.0;
3
4import { OrisUselessToken } from "./Token.sol";

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

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;

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

1 /**
2 * @dev تحديد عنوان الرمز
3 * @param tokenAddr_ عنوان عقد ERC-20
4 */
5 constructor(
6 address tokenAddr_
7 ) {
8 token = OrisUselessToken(tokenAddr_);
9 } // constructor

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

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

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

1 require(length < 0x21,
2 "حد طول calldataVal هو 32 بايت");
3
4 require(length + startByte <= msg.data.length,
5 "تحاول calldataVal القراءة بعد حجم بيانات الاستدعاء");

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

1 assembly {
2 _retVal := calldataload(startByte)
3 }

كان بإمكاننا نسخ البيانات من الاستدعاء إلى fallback() (انظر أدناه)، ولكن من الأسهل استخدام يول (opens in a new tab)، لغة التجميع الخاصة بآلة إيثريوم الافتراضية (EVM).

هنا نستخدم رمز التشغيل CALLDATALOAD (opens in a new tab) لقراءة البايتات من startByte إلى startByte+31 في المكدس. بشكل عام، صيغة رمز التشغيل في يول هي <اسم رمز التشغيل>(<قيمة المكدس الأولى، إن وجدت>، <قيمة المكدس الثانية، إن وجدت>...).

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

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

1 return _retVal;
2 }
3
4 fallback() external {

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

1 uint _func;
2
3 _func = calldataVal(0, 1);

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

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

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

1 // Call the state changing methods of token using
2 // information from the calldata
3
4 // faucet
5 if (_func == 1) {

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

1 token.faucet();
2 token.transfer(msg.sender,
3 token.balanceOf(address(this)));
4 }

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

1 // transfer (assume we have an allowance for it)
2 if (_func == 2) {

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

1 token.transferFrom(
2 msg.sender,

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

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

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

1 calldataVal(21, 2)

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

1 );
2 }

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

القسمالطولبايت
محدد الوظيفة١0
عنوان الوجهة321-32
المبلغ233-34
1 } // fallback
2
3} // contract CalldataInterpreter

test.js

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

1const { expect } = require("chai");
2
3describe("CalldataInterpreter", function () {
4 it("Should let us use tokens", async function () {
5 const Token = await ethers.getContractFactory("OrisUselessToken")
6 const token = await Token.deploy()
7 await token.deployed()
8 console.log("Token addr:", token.address)
9
10 const Cdi = await ethers.getContractFactory("CalldataInterpreter")
11 const cdi = await Cdi.deploy(token.address)
12 await cdi.deployed()
13 console.log("CalldataInterpreter addr:", cdi.address)
14
15 const signer = await ethers.getSigner()

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

1 // Get tokens to play with
2 const faucetTx = {

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

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

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

  1. to، عنوان الوجهة. هذا هو عقد مترجم بيانات الاستدعاء.
  2. data، بيانات الاستدعاء المراد إرسالها. في حالة استدعاء السبيل (faucet)، تكون البيانات بايت واحد، 0x01.
1 }
2 await (await signer.sendTransaction(faucetTx)).wait()

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

1// Check the faucet provides the tokens correctly
2expect(await token.balanceOf(signer.address)).to.equal(1000)

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

1// Give the CDI an allowance (approvals cannot be proxied)
2const approveTX = await token.approve(cdi.address, 10000)
3await approveTX.wait()
4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

امنح مترجم بيانات الاستدعاء بدلًا ليكون قادرًا على إجراء التحويلات.

1// Transfer tokens
2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
3const transferTx = {
4 to: cdi.address,
5 data: "0x02" + destAddr.slice(2, 42) + "0100",
6}

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

1 await (await signer.sendTransaction(transferTx)).wait()
2
3 // Check that we have 256 tokens less
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // And that our destination got them
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe

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

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

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

Token.sol

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

1 // The only address allowed to specify the CalldataInterpreter address
2 address owner;
3
4 // The CalldataInterpreter address
5 address proxy = address(0);

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

1 /**
2 * @dev Calls the ERC20 constructor.
3 */
4 constructor(
5 ) ERC20("Oris useless token-2", "OUT-2") {
6 owner = msg.sender;
7 }

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

1 /**
2 * @dev set the address for the proxy (the CalldataInterpreter).
3 * Can only be called once by the owner
4 */
5 function setProxy(address _proxy) external {
6 require(msg.sender == owner, "Can only be called by owner");
7 require(proxy == address(0), "Proxy is already set");
8
9 proxy = _proxy;
10 } // function setProxy

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

1 /**
2 * @dev Some functions may only be called by the proxy.
3 */
4 modifier onlyProxy {

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

1 require(msg.sender == proxy);

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

1 _;
2 }

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

1 /* Functions that allow the proxy to actually proxy for accounts */
2
3 function transferProxy(address from, address to, uint256 amount)
4 public virtual onlyProxy() returns (bool)
5 {
6 _transfer(from, to, amount);
7 return true;
8 }
9
10 function approveProxy(address from, address spender, uint256 amount)
11 public virtual onlyProxy() returns (bool)
12 {
13 _approve(from, spender, amount);
14 return true;
15 }
16
17 function transferFromProxy(
18 address spender,
19 address from,
20 address to,
21 uint256 amount
22 ) public virtual onlyProxy() returns (bool)
23 {
24 _spendAllowance(from, spender, amount);
25 _transfer(from, to, amount);
26 return true;
27 }

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

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

CalldataInterpreter.sol

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

1 // transfer (no need for allowance)
2 if (_func == 2) {
3 token.transferProxy(
4 msg.sender,
5 address(uint160(calldataVal(1, 20))),
6 calldataVal(21, 2)
7 );
8 }
9
10 // approve
11 if (_func == 3) {
12 token.approveProxy(
13 msg.sender,
14 address(uint160(calldataVal(1, 20))),
15 calldataVal(21, 2)
16 );
17 }
18
19 // transferFrom
20 if (_func == 4) {
21 token.transferFromProxy(
22 msg.sender,
23 address(uint160(calldataVal( 1, 20))),
24 address(uint160(calldataVal(21, 20))),
25 calldataVal(41, 2)
26 );
27 }

Test.js

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

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

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

1console.log("CalldataInterpreter addr:", cdi.address)
2
3// Need two signers to verify allowances
4const signers = await ethers.getSigners()
5const signer = signers[0]
6const poorSigner = signers[1]

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

1// Transfer tokens
2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
3const transferTx = {
4 to: cdi.address,
5 data: "0x02" + destAddr.slice(2, 42) + "0100",
6}
7await (await signer.sendTransaction(transferTx)).wait()

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

1// approval and transferFrom
2const approveTx = {
3 to: cdi.address,
4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",
5}
6await (await signer.sendTransaction(approveTx)).wait()
7
8const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"
9
10const transferFromTx = {
11 to: cdi.address,
12 data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",
13}
14await (await poorSigner.sendTransaction(transferFromTx)).wait()
15
16// Check the approve / transferFrom combo was done correctly
17expect(await token.balanceOf(destAddr2)).to.equal(255)

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

الخلاصة

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

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

آخر تحديث للصفحة: 3 مارس 2026

هل كانت تعليمات الاستخدام هذه مفيدة؟