كل ما يمكنك تخزينه مؤقتًا
عند استخدام التجميعات (rollups)، تكون تكلفة البايت في المعاملة أغلى بكثير من تكلفة خانة التخزين. لذلك، من المنطقي تخزين أكبر قدر ممكن من المعلومات مؤقتًا على السلسلة.
في هذه المقالة، ستتعلم كيفية إنشاء واستخدام عقد تخزين مؤقت بطريقة تضمن تخزين أي قيمة معلمة (parameter) يُحتمل استخدامها عدة مرات مؤقتًا لتكون متاحة للاستخدام (بعد المرة الأولى) بعدد أقل بكثير من البايتات، وكيفية كتابة كود خارج السلسلة يستخدم هذا التخزين المؤقت.
إذا كنت ترغب في تخطي المقالة والاطلاع على الكود المصدري فقط، فهو موجود هنا (opens in a new tab). حزمة التطوير المستخدمة هي Foundry (opens in a new tab).
التصميم العام
من أجل التبسيط، سنفترض أن جميع معلمات المعاملة هي uint256، بطول 32 bytes. عندما نتلقى معاملة، سنقوم بتحليل كل معلمة على النحو التالي:
-
إذا كان البايت الأول هو
0xFF، خذ الـ 32 bytes التالية كقيمة معلمة واكتبها في التخزين المؤقت. -
إذا كان البايت الأول هو
0xFE، خذ الـ 32 bytes التالية كقيمة معلمة ولكن لا تكتبها في التخزين المؤقت. -
لأي قيمة أخرى، خذ البتات الأربعة العليا كعدد البايتات الإضافية، والبتات الأربعة السفلى كالبتات الأكثر أهمية لمفتاح التخزين المؤقت. إليك بعض الأمثلة:
البايتات في بيانات الاستدعاء مفتاح التخزين المؤقت 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
معالجة التخزين المؤقت
يتم تنفيذ التخزين المؤقت في Cache.sol (opens in a new tab). دعونا نراجعه سطرًا بسطر.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Cache {
bytes1 public constant INTO_CACHE = 0xFF;
bytes1 public constant DONT_CACHE = 0xFE;
تُستخدم هذه الثوابت لتفسير الحالات الخاصة التي نقدم فيها جميع المعلومات ونريد إما كتابتها في التخزين المؤقت أو لا. تتطلب الكتابة في التخزين المؤقت عمليتي SSTORE (opens in a new tab) في خانات تخزين غير مستخدمة مسبقًا بتكلفة 22,100 غاز لكل منها، لذلك نجعلها اختيارية.
mapping(uint => uint) public val2key;
تعيين (mapping (opens in a new tab)) بين القيم ومفاتيحها. هذه المعلومات ضرورية لتشفير القيم قبل إرسال المعاملة.
// يحتوي الموقع n على القيمة الخاصة بالمفتاح n+1، لأننا بحاجة إلى الحفاظ على
// الصفر كـ "غير موجود في ذاكرة التخزين المؤقت".
uint[] public key2val;
يمكننا استخدام مصفوفة للتعيين من المفاتيح إلى القيم لأننا نقوم بتعيين المفاتيح، وللتبسيط نقوم بذلك بشكل تسلسلي.
function cacheRead(uint _key) public view returns (uint) {
require(_key <= key2val.length, "Reading uninitialize cache entry");
return key2val[_key-1];
} // cacheRead
قراءة قيمة من التخزين المؤقت.
// كتابة قيمة في ذاكرة التخزين المؤقت إذا لم تكن موجودة بالفعل
// عامة (public) فقط لتمكين الاختبار من العمل
function cacheWrite(uint _value) public returns (uint) {
// إذا كانت القيمة موجودة بالفعل في ذاكرة التخزين المؤقت، أرجع المفتاح الحالي
if (val2key[_value] != 0) {
return val2key[_value];
}
لا جدوى من وضع نفس القيمة في التخزين المؤقت أكثر من مرة. إذا كانت القيمة موجودة بالفعل، فما عليك سوى إرجاع المفتاح الحالي.
// بما أن 0xFE هي حالة خاصة، فإن أكبر مفتاح يمكن لذاكرة التخزين المؤقت
// الاحتفاظ به هو 0x0D متبوعًا بـ 15 من 0xFF. إذا كان طول ذاكرة التخزين المؤقت بهذا الحجم بالفعل،
// فستفشل.
// 1 2 3 4 5 6 7 8 9 A B C D E F
require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
"cache overflow");
لا أعتقد أننا سنحصل أبدًا على تخزين مؤقت بهذا الحجم (حوالي 1.8*1037 إدخال، مما سيتطلب حوالي 1027 TB لتخزينه). ومع ذلك، أنا كبير بما يكفي لأتذكر مقولة "640kB ستكون دائمًا كافية" (opens in a new tab). هذا الاختبار رخيص جدًا.
// كتابة القيمة باستخدام المفتاح التالي
val2key[_value] = key2val.length+1;
إضافة البحث العكسي (من القيمة إلى المفتاح).
key2val.push(_value);
إضافة البحث الأمامي (من المفتاح إلى القيمة). نظرًا لأننا نُعين القيم بشكل تسلسلي، يمكننا ببساطة إضافتها بعد آخر قيمة في المصفوفة.
return key2val.length;
} // cacheWrite
إرجاع الطول الجديد لـ key2val، وهي الخلية التي يتم فيها تخزين القيمة الجديدة.
function _calldataVal(uint startByte, uint length)
private pure returns (uint)
تقرأ هذه الدالة قيمة من بيانات الاستدعاء بطول عشوائي (يصل إلى 32 bytes، وهو حجم الكلمة).
{
uint _retVal;
require(length < 0x21,
"_calldataVal length limit is 32 bytes");
require(length + startByte <= msg.data.length,
"_calldataVal trying to read beyond calldatasize");
هذه الدالة داخلية، لذا إذا تمت كتابة بقية الكود بشكل صحيح، فلن تكون هذه الاختبارات مطلوبة. ومع ذلك، فهي لا تكلف الكثير لذا من الأفضل الاحتفاظ بها.
assembly {
_retVal := calldataload(startByte)
}
هذا الكود مكتوب بلغة Yul (opens in a new tab). يقرأ قيمة بحجم 32 bytes من بيانات الاستدعاء. يعمل هذا حتى إذا توقفت بيانات الاستدعاء قبل startByte+32 لأن المساحة غير المهيأة في جهاز الإيثيريوم الظاهري (EVM) تُعتبر صفرًا.
_retVal = _retVal >> (256-length*8);
لا نريد بالضرورة قيمة بحجم 32 bytes. هذا يتخلص من البايتات الزائدة.
return _retVal;
} // _calldataVal
// قراءة معلمة واحدة من بيانات الاستدعاء، بدءًا من _fromByte
function _readParam(uint _fromByte) internal
returns (uint _nextByte, uint _parameterValue)
{
قراءة معلمة واحدة من بيانات الاستدعاء. لاحظ أننا بحاجة إلى إرجاع ليس فقط القيمة التي قرأناها، ولكن أيضًا موقع البايت التالي لأن المعلمات يمكن أن تتراوح من بايت واحد إلى 33 bytes.
// يخبرنا البايت الأول بكيفية تفسير الباقي
uint8 _firstByte;
_firstByte = uint8(_calldataVal(_fromByte, 1));
تحاول لغة Solidity تقليل عدد الأخطاء البرمجية عن طريق حظر تحويلات الأنواع الضمنية (opens in a new tab) التي قد تكون خطيرة. يجب أن يكون التخفيض، على سبيل المثال من 256 bits إلى 8 bits، صريحًا.
// قراءة القيمة، ولكن لا تكتبها في ذاكرة التخزين المؤقت
if (_firstByte == uint8(DONT_CACHE))
return(_fromByte+33, _calldataVal(_fromByte+1, 32));
// قراءة القيمة، وكتابتها في ذاكرة التخزين المؤقت
if (_firstByte == uint8(INTO_CACHE)) {
uint _param = _calldataVal(_fromByte+1, 32);
cacheWrite(_param);
return(_fromByte+33, _param);
}
// إذا وصلنا إلى هنا، فهذا يعني أننا بحاجة إلى القراءة من ذاكرة التخزين المؤقت
// عدد البايتات الإضافية للقراءة
uint8 _extraBytes = _firstByte / 16;
خذ نصف البايت (nibble) (opens in a new tab) السفلي وادمجه مع البايتات الأخرى لقراءة القيمة من التخزين المؤقت.
uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
_calldataVal(_fromByte+1, _extraBytes);
return (_fromByte+_extraBytes+1, cacheRead(_key));
} // _readParam
// قراءة n من المعلمات (تعرف الدوال عدد المعلمات التي تتوقعها)
function _readParams(uint _paramNum) internal returns (uint[] memory) {
يمكننا الحصول على عدد المعلمات التي لدينا من بيانات الاستدعاء نفسها، لكن الدوال التي تستدعينا تعرف عدد المعلمات التي تتوقعها. من الأسهل السماح لهم بإخبارنا.
// المعلمات التي نقرأها
uint[] memory params = new uint[](_paramNum);
// تبدأ المعلمات عند البايت 4، وقبل ذلك يوجد توقيع الدالة
uint _atByte = 4;
for(uint i=0; i<_paramNum; i++) {
(_atByte, params[i]) = _readParam(_atByte);
}
اقرأ المعلمات حتى تحصل على العدد الذي تحتاجه. إذا تجاوزنا نهاية بيانات الاستدعاء، فإن _readParams سيؤدي إلى تراجع الاستدعاء.
return(params);
} // readParams
// لاختبار _readParams، اختبر قراءة أربع معلمات
function fourParam() public
returns (uint256,uint256,uint256,uint256)
{
uint[] memory params;
params = _readParams(4);
return (params[0], params[1], params[2], params[3]);
} // fourParam
إحدى المزايا الكبيرة لـ Foundry هي أنها تسمح بكتابة الاختبارات بلغة Solidity (انظر اختبار التخزين المؤقت أدناه). هذا يجعل اختبارات الوحدة أسهل بكثير. هذه دالة تقرأ أربع معلمات وترجعها حتى يتمكن الاختبار من التحقق من صحتها.
// الحصول على قيمة، وإرجاع البايتات التي ستقوم بتشفيرها (باستخدام ذاكرة التخزين المؤقت إن أمكن)
function encodeVal(uint _val) public view returns(bytes memory) {
encodeVal هي دالة يستدعيها الكود خارج السلسلة للمساعدة في إنشاء بيانات الاستدعاء التي تستخدم التخزين المؤقت. تتلقى قيمة واحدة وترجع البايتات التي تشفرها. هذه الدالة هي view، لذا فهي لا تتطلب معاملة وعند استدعائها خارجيًا لا تكلف أي غاز.
uint _key = val2key[_val];
// القيمة ليست في ذاكرة التخزين المؤقت بعد، أضفها
if (_key == 0)
return bytes.concat(INTO_CACHE, bytes32(_val));
في جهاز الإيثيريوم الظاهري (EVM)، يُفترض أن جميع مساحات التخزين غير المهيأة هي أصفار. لذلك إذا بحثنا عن مفتاح لقيمة غير موجودة، نحصل على صفر. في هذه الحالة، البايتات التي تشفرها هي INTO_CACHE (لذا سيتم تخزينها مؤقتًا في المرة القادمة)، متبوعة بالقيمة الفعلية.
// إذا كان المفتاح <0x10، فأرجعه كبايت واحد
if (_key < 0x10)
return bytes.concat(bytes1(uint8(_key)));
البايتات المفردة هي الأسهل. نستخدم ببساطة bytes.concat (opens in a new tab) لتحويل نوع bytes<n> إلى مصفوفة بايتات يمكن أن تكون بأي طول. على الرغم من الاسم، فإنها تعمل بشكل جيد عند تزويدها بوسيطة واحدة فقط.
// قيمة من بايتين، مشفرة كـ 0x1vvv
if (_key < 0x1000)
return bytes.concat(bytes2(uint16(_key) | 0x1000));
عندما يكون لدينا مفتاح أقل من 163، يمكننا التعبير عنه في بايتين. نقوم أولاً بتحويل _key، وهي قيمة بحجم 256 bit، إلى قيمة بحجم 16 bit ونستخدم المعامل المنطقي (OR) لإضافة عدد البايتات الإضافية إلى البايت الأول. ثم نضعها ببساطة في قيمة bytes2، والتي يمكن تحويلها إلى bytes.
// ربما توجد طريقة ذكية للقيام بالأسطر التالية كحلقة،
// ولكنها دالة عرض (view) لذا أقوم بالتحسين من أجل وقت المبرمج و
// البساطة.
if (_key < 16*256**2)
return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));
if (_key < 16*256**3)
return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));
.
.
.
if (_key < 16*256**14)
return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));
if (_key < 16*256**15)
return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));
يتم التعامل مع القيم الأخرى (3 bytes، 4 bytes، إلخ) بنفس الطريقة، فقط بأحجام حقول مختلفة.
// إذا وصلنا إلى هنا، فهناك خطأ ما.
revert("Error in encodeVal, should not happen");
إذا وصلنا إلى هنا، فهذا يعني أننا حصلنا على مفتاح ليس أقل من 16*25615. لكن cacheWrite يحد من المفاتيح بحيث لا يمكننا حتى الوصول إلى 14*25616 (والذي سيكون البايت الأول له هو 0xFE، لذا سيبدو مثل DONT_CACHE). لكن لا يكلفنا الكثير إضافة اختبار في حال قام مبرمج مستقبلي بإدخال خطأ برمجي.
} // encodeVal
} // Cache
اختبار التخزين المؤقت
إحدى مزايا Foundry هي أنها تتيح لك كتابة الاختبارات بلغة Solidity (opens in a new tab)، مما يسهل كتابة اختبارات الوحدة. الاختبارات الخاصة بفئة Cache موجودة هنا (opens in a new tab). نظرًا لأن كود الاختبار متكرر، كما تميل الاختبارات أن تكون، تشرح هذه المقالة الأجزاء المثيرة للاهتمام فقط.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
// تحتاج إلى تشغيل `forge test -vv` من أجل وحدة التحكم.
import "forge-std/console.sol";
هذا مجرد كود نموذجي (boilerplate) ضروري لاستخدام حزمة الاختبار و console.log.
import "src/Cache.sol";
نحتاج إلى معرفة العقد الذي نختبره.
contract CacheTest is Test {
Cache cache;
function setUp() public {
cache = new Cache();
}
يتم استدعاء دالة setUp قبل كل اختبار. في هذه الحالة، نقوم ببساطة بإنشاء تخزين مؤقت جديد، حتى لا تؤثر اختباراتنا على بعضها البعض.
function testCaching() public {
الاختبارات هي دوال تبدأ أسماؤها بـ test. تتحقق هذه الدالة من الوظائف الأساسية للتخزين المؤقت، بكتابة القيم وقراءتها مرة أخرى.
for(uint i=1; i<5000; i++) {
cache.cacheWrite(i*i);
}
for(uint i=1; i<5000; i++) {
assertEq(cache.cacheRead(i), i*i);
هكذا تقوم بالاختبار الفعلي، باستخدام دوال assert... (opens in a new tab). في هذه الحالة، نتحقق من أن القيمة التي كتبناها هي التي قرأناها. يمكننا تجاهل نتيجة cache.cacheWrite لأننا نعلم أن مفاتيح التخزين المؤقت يتم تعيينها بشكل خطي.
}
} // testCaching
// تخزين نفس القيمة في ذاكرة التخزين المؤقت عدة مرات، والتأكد من أن المفتاح يبقى
// كما هو
function testRepeatCaching() public {
for(uint i=1; i<100; i++) {
uint _key1 = cache.cacheWrite(i);
uint _key2 = cache.cacheWrite(i);
assertEq(_key1, _key2);
}
أولاً نكتب كل قيمة مرتين في التخزين المؤقت ونتأكد من أن المفاتيح متطابقة (مما يعني أن الكتابة الثانية لم تحدث حقًا).
for(uint i=1; i<100; i+=3) {
uint _key = cache.cacheWrite(i);
assertEq(_key, i);
}
} // testRepeatCaching
نظريًا، قد يكون هناك خطأ برمجي لا يؤثر على عمليات الكتابة المتتالية في التخزين المؤقت. لذا نقوم هنا ببعض عمليات الكتابة غير المتتالية ونرى أن القيم لا تزال غير مُعاد كتابتها.
// قراءة uint من مخزن الذاكرة المؤقت (للتأكد من أننا نسترجع المعلمات
// التي أرسلناها)
function toUint256(bytes memory _bytes, uint256 _start) internal pure
returns (uint256)
قراءة كلمة بحجم 256 bit من مخزن مؤقت bytes memory. تتيح لنا هذه الدالة المساعدة التحقق من أننا نتلقى النتائج الصحيحة عندما نقوم بتشغيل استدعاء دالة يستخدم التخزين المؤقت.
{
require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
uint256 tempUint;
assembly {
tempUint := mload(add(add(_bytes, 0x20), _start))
}
لا تدعم لغة Yul هياكل البيانات التي تتجاوز uint256، لذلك عندما تشير إلى هيكل بيانات أكثر تعقيدًا، مثل المخزن المؤقت للذاكرة _bytes، فإنك تحصل على عنوان ذلك الهيكل. تخزن Solidity قيم bytes memory ككلمة بحجم 32 bytes تحتوي على الطول، متبوعة بالبايتات الفعلية، لذلك للحصول على البايت رقم _start نحتاج إلى حساب _bytes+32+_start.
return tempUint;
} // toUint256
// توقيع الدالة لـ fourParams()، مقدم من
// https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
// مجرد بعض القيم الثابتة لنرى أننا نحصل على القيم الصحيحة مرة أخرى
uint256 constant VAL_A = 0xDEAD60A7;
uint256 constant VAL_B = 0xBEEF;
uint256 constant VAL_C = 0x600D;
uint256 constant VAL_D = 0x600D60A7;
بعض الثوابت التي نحتاجها للاختبار.
function testReadParam() public {
استدعاء fourParams()، وهي دالة تستخدم readParams، لاختبار قدرتنا على قراءة المعلمات بشكل صحيح.
address _cacheAddr = address(cache);
bool _success;
bytes memory _callInput;
bytes memory _callOutput;
لا يمكننا استخدام آلية واجهة التطبيق الثنائية (ABI) العادية لاستدعاء دالة باستخدام التخزين المؤقت، لذلك نحتاج إلى استخدام آلية <address>.call() (opens in a new tab) منخفضة المستوى. تأخذ هذه الآلية bytes memory كمدخل، وترجع ذلك (بالإضافة إلى قيمة منطقية) كمخرج.
// الاستدعاء الأول، ذاكرة التخزين المؤقت فارغة
_callInput = bytes.concat(
FOUR_PARAMS,
من المفيد أن يدعم نفس العقد كلاً من الدوال المخزنة مؤقتًا (للاستدعاءات المباشرة من المعاملات) والدوال غير المخزنة مؤقتًا (للاستدعاءات من العقود الذكية الأخرى). للقيام بذلك، نحتاج إلى الاستمرار في الاعتماد على آلية Solidity لاستدعاء الدالة الصحيحة، بدلاً من وضع كل شيء في دالة fallback (opens in a new tab). القيام بذلك يجعل قابلية التركيب أسهل بكثير. سيكون بايت واحد كافيًا لتحديد الدالة في معظم الحالات، لذلك نحن نهدر ثلاثة بايتات (16*3=48 غاز). ومع ذلك، أثناء كتابتي لهذا، تكلف هذه الـ 48 غاز 0.07 سنت، وهي تكلفة معقولة لكود أبسط وأقل عرضة للأخطاء.
// القيمة الأولى، أضفها إلى ذاكرة التخزين المؤقت
cache.INTO_CACHE(),
bytes32(VAL_A),
القيمة الأولى: علامة (flag) تشير إلى أنها قيمة كاملة يجب كتابتها في التخزين المؤقت، متبوعة بـ 32 bytes من القيمة. القيم الثلاث الأخرى مشابهة، باستثناء أن VAL_B لا تُكتب في التخزين المؤقت وأن VAL_C هي المعلمة الثالثة والرابعة معًا.
.
.
.
);
(_success, _callOutput) = _cacheAddr.call(_callInput);
هنا نقوم فعليًا باستدعاء عقد Cache.
assertEq(_success, true);
نتوقع أن يكون الاستدعاء ناجحًا.
assertEq(cache.cacheRead(1), VAL_A);
assertEq(cache.cacheRead(2), VAL_C);
نبدأ بتخزين مؤقت فارغ ثم نضيف VAL_A متبوعًا بـ VAL_C. نتوقع أن يكون للأول المفتاح 1، وللثاني المفتاح 2.
assertEq(toUint256(_callOutput,0), VAL_A);
assertEq(toUint256(_callOutput,32), VAL_B);
assertEq(toUint256(_callOutput,64), VAL_C);
assertEq(toUint256(_callOutput,96), VAL_C);
المخرجات هي المعلمات الأربع. هنا نتحقق من صحتها.
// الاستدعاء الثاني، يمكننا استخدام ذاكرة التخزين المؤقت
_callInput = bytes.concat(
FOUR_PARAMS,
// القيمة الأولى في ذاكرة التخزين المؤقت
bytes1(0x01),
مفاتيح التخزين المؤقت الأقل من 16 هي بايت واحد فقط.
// القيمة الثانية، لا تضفها إلى ذاكرة التخزين المؤقت
cache.DONT_CACHE(),
bytes32(VAL_B),
// القيمتان الثالثة والرابعة، نفس القيمة
bytes1(0x02),
bytes1(0x02)
);
.
.
.
} // testReadParam
الاختبارات بعد الاستدعاء مطابقة لتلك التي بعد الاستدعاء الأول.
function testEncodeVal() public {
هذه الدالة مشابهة لـ testReadParam، باستثناء أننا بدلاً من كتابة المعلمات صراحةً نستخدم encodeVal().
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
cache.encodeVal(VAL_A),
cache.encodeVal(VAL_B),
cache.encodeVal(VAL_C),
cache.encodeVal(VAL_D)
);
.
.
.
assertEq(_callInput.length, 4+1*4);
} // testEncodeVal
الاختبار الإضافي الوحيد في testEncodeVal() هو التحقق من أن طول _callInput صحيح. بالنسبة للاستدعاء الأول، يكون 4+33*4. بالنسبة للثاني، حيث تكون كل قيمة موجودة بالفعل في التخزين المؤقت، يكون 4+1*4.
// اختبار encodeVal عندما يكون المفتاح أكثر من بايت واحد
// الحد الأقصى ثلاثة بايتات لأن ملء ذاكرة التخزين المؤقت إلى أربعة بايتات يستغرق
// وقتًا طويلاً جدًا.
function testEncodeValBig() public {
// وضع عدد من القيم في ذاكرة التخزين المؤقت.
// لإبقاء الأمور بسيطة، استخدم المفتاح n للقيمة n.
for(uint i=1; i<0x1FFF; i++) {
cache.cacheWrite(i);
}
دالة testEncodeVal أعلاه تكتب أربع قيم فقط في التخزين المؤقت، لذا فإن الجزء من الدالة الذي يتعامل مع القيم متعددة البايتات (opens in a new tab) لا يتم التحقق منه. لكن هذا الكود معقد وعرضة للأخطاء.
الجزء الأول من هذه الدالة عبارة عن حلقة (loop) تكتب جميع القيم من 1 إلى 0x1FFF في التخزين المؤقت بالترتيب، حتى نتمكن من تشفير تلك القيم ومعرفة إلى أين تتجه.
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
cache.encodeVal(0x000F), // بايت واحد 0x0F
cache.encodeVal(0x0010), // بايتان 0x1010
cache.encodeVal(0x0100), // بايتان 0x1100
cache.encodeVal(0x1000) // ثلاثة بايتات 0x201000
);
اختبار قيم البايت الواحد، والبايتين، والثلاثة بايتات. لا نختبر أبعد من ذلك لأنه سيستغرق وقتًا طويلاً لكتابة إدخالات مكدس (stack) كافية (على الأقل 0x10000000، أي حوالي ربع مليار).
.
.
.
.
} // testEncodeValBig
// اختبار أنه مع مخزن مؤقت صغير جدًا نحصل على تراجع
function testShortCalldata() public {
اختبار ما يحدث في الحالة غير الطبيعية حيث لا يوجد عدد كافٍ من المعلمات.
.
.
.
(_success, _callOutput) = _cacheAddr.call(_callInput);
assertEq(_success, false);
} // testShortCalldata
نظرًا لأنه يتراجع، فإن النتيجة التي يجب أن نحصل عليها هي false.
// Call with cache keys that aren't there
function testNoCacheKey() public {
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
// القيمة الأولى، أضفها إلى ذاكرة التخزين المؤقت
cache.INTO_CACHE(),
bytes32(VAL_A),
// Second value
bytes1(0x0F),
bytes2(0x1234),
bytes11(0xA10102030405060708090A)
);
تحصل هذه الدالة على أربع معلمات مشروعة تمامًا، باستثناء أن التخزين المؤقت فارغ لذا لا توجد قيم هناك لقراءتها.
.
.
.
// اختبار أنه مع مخزن مؤقت طويل جدًا يعمل كل شيء بشكل جيد
function testLongCalldata() public {
address _cacheAddr = address(cache);
bool _success;
bytes memory _callInput;
bytes memory _callOutput;
// الاستدعاء الأول، ذاكرة التخزين المؤقت فارغة
_callInput = bytes.concat(
FOUR_PARAMS,
// First value, add it to the cache
cache.INTO_CACHE(), bytes32(VAL_A),
// القيمة الثانية، أضفها إلى ذاكرة التخزين المؤقت
cache.INTO_CACHE(), bytes32(VAL_B),
// القيمة الثالثة، أضفها إلى ذاكرة التخزين المؤقت
cache.INTO_CACHE(), bytes32(VAL_C),
// القيمة الرابعة، أضفها إلى ذاكرة التخزين المؤقت
cache.INTO_CACHE(), bytes32(VAL_D),
// وقيمة أخرى من أجل "حسن الحظ"
bytes4(0x31112233)
);
ترسل هذه الدالة خمس قيم. نعلم أن القيمة الخامسة يتم تجاهلها لأنها ليست إدخال تخزين مؤقت صالح، مما كان سيؤدي إلى تراجع لو لم يتم تضمينها.
(_success, _callOutput) = _cacheAddr.call(_callInput);
assertEq(_success, true);
.
.
.
} // testLongCalldata
} // CacheTest
تطبيق نموذجي
كتابة الاختبارات بلغة Solidity أمر جيد جدًا، ولكن في نهاية المطاف يحتاج التطبيق اللامركزي (dapp) إلى أن يكون قادرًا على معالجة الطلبات من خارج السلسلة ليكون مفيدًا. توضح هذه المقالة كيفية استخدام التخزين المؤقت في تطبيق لامركزي (dapp) باستخدام WORM، والذي يرمز إلى "الكتابة مرة واحدة، والقراءة عدة مرات" (Write Once, Read Many). إذا لم يتم كتابة مفتاح بعد، يمكنك كتابة قيمة له. إذا كان المفتاح مكتوبًا بالفعل، فستحصل على تراجع.
العقد
هذا هو العقد (opens in a new tab). يكرر في الغالب ما قمنا به بالفعل مع Cache و CacheTest، لذا سنغطي فقط الأجزاء المثيرة للاهتمام.
import "./Cache.sol";
contract WORM is Cache {
أسهل طريقة لاستخدام Cache هي توريثه في عقدنا الخاص.
function writeEntryCached() external {
uint[] memory params = _readParams(2);
writeEntry(params[0], params[1]);
} // writeEntryCached
هذه الدالة مشابهة لـ fourParam في CacheTest أعلاه. نظرًا لأننا لا نتبع مواصفات واجهة التطبيق الثنائية (ABI)، فمن الأفضل عدم التصريح عن أي معلمات في الدالة.
// تسهيل استدعائنا
// توقيع الدالة لـ writeEntryCached()، مقدم من
// https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;
سيحتاج الكود الخارجي الذي يستدعي writeEntryCached إلى بناء بيانات الاستدعاء يدويًا، بدلاً من استخدام worm.writeEntryCached، لأننا لا نتبع مواصفات واجهة التطبيق الثنائية (ABI). وجود هذه القيمة الثابتة يجعل كتابتها أسهل.
لاحظ أنه على الرغم من أننا نُعرّف WRITE_ENTRY_CACHED كمتغير حالة، لقراءته خارجيًا من الضروري استخدام دالة الجلب (getter) الخاصة به، worm.WRITE_ENTRY_CACHED().
function readEntry(uint key) public view
returns (uint _value, address _writtenBy, uint _writtenAtBlock)
دالة القراءة هي view، لذا فهي لا تتطلب معاملة ولا تكلف غازًا. نتيجة لذلك، لا توجد فائدة من استخدام التخزين المؤقت للمعلمة. مع دوال العرض (view functions)، من الأفضل استخدام الآلية القياسية الأبسط.
كود الاختبار
هذا هو كود الاختبار للعقد (opens in a new tab). مرة أخرى، دعونا ننظر فقط إلى ما هو مثير للاهتمام.
function testWReadWrite() public {
worm.writeEntry(0xDEAD, 0x60A7);
vm.expectRevert(bytes("entry already written"));
worm.writeEntry(0xDEAD, 0xBEEF);
هكذا (vm.expectRevert) (opens in a new tab) نحدد في اختبار Foundry أن الاستدعاء التالي يجب أن يفشل، والسبب المُبلغ عنه للفشل. ينطبق هذا عندما نستخدم الصيغة <contract>.<function name>() بدلاً من بناء بيانات الاستدعاء واستدعاء العقد باستخدام الواجهة منخفضة المستوى (<contract>.call()، إلخ).
function testReadWriteCached() public {
uint cacheGoat = worm.cacheWrite(0x60A7);
هنا نستخدم حقيقة أن cacheWrite يُرجع مفتاح التخزين المؤقت. هذا ليس شيئًا نتوقع استخدامه في الإنتاج، لأن cacheWrite يغير الحالة، وبالتالي لا يمكن استدعاؤه إلا أثناء معاملة. المعاملات ليس لها قيم إرجاع، وإذا كانت لها نتائج، فمن المفترض أن يتم إصدار تلك النتائج كأحداث. لذا فإن قيمة إرجاع cacheWrite لا يمكن الوصول إليها إلا من الكود على السلسلة، والكود على السلسلة لا يحتاج إلى تخزين المعلمات مؤقتًا.
(_success,) = address(worm).call(_callInput);
هكذا نخبر Solidity أنه بينما يحتوي <contract address>.call() على قيمتي إرجاع، فإننا نهتم فقط بالأولى.
(_success,) = address(worm).call(_callInput);
assertEq(_success, false);
نظرًا لأننا نستخدم دالة <address>.call() منخفضة المستوى، لا يمكننا استخدام vm.expectRevert() وعلينا النظر إلى القيمة المنطقية للنجاح التي نحصل عليها من الاستدعاء.
event EntryWritten(uint indexed key, uint indexed value);
.
.
.
_callInput = bytes.concat(
worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));
vm.expectEmit(true, true, false, false);
emit EntryWritten(a, b);
(_success,) = address(worm).call(_callInput);
هذه هي الطريقة التي نتحقق بها من أن الكود يُصدر حدثًا بشكل صحيح (opens in a new tab) في Foundry.
العميل
الشيء الوحيد الذي لا تحصل عليه مع اختبارات Solidity هو كود JavaScript الذي يمكنك قصه ولصقه في تطبيقك الخاص. نشرت النسخة الأصلية من هذا البرنامج التعليمي عقد WORM على شبكة أوبتيميزم غويرلي، والتي تم إيقافها منذ ذلك الحين. لتشغيل العميل اليوم، أعد نشر WORM على شبكة OP Stack مدعومة مثل OP Sepolia (opens in a new tab)، ثم استخدم عنوان العقد الناتج في عميل JavaScript.
يمكنك رؤية كود JavaScript للعميل هنا (opens in a new tab). تمت كتابة المستودع النموذجي لشبكة أوبتيميزم غويرلي، لذا قبل تشغيله، قم بتحديث نقطة نهاية RPC وعناوين URL الخاصة بالمستكشف في javascript/.env.example و javascript/index.js لشبكتك المستهدفة. لاستخدامه:
-
استنسخ مستودع git:
git clone https://github.com/qbzzt/20220915-all-you-can-cache.git -
قم بتثبيت الحزم الضرورية:
cd javascript yarn -
انسخ ملف التكوين:
cp .env.example .env -
قم بتعديل
.envليناسب تكوينك:المعلمة القيمة MNEMONIC العبارة التذكيرية (mnemonic) لحساب يحتوي على ما يكفي من ETH لدفع رسوم معاملة. تسرد مستندات صنبور أوبتيميزم (opens in a new tab) صنابير شبكة الاختبار الحالية. OPTIMISM_GOERLI_URL عنوان URL لـ RPC للشبكة التي تعيد نشر WORM عليها. بالنسبة لـ OP Sepolia، استخدم نقطة نهاية RPC لـ OP Sepolia مثل https://sepolia.optimism.io، أو نقطة نهاية أخرى من مزودك. -
قم بتشغيل
index.js.node index.jsيقوم هذا التطبيق النموذجي أولاً بكتابة إدخال في WORM، ويعرض بيانات الاستدعاء ورابطًا للمعاملة على مستكشف الكتل. ثم يقرأ هذا الإدخال مرة أخرى، ويعرض المفتاح الذي يستخدمه والقيم الموجودة في الإدخال (القيمة، ورقم الكتلة، والمؤلف).
معظم العميل عبارة عن كود JavaScript عادي لتطبيق لامركزي (dapp). لذا مرة أخرى سنراجع الأجزاء المثيرة للاهتمام فقط.
.
.
.
const main = async () => {
const func = await worm.WRITE_ENTRY_CACHED()
// نحتاج إلى مفتاح جديد في كل مرة
const key = await worm.encodeVal(Number(new Date()))
لا يمكن الكتابة في خانة معينة إلا مرة واحدة، لذا نستخدم الطابع الزمني للتأكد من أننا لا نعيد استخدام الخانات.
const val = await worm.encodeVal("0x600D")
// كتابة إدخال
const calldata = func + key.slice(2) + val.slice(2)
تتوقع Ethers أن تكون بيانات الاستدعاء سلسلة سداسية عشرية، تبدأ بـ 0x متبوعة بعدد زوجي من الأرقام السداسية العشرية. بما أن كلاً من key و val يبدآن بـ 0x، نحتاج إلى إزالة هذه البادئات.
const tx = await worm.populateTransaction.writeEntryCached()
tx.data = calldata
sentTx = await wallet.sendTransaction(tx)
كما هو الحال مع كود اختبار Solidity، لا يمكننا استدعاء دالة مخزنة مؤقتًا بشكل طبيعي. بدلاً من ذلك، نحتاج إلى استخدام آلية منخفضة المستوى.
.
.
.
// قراءة الإدخال الذي تمت كتابته للتو
const realKey = '0x' + key.slice(4) // إزالة علامة FF
const entryRead = await worm.readEntry(realKey)
.
.
.
لقراءة الإدخالات يمكننا استخدام الآلية العادية. ليست هناك حاجة لاستخدام التخزين المؤقت للمعلمات مع دوال العرض (view).
الخاتمة
الكود في هذه المقالة هو إثبات للمفهوم (proof of concept)، والغرض منه هو جعل الفكرة سهلة الفهم. بالنسبة لنظام جاهز للإنتاج، قد ترغب في تنفيذ بعض الوظائف الإضافية:
-
التعامل مع القيم التي ليست
uint256. على سبيل المثال، السلاسل النصية (strings). -
بدلاً من تخزين مؤقت عالمي، ربما يكون هناك تعيين بين المستخدمين والتخزينات المؤقتة. يستخدم المستخدمون المختلفون قيمًا مختلفة.
-
القيم المستخدمة للعناوين متميزة عن تلك المستخدمة لأغراض أخرى. قد يكون من المنطقي وجود تخزين مؤقت منفصل للعناوين فقط.
-
حاليًا، تعتمد مفاتيح التخزين المؤقت على خوارزمية "من يأتي أولاً، يحصل على أصغر مفتاح". يمكن إرسال القيم الست عشرة الأولى كبايت واحد. يمكن إرسال القيم الـ 4080 التالية كبايتين. المليون قيمة التالية تقريبًا هي ثلاثة بايتات، إلخ. يجب أن يحتفظ نظام الإنتاج بعدادات استخدام لإدخالات التخزين المؤقت وإعادة تنظيمها بحيث تكون القيم الست عشرة الأكثر شيوعًا بايتًا واحدًا، والقيم الـ 4080 الأكثر شيوعًا التالية بايتين، إلخ.
ومع ذلك، فهذه عملية قد تكون خطيرة. تخيل تسلسل الأحداث التالي:
-
يستدعي نعوم الساذج (Noam Naive)
encodeValلتشفير العنوان الذي يريد إرسال الرموز إليه. هذا العنوان هو أحد العناوين الأولى المستخدمة في التطبيق، لذا فإن القيمة المشفرة هي 0x06. هذه دالةview، وليست معاملة، لذا فهي بين نعوم والعقدة التي يستخدمها، ولا أحد غيره يعرف عنها. -
يقوم أوين المالك (Owen Owner) بتشغيل عملية إعادة ترتيب التخزين المؤقت. عدد قليل جدًا من الأشخاص يستخدمون هذا العنوان فعليًا، لذا يتم تشفيره الآن كـ 0x201122. يتم تعيين قيمة مختلفة، 1018، إلى 0x06.
-
يرسل نعوم الساذج رموزه إلى 0x06. تذهب إلى العنوان
0x0000000000000000000000000de0b6b3a7640000، وبما أنه لا أحد يعرف المفتاح الخاص لهذا العنوان، فإنها تظل عالقة هناك. نعوم ليس سعيدًا.
هناك طرق لحل هذه المشكلة، والمشكلة ذات الصلة بالمعاملات الموجودة في مجمع الذاكرة أثناء إعادة ترتيب التخزين المؤقت، ولكن يجب أن تكون على دراية بها.
-
لقد أوضحت التخزين المؤقت هنا باستخدام أوبتيميزم، لأنني موظف في أوبتيميزم وهذا هو التجميع (rollup) الذي أعرفه بشكل أفضل. ولكن يجب أن يعمل مع أي تجميع يفرض تكلفة ضئيلة للمعالجة الداخلية، بحيث تكون كتابة بيانات المعاملة إلى طبقة 1 (L1) هي النفقات الرئيسية بالمقارنة.