آپ جتنا کیش کر سکتے ہیں
رول اپس کا استعمال کرتے وقت ٹرانزیکشن میں ایک بائٹ کی قیمت اسٹوریج سلاٹ کی قیمت سے کہیں زیادہ مہنگی ہوتی ہے۔ اس لیے، زیادہ سے زیادہ معلومات کو آن چین کیش کرنا سمجھداری ہے۔
اس مضمون میں آپ سیکھیں گے کہ کیشنگ کنٹریکٹ کو اس طرح کیسے بنایا اور استعمال کیا جائے کہ کسی بھی پیرامیٹر کی قدر جس کے متعدد بار استعمال ہونے کا امکان ہو، اسے کیش کر لیا جائے اور (پہلی بار کے بعد) بہت کم بائٹس کے ساتھ استعمال کے لیے دستیاب ہو، اور آف چین کوڈ کیسے لکھا جائے جو اس کیش کا استعمال کرے۔
اگر آپ مضمون کو چھوڑ کر صرف سورس کوڈ دیکھنا چاہتے ہیں، تو وہ یہاں ہے (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
کیش کی ہیرا پھیری
کیش کو کیشے.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;
یہ مستقل (constants) ان خاص صورتوں کی تشریح کے لیے استعمال ہوتے ہیں جہاں ہم تمام معلومات فراہم کرتے ہیں اور یا تو اسے کیش میں لکھنا چاہتے ہیں یا نہیں۔ کیش میں لکھنے کے لیے پہلے سے غیر استعمال شدہ اسٹوریج سلاٹس میں دو SSTORE (opens in a new tab) آپریشنز کی ضرورت ہوتی ہے جن میں سے ہر ایک کی قیمت 22100 گیس ہوتی ہے، اس لیے ہم اسے اختیاری بناتے ہیں۔
mapping(uint => uint) public val2key;
اقدار اور ان کی کلیدوں کے درمیان ایک میپنگ (opens in a new tab)۔ ٹرانزیکشن بھیجنے سے پہلے اقدار کو انکوڈ کرنے کے لیے یہ معلومات ضروری ہے۔
// مقام n میں کلید n+1 کی قدر ہے، کیونکہ ہمیں محفوظ رکھنے کی ضرورت ہے
// صفر کو بطور "کیشے میں نہیں"۔
uint[] public key2val;
ہم کلیدوں سے اقدار تک میپنگ کے لیے ایک سرنی (array) استعمال کر سکتے ہیں کیونکہ ہم کلیدیں تفویض کرتے ہیں، اور سادگی کے لیے ہم اسے ترتیب وار کرتے ہیں۔
function cacheRead(uint _key) public view returns (uint) {
require(_key <= key2val.length, "Reading uninitialize cache entry");
return key2val[_key-1];
} // cacheRead
کیش سے ایک قدر پڑھیں۔
// اگر کوئی قدر پہلے سے کیشے میں موجود نہیں ہے تو اسے لکھیں
// ٹیسٹ کو کام کرنے کے قابل بنانے کے لیے صرف پبلک ہے
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);
فارورڈ لک اپ شامل کریں (کلید سے قدر تک)۔ چونکہ ہم اقدار کو ترتیب وار تفویض کرتے ہیں اس لیے ہم اسے آخری سرنی (array) کی قدر کے بعد شامل کر سکتے ہیں۔
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 byte کی قدر پڑھتا ہے۔ یہ تب بھی کام کرتا ہے اگر کال ڈیٹا startByte+32 سے پہلے رک جائے کیونکہ EVM میں غیر شروع شدہ (uninitialized) جگہ کو صفر سمجھا جاتا ہے۔
_retVal = _retVal >> (256-length*8);
ہمیں لازمی طور پر 32 byte کی قدر نہیں چاہیے۔ یہ اضافی بائٹس سے چھٹکارا پاتا ہے۔
return _retVal;
} // _calldataVal
// کال ڈیٹا سے ایک واحد پیرامیٹر پڑھیں، جو _fromByte سے شروع ہوتا ہے
function _readParam(uint _fromByte) internal
returns (uint _nextByte, uint _parameterValue)
{
کال ڈیٹا سے ایک واحد پیرامیٹر پڑھیں۔ نوٹ کریں کہ ہمیں نہ صرف وہ قدر واپس کرنے کی ضرورت ہے جو ہم نے پڑھی ہے، بلکہ اگلی بائٹ کا مقام بھی کیونکہ پیرامیٹرز 1 byte سے لے کر 33 bytes تک طویل ہو سکتے ہیں۔
// پہلا بائٹ ہمیں بتاتا ہے کہ باقی کی تشریح کیسے کی جائے
uint8 _firstByte;
_firstByte = uint8(_calldataVal(_fromByte, 1));
Solidity ممکنہ طور پر خطرناک پوشیدہ ٹائپ کنورژنز (implicit type conversions) (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 میں تمام غیر شروع شدہ (uninitialized) اسٹوریج کو صفر فرض کیا جاتا ہے۔ لہذا اگر ہم کسی ایسی قدر کی کلید تلاش کرتے ہیں جو وہاں نہیں ہے، تو ہمیں صفر ملتا ہے۔ اس صورت میں جو بائٹس اسے انکوڈ کرتی ہیں وہ INTO_CACHE ہیں (تاکہ اگلی بار اسے کیش کیا جا سکے)، جس کے بعد اصل قدر ہوتی ہے۔
// اگر کلید <0x10 ہے، تو اسے ایک بائٹ کے طور پر واپس کریں
if (_key < 0x10)
return bytes.concat(bytes1(uint8(_key)));
سنگل بائٹس سب سے آسان ہیں۔ ہم صرف bytes.concat (opens in a new tab) کا استعمال کرتے ہیں تاکہ bytes<n> ٹائپ کو بائٹ سرنی (array) میں تبدیل کیا جا سکے جو کسی بھی لمبائی کی ہو سکتی ہے۔ نام کے باوجود، جب اسے صرف ایک دلیل (argument) فراہم کی جاتی ہے تو یہ ٹھیک کام کرتا ہے۔
// دو بائٹ کی قدر، 0x1vvv کے طور پر انکوڈ کی گئی
if (_key < 0x1000)
return bytes.concat(bytes2(uint16(_key) | 0x1000));
جب ہمارے پاس کوئی کلید ہوتی ہے جو 163 سے کم ہوتی ہے، تو ہم اسے دو بائٹس میں ظاہر کر سکتے ہیں۔ ہم پہلے _key کو، جو کہ ایک 256 bit کی قدر ہے، 16 bit کی قدر میں تبدیل کرتے ہیں اور پہلی بائٹ میں اضافی بائٹس کی تعداد شامل کرنے کے لیے منطقی 'or' کا استعمال کرتے ہیں۔ پھر ہم اسے صرف ایک bytes2 قدر میں ڈالتے ہیں، جسے bytes میں تبدیل کیا جا سکتا ہے۔
// مندرجہ ذیل لائنوں کو لوپ کے طور پر کرنے کا شاید کوئی ہوشیار طریقہ ہے،
// لیکن یہ ایک ویو فنکشن ہے اس لیے میں پروگرامر کے وقت اور
// سادگی کے لیے آپٹیمائز کر رہا ہوں۔
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";
یہ صرف بوائلر پلیٹ ہے جو ٹیسٹ پیکج اور 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 کے نتیجے کو مسترد کر سکتے ہیں کیونکہ ہم جانتے ہیں کہ کیش کلیدیں خطی (linearly) طور پر تفویض کی جاتی ہیں۔
}
} // 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)
bytes memory بفر سے ایک 256 bit کا لفظ پڑھیں۔ یہ یوٹیلیٹی فنکشن ہمیں اس بات کی تصدیق کرنے دیتا ہے کہ جب ہم کیش استعمال کرنے والی فنکشن کال چلاتے ہیں تو ہمیں درست نتائج موصول ہوتے ہیں۔
{
require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
uint256 tempUint;
assembly {
tempUint := mload(add(add(_bytes, 0x20), _start))
}
Yul uint256 سے آگے ڈیٹا اسٹرکچرز کو سپورٹ نہیں کرتا، لہذا جب آپ کسی زیادہ نفیس ڈیٹا اسٹرکچر کا حوالہ دیتے ہیں، جیسے کہ میموری بفر _bytes، تو آپ کو اس اسٹرکچر کا پتہ ملتا ہے۔ Solidity bytes memory اقدار کو ایک 32 byte کے لفظ کے طور پر اسٹور کرتی ہے جس میں لمبائی ہوتی ہے، جس کے بعد اصل بائٹس ہوتی ہیں، لہذا بائٹ نمبر _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;
کچھ مستقل (constants) جن کی ہمیں ٹیسٹنگ کے لیے ضرورت ہے۔
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,
ایک ہی کنٹریکٹ کے لیے کیش شدہ فنکشنز (براہ راست ٹرانزیکشنز سے کالز کے لیے) اور غیر کیش شدہ فنکشنز (دیگر اسمارٹ کنٹریکٹس سے کالز کے لیے) دونوں کو سپورٹ کرنا مفید ہے۔ ایسا کرنے کے لیے ہمیں ہر چیز کو ایک fallback فنکشن (opens in a new tab) میں ڈالنے کے بجائے، درست فنکشن کو کال کرنے کے لیے Solidity میکانزم پر انحصار جاری رکھنے کی ضرورت ہے۔ ایسا کرنے سے ترکیب پذیری بہت آسان ہو جاتی ہے۔ زیادہ تر معاملات میں فنکشن کی شناخت کے لیے ایک ہی بائٹ کافی ہوگی، اس لیے ہم تین بائٹس ضائع کر رہے ہیں (16*3=48 گیس)۔ تاہم، جب میں یہ لکھ رہا ہوں تو ان 48 گیس کی قیمت 0.07 cents ہے، جو کہ آسان، کم بگ والے کوڈ کی ایک معقول قیمت ہے۔
// پہلی قدر، اسے کیشے میں شامل کریں
cache.INTO_CACHE(),
bytes32(VAL_A),
پہلی قدر: ایک فلیگ جو یہ بتاتا ہے کہ یہ ایک مکمل قدر ہے جسے کیش میں لکھنے کی ضرورت ہے، جس کے بعد قدر کی 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) چیک نہیں کیا جاتا۔ لیکن وہ کوڈ پیچیدہ ہے اور اس میں غلطی کا امکان ہے۔
اس فنکشن کا پہلا حصہ ایک لوپ ہے جو 1 سے 0x1FFF تک کی تمام اقدار کو ترتیب سے کیش میں لکھتا ہے، تاکہ ہم ان اقدار کو انکوڈ کر سکیں اور جان سکیں کہ وہ کہاں جا رہی ہیں۔
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
cache.encodeVal(0x000F), // ایک بائٹ 0x0F
cache.encodeVal(0x0010), // دو بائٹس 0x1010
cache.encodeVal(0x0100), // دو بائٹس 0x1100
cache.encodeVal(0x1000) // تین بائٹس 0x201000
);
ایک بائٹ، دو بائٹ، اور تین بائٹ کی اقدار کا ٹیسٹ کریں۔ ہم اس سے آگے ٹیسٹ نہیں کرتے کیونکہ کافی اسٹیک اندراجات (کم از کم 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) کو مفید ہونے کے لیے چین کے باہر سے درخواستوں پر کارروائی کرنے کے قابل ہونا چاہیے۔ یہ مضمون ظاہر کرتا ہے کہ WORM کے ساتھ غیر مرکزی ایپلی کیشن (dapp) میں کیشنگ کا استعمال کیسے کیا جائے، جس کا مطلب ہے "Write Once, Read Many" (ایک بار لکھیں، کئی بار پڑھیں)۔ اگر کوئی کلید ابھی تک نہیں لکھی گئی ہے، تو آپ اس پر ایک قدر لکھ سکتے ہیں۔ اگر کلید پہلے سے لکھی ہوئی ہے، تو آپ کو ایک ریورٹ ملتا ہے۔
کنٹریکٹ
یہ کنٹریکٹ ہے (opens in a new tab)۔ یہ زیادہ تر وہی دہراتا ہے جو ہم پہلے ہی Cache اور CacheTest کے ساتھ کر چکے ہیں، اس لیے ہم صرف ان حصوں کا احاطہ کرتے ہیں جو دلچسپ ہیں۔
import "./Cache.sol";
contract WORM is Cache {
Cache کو استعمال کرنے کا سب سے آسان طریقہ اسے اپنے کنٹریکٹ میں وراثت (inherit) میں لینا ہے۔
function writeEntryCached() external {
uint[] memory params = _readParams(2);
writeEntry(params[0], params[1]);
} // writeEntryCached
یہ فنکشن اوپر CacheTest میں fourParam سے ملتا جلتا ہے۔ چونکہ ہم ABI تصریحات (specifications) کی پیروی نہیں کرتے ہیں، اس لیے بہتر ہے کہ فنکشن میں کسی بھی پیرامیٹرز کا اعلان نہ کیا جائے۔
// ہمیں کال کرنا آسان بنائیں
// writeEntryCached() کے لیے فنکشن سگنیچر، بشکریہ
// https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;
بیرونی کوڈ جو writeEntryCached کو کال کرتا ہے اسے worm.writeEntryCached استعمال کرنے کے بجائے دستی طور پر کال ڈیٹا بنانے کی ضرورت ہوگی، کیونکہ ہم ABI تصریحات کی پیروی نہیں کرتے ہیں۔ اس مستقل (constant) قدر کا ہونا اسے لکھنا آسان بناتا ہے۔
نوٹ کریں کہ اگرچہ ہم WRITE_ENTRY_CACHED کو ایک اسٹیٹ (state) متغیر کے طور پر بیان کرتے ہیں، اسے بیرونی طور پر پڑھنے کے لیے اس کے لیے گیٹر (getter) فنکشن، worm.WRITE_ENTRY_CACHED() کا استعمال کرنا ضروری ہے۔
function readEntry(uint key) public view
returns (uint _value, address _writtenBy, uint _writtenAtBlock)
ریڈ فنکشن ایک view ہے، اس لیے اسے ٹرانزیکشن کی ضرورت نہیں ہوتی اور اس پر گیس خرچ نہیں ہوتی۔ نتیجے کے طور پر، پیرامیٹر کے لیے کیش استعمال کرنے کا کوئی فائدہ نہیں ہے۔ ویو (view) فنکشنز کے ساتھ معیاری میکانزم استعمال کرنا بہتر ہے جو زیادہ آسان ہے۔
ٹیسٹنگ کوڈ
یہ کنٹریکٹ کے لیے ٹیسٹنگ کوڈ ہے (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>.call()، وغیرہ) کا استعمال کرتے ہوئے کنٹریکٹ کو کال کرنے کے بجائے <contract>.<function name>() سنٹیکس استعمال کرتے ہیں۔
function testReadWriteCached() public {
uint cacheGoat = worm.cacheWrite(0x60A7);
یہاں ہم اس حقیقت کا استعمال کرتے ہیں کہ cacheWrite کیش کلید واپس کرتا ہے۔ یہ کوئی ایسی چیز نہیں ہے جس کی ہم پروڈکشن میں استعمال کرنے کی توقع کریں گے، کیونکہ cacheWrite حالت کو تبدیل کرتا ہے، اور اس لیے اسے صرف ٹرانزیکشن کے دوران کال کیا جا سکتا ہے۔ ٹرانزیکشنز کی واپسی کی اقدار نہیں ہوتیں، اگر ان کے نتائج ہوتے ہیں تو ان نتائج کو ایونٹس کے طور پر خارج (emit) کیا جانا چاہیے۔ لہذا 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);
یہ وہ طریقہ ہے جس سے ہم تصدیق کرتے ہیں کہ کوڈ Foundry میں ایونٹ کو درست طریقے سے خارج (emit) کرتا ہے (opens in a new tab)۔
کلائنٹ
ایک چیز جو آپ کو Solidity ٹیسٹس کے ساتھ نہیں ملتی وہ JavaScript کوڈ ہے جسے آپ کاٹ کر اپنی ایپلی کیشن میں پیسٹ کر سکتے ہیں۔ وہ کوڈ لکھنے کے لیے میں نے WORM کو Optimism Goerli (opens in a new tab) پر تعینات کیا، جو آپٹیمزم (opens in a new tab) کا نیا آزمائشی نیٹ ورک ہے۔ یہ پتہ 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens in a new tab) پر ہے۔
آپ کلائنٹ کے لیے JavaScript کوڈ یہاں دیکھ سکتے ہیں (opens in a new tab)۔ اسے استعمال کرنے کے لیے:
-
گٹ (git) ریپوزٹری کو کلون کریں:
git clone https://github.com/qbzzt/20220915-all-you-can-cache.git -
ضروری پیکجز انسٹال کریں:
cd javascript yarn -
کنفیگریشن فائل کاپی کریں:
cp .env.example .env -
اپنی کنفیگریشن کے لیے
.envمیں ترمیم کریں:پیرامیٹر قدر MNEMONIC ایک اکاؤنٹ کے لیے یادداشت (mnemonic) جس کے پاس ٹرانزیکشن کی ادائیگی کے لیے کافی ETH ہو۔ آپ Optimism Goerli نیٹ ورک کے لیے مفت ETH یہاں حاصل کر سکتے ہیں (opens in a new tab)۔ OPTIMISM_GOERLI_URL Optimism Goerli کا URL۔ عوامی اینڈ پوائنٹ، https://goerli.optimism.io، ریٹ لمیٹڈ ہے لیکن اس کے لیے کافی ہے جس کی ہمیں یہاں ضرورت ہے۔ -
index.jsچلائیں۔node index.jsیہ نمونہ ایپلی کیشن پہلے WORM میں ایک اندراج لکھتی ہے، جس میں کال ڈیٹا اور Etherscan پر ٹرانزیکشن کا لنک دکھایا جاتا ہے۔ پھر یہ اس اندراج کو واپس پڑھتی ہے، اور اس کی استعمال کردہ کلید اور اندراج میں موجود اقدار (قدر، بلاک نمبر، اور مصنف) کو دکھاتی ہے۔
زیادہ تر کلائنٹ عام غیر مرکزی ایپلی کیشن (dapp) JavaScript ہے۔ لہذا ایک بار پھر ہم صرف دلچسپ حصوں کا جائزہ لیں گے۔
.
.
.
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 توقع کرتا ہے کہ کال ڈیٹا ایک ہیکس (hex) اسٹرنگ ہو، 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نہیں ہیں۔ مثال کے طور پر، اسٹرنگز۔ -
عالمی کیش کے بجائے، شاید صارفین اور کیشز کے درمیان میپنگ ہو۔ مختلف صارفین مختلف اقدار استعمال کرتے ہیں۔
-
پتوں کے لیے استعمال ہونے والی اقدار دیگر مقاصد کے لیے استعمال ہونے والی اقدار سے الگ ہوتی ہیں۔ صرف پتوں کے لیے ایک الگ کیش رکھنا سمجھداری ہو سکتی ہے۔
-
فی الحال، کیش کلیدیں "پہلے آئیں، سب سے چھوٹی کلید" الگورتھم پر ہیں۔ پہلی سولہ اقدار کو ایک بائٹ کے طور پر بھیجا جا سکتا ہے۔ اگلی 4080 اقدار کو دو بائٹس کے طور پر بھیجا جا سکتا ہے۔ اگلی تقریباً دس لاکھ اقدار تین بائٹس ہیں، وغیرہ۔ ایک پروڈکشن سسٹم کو کیش اندراجات پر استعمال کے کاؤنٹرز رکھنے چاہئیں اور انہیں دوبارہ منظم کرنا چاہیے تاکہ سولہ سب سے عام اقدار ایک بائٹ ہوں، اگلی 4080 سب سے عام اقدار دو بائٹس ہوں، وغیرہ۔
تاہم، یہ ممکنہ طور پر ایک خطرناک آپریشن ہے۔ واقعات کی درج ذیل ترتیب کا تصور کریں:
-
نوم نیو (Noam Naive) اس پتے کو انکوڈ کرنے کے لیے
encodeValکو کال کرتا ہے جس پر وہ ٹوکن بھیجنا چاہتا ہے۔ وہ پتہ ایپلی کیشن پر استعمال ہونے والے پہلے پتوں میں سے ایک ہے، اس لیے انکوڈ شدہ قدر 0x06 ہے۔ یہ ایکviewفنکشن ہے، ٹرانزیکشن نہیں، اس لیے یہ نوم اور اس کے استعمال کردہ نوڈ کے درمیان ہے، اور کسی اور کو اس کے بارے میں معلوم نہیں ہے۔ -
اوون اونر (Owen Owner) کیش کو دوبارہ ترتیب دینے کا آپریشن چلاتا ہے۔ بہت کم لوگ دراصل اس پتے کا استعمال کرتے ہیں، اس لیے اب اسے 0x201122 کے طور پر انکوڈ کیا گیا ہے۔ ایک مختلف قدر، 1018، کو 0x06 تفویض کیا گیا ہے۔
-
نوم نیو اپنے ٹوکن 0x06 پر بھیجتا ہے۔ وہ پتہ
0x0000000000000000000000000de0b6b3a7640000پر جاتے ہیں، اور چونکہ کوئی بھی اس پتے کی نجی کلید نہیں جانتا، اس لیے وہ وہیں پھنس جاتے ہیں۔ نوم خوش نہیں ہے۔
اس مسئلے کو حل کرنے کے طریقے موجود ہیں، اور کیش کو دوبارہ ترتیب دینے کے دوران میم پول میں موجود ٹرانزیکشنز کے متعلقہ مسئلے کو بھی، لیکن آپ کو اس سے آگاہ ہونا چاہیے۔
-
میں نے یہاں آپٹیمزم کے ساتھ کیشنگ کا مظاہرہ کیا، کیونکہ میں آپٹیمزم کا ملازم ہوں اور یہ وہ رول اپ ہے جسے میں سب سے بہتر جانتا ہوں۔ لیکن اسے کسی بھی ایسے رول اپ کے ساتھ کام کرنا چاہیے جو اندرونی پروسیسنگ کے لیے کم سے کم قیمت وصول کرتا ہو، تاکہ اس کے مقابلے میں ٹرانزیکشن ڈیٹا کو لیئر ۱ (l1) پر لکھنا بڑا خرچ ہو۔