واجهات ABIs القصيرة لتحسين بيانات الاستدعاء
مقدمة
في هذه المقالة، ستتعرف على تجميعات Optimistic، وتكلفة المعاملات عليها، وكيف يتطلب منا هيكل التكلفة المختلف هذا تحسين أشياء مختلفة عما هو موجود على شبكة إيثيريوم الرئيسية. ستتعلم أيضًا كيفية تنفيذ هذا التحسين.
إفصاح كامل
أنا موظف بدوام كامل في أوبتيميزم (opens in a new tab)، لذا فإن الأمثلة في هذه المقالة ستعمل على أوبتيميزم. ومع ذلك، فإن التقنية المشروحة هنا يجب أن تعمل بنفس الكفاءة مع التجميعات الأخرى.
المصطلحات
عند مناقشة التجميعات، يُستخدم مصطلح 'طبقة 1 (L1)' للإشارة إلى الشبكة الرئيسية، وهي شبكة إيثيريوم الإنتاجية. يُستخدم مصطلح 'طبقة 2 (L2)' للإشارة إلى التجميع أو أي نظام آخر يعتمد على طبقة 1 (L1) في الأمان ولكنه يقوم بمعظم معالجته خارج السلسلة.
كيف يمكننا تقليل تكلفة معاملات طبقة 2 (L2) بشكل أكبر؟
يجب أن تحتفظ تجميعات Optimistic بسجل لكل معاملة تاريخية حتى يتمكن أي شخص من مراجعتها والتحقق من أن الحالة الحالية صحيحة. أرخص طريقة لإدخال البيانات إلى شبكة إيثيريوم الرئيسية هي كتابتها كبيانات الاستدعاء. تم اختيار هذا الحل من قبل كل من أوبتيميزم (opens in a new tab) وأربيتروم (opens in a new tab).
تكلفة معاملات طبقة 2 (L2)
تتكون تكلفة معاملات طبقة 2 (L2) من مكونين:
- معالجة طبقة 2 (L2)، والتي عادة ما تكون رخيصة للغاية
- تخزين طبقة 1 (L1)، والذي يرتبط بتكاليف غاز الشبكة الرئيسية
أثناء كتابتي لهذا، تبلغ تكلفة غاز طبقة 2 (L2) على أوبتيميزم 0.001 Gwei. من ناحية أخرى، تبلغ تكلفة غاز طبقة 1 (L1) حوالي 40 gwei. يمكنك رؤية الأسعار الحالية هنا (opens in a new tab).
تبلغ تكلفة البايت الواحد من بيانات الاستدعاء إما 4 غاز (إذا كان صفرًا) أو 16 غاز (إذا كان أي قيمة أخرى). تعد الكتابة في التخزين واحدة من أغلى العمليات على جهاز إيثيريوم الظاهري (EVM). الحد الأقصى لتكلفة كتابة كلمة بحجم 32-byte في التخزين على طبقة 2 (L2) هو 22,100 غاز. حاليًا، هذا يعادل 22.1 gwei. لذا، إذا تمكنا من توفير بايت صفري واحد من بيانات الاستدعاء، فسنتمكن من كتابة حوالي 200 بايت في التخزين مع الاستمرار في تحقيق مكاسب.
واجهة برمجة التطبيقات (ABI)
تصل الغالبية العظمى من المعاملات إلى عقد من حساب مملوك خارجيًا. تُكتب معظم العقود بلغة Solidity وتفسر حقل بياناتها وفقًا لـ واجهة التطبيق الثنائية (ABI) (opens in a new tab).
ومع ذلك، تم تصميم واجهة ABI لطبقة 1 (L1)، حيث تكلف بايت واحد من بيانات الاستدعاء تقريبًا نفس تكلفة أربع عمليات حسابية، وليس لطبقة 2 (L2) حيث تكلف بايت واحد من بيانات الاستدعاء أكثر من ألف عملية حسابية. تُقسم بيانات الاستدعاء على النحو التالي:
| القسم | الطول | البايتات | البايتات المهدرة | الغاز المهدر | البايتات الضرورية | الغاز الضروري |
|---|---|---|---|---|---|---|
| محدد الدالة | 4 | 0-3 | 3 | 48 | 1 | 16 |
| الأصفار | 12 | 4-15 | 12 | 48 | 0 | 0 |
| عنوان الوجهة | 20 | 16-35 | 0 | 0 | 20 | 320 |
| المبلغ | 32 | 36-67 | 17 | 64 | 15 | 240 |
| الإجمالي | 68 | 160 | 576 |
الشرح:
- محدد الدالة: يحتوي العقد على أقل من 256 دالة، لذا يمكننا التمييز بينها باستخدام بايت واحد. عادةً ما تكون هذه البايتات غير صفرية وبالتالي تكلف ستة عشر غازًا (opens in a new tab).
- الأصفار: هذه البايتات دائمًا ما تكون صفرًا لأن العنوان المكون من عشرين بايت لا يتطلب كلمة مكونة من اثنين وثلاثين بايت لاحتوائه.
البايتات التي تحتوي على صفر تكلف أربعة غاز (انظر الورقة الصفراء (opens in a new tab)، الملحق G، ص 27، قيمة
Gtxdatazero). - المبلغ: إذا افترضنا أن
decimalsفي هذا العقد هو ثمانية عشر (القيمة العادية) وأن الحد الأقصى لعدد الرموز المميزة التي نقوم بتحويلها سيكون 1018، فسنحصل على حد أقصى قدره 1036. 25615 > 1036، لذا فإن خمسة عشر بايتًا كافية.
عادةً ما يكون إهدار 160 غاز على طبقة 1 (L1) ضئيلًا. تكلف المعاملة ما لا يقل عن 21,000 غاز (opens in a new tab)، لذا فإن زيادة بنسبة 0.8% لا تهم.
ومع ذلك، تختلف الأمور على طبقة 2 (L2). حيث أن التكلفة الإجمالية للمعاملة تقريبًا تكمن في كتابتها على طبقة 1 (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 موجودًا فقط لتسهيل الاختبار.
/**
* @dev يمنح المستدعي 1000 رمز مميز للعب بها
*/
function faucet() external {
_mint(msg.sender, 1000);
} // function faucet
CalldataInterpreter.sol
هذا هو العقد الذي يُفترض أن تستدعيه المعاملات ببيانات استدعاء أقصر (opens in a new tab). دعونا نراجعه سطرًا بسطر.
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import { OrisUselessToken } from "./Token.sol";
نحتاج إلى دالة الرمز المميز لمعرفة كيفية استدعائها.
contract CalldataInterpreter {
OrisUselessToken public immutable token;
عنوان الرمز المميز الذي نعمل كوكيل له.
/**
* @dev تحديد عنوان الرمز المميز
* @param tokenAddr_ عنوان عقد ERC-20
*/
constructor(
address tokenAddr_
) {
token = OrisUselessToken(tokenAddr_);
} // constructor
عنوان الرمز المميز هو المعلمة الوحيدة التي نحتاج إلى تحديدها.
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 (opens in a new tab)، وهي لغة التجميع الخاصة بجهاز إيثيريوم الظاهري (EVM).
هنا نستخدم رمز التشغيل CALLDATALOAD (opens in a new tab) لقراءة البايتات من startByte إلى startByte+31 في المكدس.
بشكل عام، تكون صيغة رمز التشغيل في Yul هي <opcode name>(<first stack value, if any>,<second stack value, if any>...).
_retVal = _retVal >> (256-length*8);
فقط البايتات length الأكثر أهمية هي جزء من الحقل، لذا نقوم بـ الإزاحة لليمين (opens in a new tab) للتخلص من القيم الأخرى.
يتميز هذا بميزة إضافية تتمثل في نقل القيمة إلى يمين الحقل، بحيث تصبح القيمة نفسها بدلاً من القيمة مضروبة في 256شيء ما.
return _retVal;
}
fallback() external {
عندما لا يتطابق استدعاء لعقد Solidity مع أي من تواقيع الدوال، فإنه يستدعي دالة fallback() (opens in a new tab) (بافتراض وجود واحدة).
في حالة CalldataInterpreter، يصل أي استدعاء إلى هنا لأنه لا توجد دوال external أو public أخرى.
uint _func;
_func = calldataVal(0, 1);
قراءة البايت الأول من بيانات الاستدعاء، والذي يخبرنا بالدالة. هناك سببان لعدم توفر دالة هنا:
- الدوال التي تكون
pureأوviewلا تغير الحالة ولا تكلف غازًا (عند استدعائها خارج السلسلة). ليس من المنطقي محاولة تقليل تكلفة الغاز الخاصة بها. - الدوال التي تعتمد على
msg.sender(opens in a new tab). ستكون قيمةmsg.senderهي عنوانCalldataInterpreter، وليس المستدعي.
لسوء الحظ، بالنظر إلى مواصفات ERC-20 (opens in a new tab)، يترك هذا دالة واحدة فقط، وهي 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 بايت من بيانات الاستدعاء:
| القسم | الطول | البايتات |
|---|---|---|
| محدد الدالة | 1 | 0 |
| عنوان الوجهة | 32 | 1-32 |
| المبلغ | 2 | 33-34 |
} // fallback
} // contract CalldataInterpreter
test.js
يوضح لنا اختبار الوحدة هذا بلغة JavaScript (opens in a new tab) كيفية استخدام هذه الآلية (وكيفية التحقق من أنها تعمل بشكل صحيح). سأفترض أنك تفهم chai (opens in a new tab) و ethers (opens in a new tab) وسأشرح فقط الأجزاء التي تنطبق تحديدًا على العقد.
const { expect } = require("chai");
describe("CalldataInterpreter", function () {
it("Should let us use tokens", async function () {
const Token = await ethers.getContractFactory("OrisUselessToken")
const token = await Token.deploy()
await token.deployed()
console.log("Token addr:", token.address)
const Cdi = await ethers.getContractFactory("CalldataInterpreter")
const cdi = await Cdi.deploy(token.address)
await cdi.deployed()
console.log("CalldataInterpreter addr:", cdi.address)
const signer = await ethers.getSigner()
نبدأ بنشر كلا العقدين.
// الحصول على رموز مميزة للعب بها
const faucetTx = {
لا يمكننا استخدام الدوال عالية المستوى التي نستخدمها عادةً (مثل token.faucet()) لإنشاء المعاملات، لأننا لا نتبع واجهة ABI.
بدلاً من ذلك، يتعين علينا بناء المعاملة بأنفسنا ثم إرسالها.
to: cdi.address,
data: "0x01"
هناك معلمتان نحتاج إلى توفيرهما للمعاملة:
to، عنوان الوجهة. هذا هو عقد مفسر بيانات الاستدعاء.data، بيانات الاستدعاء المراد إرسالها. في حالة استدعاء الصنبور، تكون البيانات عبارة عن بايت واحد،0x01.
}
await (await signer.sendTransaction(faucetTx)).wait()
نستدعي طريقة sendTransaction الخاصة بالمُوقّع (opens in a new tab) لأننا حددنا الوجهة بالفعل (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 بالنظام العشري).
await (await signer.sendTransaction(transferTx)).wait()
// التحقق من أن لدينا 256 رمزاً مميزاً أقل
expect (await token.balanceOf(signer.address)).to.equal(1000-256)
// وأن وجهتنا قد حصلت عليها
expect (await token.balanceOf(destAddr)).to.equal(256)
}) // it
}) // describe
تقليل التكلفة عندما تتحكم في عقد الوجهة
إذا كنت تتحكم في عقد الوجهة، يمكنك إنشاء دوال تتجاوز فحوصات msg.sender لأنها تثق في مفسر بيانات الاستدعاء.
يمكنك رؤية مثال على كيفية عمل ذلك هنا، في فرع control-contract (opens in a new tab).
إذا كان العقد يستجيب فقط للمعاملات الخارجية، فيمكننا الاكتفاء بوجود عقد واحد فقط. ومع ذلك، فإن ذلك من شأنه أن يكسر قابلية التركيب. من الأفضل بكثير أن يكون لديك عقد يستجيب لاستدعاءات 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) هنا لأن هذا هو العنوان الوحيد المسموح له بتعيين الوكيل.
/**
* @dev تعيين العنوان للوكيل (CalldataInterpreter).
* يمكن استدعاؤه مرة واحدة فقط من قبل المالك
*/
function setProxy(address _proxy) external {
require(msg.sender == owner, "Can only be called by owner");
require(proxy == address(0), "Proxy is already set");
proxy = _proxy;
} // function setProxy
يتمتع الوكيل بوصول متميز، لأنه يمكنه تجاوز الفحوصات الأمنية.
للتأكد من أنه يمكننا الوثوق بالوكيل، نسمح فقط لـ owner باستدعاء هذه الدالة، ولمرة واحدة فقط.
بمجرد أن يكون لـ proxy قيمة حقيقية (ليست صفرًا)، لا يمكن تغيير هذه القيمة، لذلك حتى لو قرر المالك أن يصبح مارقًا، أو تم الكشف عن العبارة التذكيرية الخاصة به، فإننا لا نزال في أمان.
/**
* @dev بعض الدوال يمكن استدعاؤها فقط من قبل الوكيل.
*/
modifier onlyProxy {
هذه دالة modifier (opens in a new tab)، وهي تعدل طريقة عمل الدوال الأخرى.
require(msg.sender == proxy);
أولاً، تحقق من أننا تلقينا استدعاءً من الوكيل وليس من أي شخص آخر.
إذا لم يكن الأمر كذلك، revert.
_;
}
إذا كان الأمر كذلك، فقم بتشغيل الدالة التي نقوم بتعديلها.
/* الدوال التي تسمح للوكيل بالعمل كوكيل فعلي للحسابات */
function transferProxy(address from, address to, uint256 amount)
public virtual onlyProxy() returns (bool)
{
_transfer(from, to, amount);
return true;
}
function approveProxy(address from, address spender, uint256 amount)
public virtual onlyProxy() returns (bool)
{
_approve(from, spender, amount);
return true;
}
function transferFromProxy(
address spender,
address from,
address to,
uint256 amount
) public virtual onlyProxy() returns (bool)
{
_spendAllowance(from, spender, amount);
_transfer(from, to, amount);
return true;
}
هذه ثلاث عمليات تتطلب عادةً أن تأتي الرسالة مباشرة من الكيان الذي يقوم بتحويل الرموز المميزة أو الموافقة على سماحية. هنا لدينا نسخة وكيل من هذه العمليات والتي:
- تم تعديلها بواسطة
onlyProxy()بحيث لا يُسمح لأي شخص آخر بالتحكم فيها. - تحصل على العنوان الذي سيكون عادةً
msg.senderكمعلمة إضافية.
CalldataInterpreter.sol
مفسر بيانات الاستدعاء مطابق تقريبًا للمفسر المذكور أعلاه، باستثناء أن الدوال الموكلة تتلقى معلمة msg.sender وليست هناك حاجة لسماحية لـ transfer.
// تحويل (لا حاجة إلى سماحية)
if (_func == 2) {
token.transferProxy(
msg.sender,
address(uint160(calldataVal(1, 20))),
calldataVal(21, 2)
);
}
// approve
if (_func == 3) {
token.approveProxy(
msg.sender,
address(uint160(calldataVal(1, 20))),
calldataVal(21, 2)
);
}
// transferFrom
if (_func == 4) {
token.transferFromProxy(
msg.sender,
address(uint160(calldataVal( 1, 20))),
address(uint160(calldataVal(21, 20))),
calldataVal(41, 2)
);
}
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)، فإننا لا نحتاج إلى سماحية لترحيل التحويلات.
// الموافقة و transferFrom
const approveTx = {
to: cdi.address,
data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",
}
await (await signer.sendTransaction(approveTx)).wait()
const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"
const transferFromTx = {
to: cdi.address,
data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",
}
await (await poorSigner.sendTransaction(transferFromTx)).wait()
// التحقق من أن مجموعة approve / transferFrom قد تمت بشكل صحيح
expect(await token.balanceOf(destAddr2)).to.equal(255)
اختبار الدالتين الجديدتين.
لاحظ أن transferFromTx يتطلب معلمتي عنوان: مانح السماحية والمستلم.
الخاتمة
يبحث كل من أوبتيميزم (opens in a new tab) وأربيتروم (opens in a new tab) عن طرق لتقليل حجم بيانات الاستدعاء المكتوبة على طبقة 1 (L1) وبالتالي تكلفة المعاملات. ومع ذلك، بصفتنا مزودي بنية تحتية نبحث عن حلول عامة، فإن قدراتنا محدودة. بصفتك مطور تطبيق لامركزي (dapp)، لديك معرفة خاصة بالتطبيق، مما يتيح لك تحسين بيانات الاستدعاء الخاصة بك بشكل أفضل بكثير مما يمكننا القيام به في حل عام. نأمل أن تساعدك هذه المقالة في العثور على الحل المثالي لاحتياجاتك.