तुम्ही कॅशे करू शकता ते सर्व
रोलअप्स वापरताना व्यवहारामधील एका बाइटची किंमत स्टोरेज स्लॉटच्या किंमतीपेक्षा खूप जास्त असते. त्यामुळे, शक्य तितकी माहिती ऑनचेन कॅशे करणे अर्थपूर्ण ठरते.
या लेखामध्ये तुम्ही कॅशिंग कॉन्ट्रॅक्ट अशा प्रकारे कसे तयार करावे आणि वापरावे हे शिकाल की जेणेकरून अनेक वेळा वापरले जाण्याची शक्यता असलेले कोणतेही पॅरामीटर मूल्य कॅशे केले जाईल आणि (पहिल्या वेळेनंतर) खूप कमी बाइट्ससह वापरण्यासाठी उपलब्ध होईल, आणि हे कॅशे वापरणारा साखळीबाह्य कोड कसा लिहावा.
जर तुम्हाला हा लेख वगळायचा असेल आणि फक्त सोर्स कोड पाहायचा असेल, तर तो येथे आहे (opens in a new tab). डेव्हलपमेंट स्टॅक Foundry (opens in a new tab) आहे.
एकूण डिझाइन
सोपे करण्यासाठी आपण असे गृहीत धरू की सर्व व्यवहार पॅरामीटर्स uint256, 32 बाइट्स लांबीचे आहेत. जेव्हा आपल्याला एखादा व्यवहार प्राप्त होतो, तेव्हा आपण प्रत्येक पॅरामीटरचे खालीलप्रमाणे विश्लेषण करू:
-
जर पहिला बाइट
0xFFअसेल, तर पुढील 32 बाइट्स पॅरामीटर मूल्य म्हणून घ्या आणि ते कॅशेमध्ये लिहा. -
जर पहिला बाइट
0xFEअसेल, तर पुढील 32 बाइट्स पॅरामीटर मूल्य म्हणून घ्या पण ते कॅशेमध्ये लिहू नका. -
इतर कोणत्याही मूल्यासाठी, वरचे चार बिट्स अतिरिक्त बाइट्सची संख्या म्हणून घ्या आणि खालचे चार बिट्स कॅशे कीचे सर्वात महत्त्वपूर्ण बिट्स म्हणून घ्या. येथे काही उदाहरणे आहेत:
कॉल डेटामधील बाइट्स कॅशे की 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;
हे स्थिरांक अशा विशेष प्रकरणांचा अर्थ लावण्यासाठी वापरले जातात जिथे आपण सर्व माहिती प्रदान करतो आणि ती कॅशेमध्ये लिहिली जावी की नाही हे ठरवतो. कॅशेमध्ये लिहिण्यासाठी पूर्वी न वापरलेल्या स्टोरेज स्लॉट्समध्ये दोन SSTORE (opens in a new tab) ऑपरेशन्स आवश्यक असतात, ज्यासाठी प्रत्येकी 22100 गॅस खर्च येतो, त्यामुळे आपण ते ऐच्छिक बनवतो.
mapping(uint => uint) public val2key;
मूल्ये आणि त्यांच्या कीजमधील एक मॅपिंग (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's आहे. जर कॅशेची लांबी आधीपासूनच तितकी
// मोठी असेल, तर अयशस्वी (fail) करा.
// 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 बाइट्सपर्यंत, वर्ड आकार) कॉल डेटामधून मूल्य वाचते.
{
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 बाइट मूल्य वाचतो. कॉल डेटा startByte+32 च्या आधी थांबला तरीही हे कार्य करते कारण EVM मधील अनिनिशियलाइज्ड जागा शून्य मानली जाते.
_retVal = _retVal >> (256-length*8);
आपल्याला 32 बाइट मूल्याची आवश्यकता असेलच असे नाही. हे अतिरिक्त बाइट्स काढून टाकते.
return _retVal;
} // _calldataVal
// कॉल डेटा मधून _fromByte पासून सुरू होणारा एकच पॅरामीटर वाचा
function _readParam(uint _fromByte) internal
returns (uint _nextByte, uint _parameterValue)
{
कॉल डेटामधून एकच पॅरामीटर वाचा. लक्षात घ्या की आपल्याला फक्त वाचलेले मूल्यच नाही, तर पुढील बाइटचे स्थान देखील परत करणे आवश्यक आहे कारण पॅरामीटर्स 1 बाइट लांबीपासून 33 बाइट्सपर्यंत असू शकतात.
// पहिला बाइट आपल्याला उर्वरित भागाचा अर्थ कसा लावायचा हे सांगतो
uint8 _firstByte;
_firstByte = uint8(_calldataVal(_fromByte, 1));
Solidity संभाव्य धोकादायक इम्प्लिसिट टाइप कन्व्हर्जन्स (opens in a new tab) ला मनाई करून बग्सची संख्या कमी करण्याचा प्रयत्न करते. डाउनग्रेड, उदाहरणार्थ 256 बिट्सवरून 8 बिट्सपर्यंत, स्पष्ट असणे आवश्यक आहे.
// मूल्य वाचा, परंतु ते कॅशेमध्ये लिहू नका
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;
खालचे निबल (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 बिट मूल्य आहे, त्याला 16 बिट मूल्यामध्ये रूपांतरित करतो आणि पहिल्या बाइटमध्ये अतिरिक्त बाइट्सची संख्या जोडण्यासाठी लॉजिकल 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 बाइट्स, 4 बाइट्स, इ.) त्याच प्रकारे हाताळली जातात, फक्त वेगवेगळ्या फील्ड आकारांसह.
// जर आपण येथे पोहोचलो, तर काहीतरी चुकीचे आहे.
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 चा निकाल टाकून देऊ शकतो कारण आपल्याला माहित आहे की कॅशे कीज रेषीयपणे नियुक्त केल्या जातात.
}
} // 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 बिट वर्ड वाचा. हे युटिलिटी फंक्शन आपल्याला हे पडताळू देते की जेव्हा आपण कॅशे वापरणारा फंक्शन कॉल चालवतो तेव्हा आपल्याला योग्य निकाल मिळतात.
{
require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
uint256 tempUint;
assembly {
tempUint := mload(add(add(_bytes, 0x20), _start))
}
Yul uint256 च्या पलीकडे डेटा स्ट्रक्चर्सना समर्थन देत नाही, त्यामुळे जेव्हा तुम्ही मेमरी बफर _bytes सारख्या अधिक अत्याधुनिक डेटा स्ट्रक्चरचा संदर्भ देता, तेव्हा तुम्हाला त्या स्ट्रक्चरचा पत्ता मिळतो. Solidity bytes memory मूल्ये 32 बाइट वर्ड म्हणून साठवते ज्यामध्ये लांबी असते, आणि त्यानंतर वास्तविक बाइट्स असतात, त्यामुळे बाइट क्रमांक _start मिळवण्यासाठी आपल्याला _bytes+32+_start ची गणना करणे आवश्यक आहे.
return tempUint;
} // toUint256
// fourParams() साठी फंक्शन सिग्नेचर, सौजन्य
// https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
// आपल्याला योग्य मूल्ये परत मिळत आहेत हे पाहण्यासाठी फक्त काही स्थिर (constant) मूल्ये
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,
एकाच कॉन्ट्रॅक्टने कॅशे केलेली फंक्शन्स (थेट व्यवहारांमधून कॉल करण्यासाठी) आणि कॅशे न केलेली फंक्शन्स (इतर स्मार्ट कॉन्ट्रॅक्ट्समधून कॉल करण्यासाठी) या दोन्हीला समर्थन देणे उपयुक्त आहे. ते करण्यासाठी आपल्याला सर्वकाही एका fallback फंक्शनमध्ये (opens in a new tab) टाकण्याऐवजी योग्य फंक्शन कॉल करण्यासाठी Solidity यंत्रणेवर अवलंबून राहणे सुरू ठेवणे आवश्यक आहे. असे केल्याने संयोज्यता खूप सोपी होते. बहुतेक प्रकरणांमध्ये फंक्शन ओळखण्यासाठी एकच बाइट पुरेसा असेल, त्यामुळे आपण तीन बाइट्स (16*3=48 गॅस) वाया घालवत आहोत. तथापि, मी हे लिहित असताना त्या 48 गॅसची किंमत 0.07 सेंट्स आहे, जी सोप्या, कमी बग प्रवण कोडची वाजवी किंमत आहे.
// पहिले मूल्य, ते कॅशेमध्ये जोडा
cache.INTO_CACHE(),
bytes32(VAL_A),
पहिले मूल्य: एक फ्लॅग जो सांगतो की हे एक पूर्ण मूल्य आहे जे कॅशेमध्ये लिहिणे आवश्यक आहे, आणि त्यानंतर मूल्याचे 32 बाइट्स. इतर तीन मूल्ये समान आहेत, फक्त 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
// अत्यंत लहान बफरसह आपल्याला पूर्ववत करणे (revert) मिळते याची चाचणी करा
function testShortCalldata() public {
पुरेसे पॅरामीटर्स नसलेल्या असामान्य प्रकरणात काय होते ते तपासा.
.
.
.
(_success, _callOutput) = _cacheAddr.call(_callInput);
assertEq(_success, false);
} // testShortCalldata
ते पूर्ववत होत असल्याने, आपल्याला मिळणारा निकाल false असावा.
// तिथे नसलेल्या कॅशे कीजसह कॉल करा
function testNoCacheKey() public {
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
// पहिले मूल्य, ते कॅशेमध्ये जोडा
cache.INTO_CACHE(),
bytes32(VAL_A),
// दुसरे मूल्य
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,
// पहिले मूल्य, ते कॅशेमध्ये जोडा
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 वापरण्याचा सर्वात सोपा मार्ग म्हणजे ते आपल्या स्वतःच्या कॉन्ट्रॅक्टमध्ये इनहेरिट करणे.
function writeEntryCached() external {
uint[] memory params = _readParams(2);
writeEntry(params[0], params[1]);
} // writeEntryCached
हे फंक्शन वरील CacheTest मधील fourParam सारखेच आहे. आपण ABI वैशिष्ट्यांचे पालन करत नसल्यामुळे, फंक्शनमध्ये कोणतेही पॅरामीटर्स घोषित न करणे चांगले.
// आम्हाला कॉल करणे सोपे करा
// writeEntryCached() साठी फंक्शन सिग्नेचर, सौजन्य
// https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;
writeEntryCached ला कॉल करणाऱ्या बाह्य कोडला worm.writeEntryCached वापरण्याऐवजी मॅन्युअली कॉल डेटा तयार करावा लागेल, कारण आपण ABI वैशिष्ट्यांचे पालन करत नाही. हे स्थिर मूल्य असण्याने ते लिहिणे सोपे होते.
लक्षात घ्या की जरी आपण WRITE_ENTRY_CACHED ला स्थिती व्हेरिएबल म्हणून परिभाषित केले असले तरी, ते बाह्यरित्या वाचण्यासाठी त्याचे गेटर फंक्शन, worm.WRITE_ENTRY_CACHED() वापरणे आवश्यक आहे.
function readEntry(uint key) public view
returns (uint _value, address _writtenBy, uint _writtenAtBlock)
रीड फंक्शन एक view आहे, त्यामुळे त्याला व्यवहाराची आवश्यकता नसते आणि गॅस खर्च होत नाही. परिणामी, पॅरामीटरसाठी कॅशे वापरण्याचा कोणताही फायदा नाही. व्ह्यू फंक्शन्ससह मानक यंत्रणा वापरणे सर्वोत्तम आहे जी सोपी आहे.
चाचणी कोड
हा कॉन्ट्रॅक्टसाठी चाचणी कोड आहे (opens in a new tab). पुन्हा, आपण फक्त मनोरंजक गोष्टींकडे पाहूया.
function testWReadWrite() public {
worm.writeEntry(0xDEAD, 0x60A7);
vm.expectRevert(bytes("entry already written"));
worm.writeEntry(0xDEAD, 0xBEEF);
पुढील कॉल अयशस्वी झाला पाहिजे आणि अपयशाचे नोंदवलेले कारण काय असावे हे आपण Foundry चाचणीमध्ये अशा प्रकारे (vm.expectRevert) (opens in a new tab) निर्दिष्ट करतो. जेव्हा आपण कॉल डेटा तयार करण्याऐवजी आणि लो लेव्हल इंटरफेस (<contract>.call(), इ.) वापरून कॉन्ट्रॅक्टला कॉल करण्याऐवजी <contract>.<function name>() सिंटॅक्स वापरतो तेव्हा हे लागू होते.
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);
अशा प्रकारे आपण Foundry मध्ये कोड घटना योग्यरित्या उत्सर्जित करतो (opens in a new tab) याची पडताळणी करतो.
क्लायंट
Solidity चाचण्यांसह तुम्हाला एक गोष्ट मिळत नाही ती म्हणजे JavaScript कोड जो तुम्ही कट करून तुमच्या स्वतःच्या ॲप्लिकेशनमध्ये पेस्ट करू शकता. या ट्युटोरिअलच्या मूळ आवृत्तीने WORM ला ऑप्टिमिझम् गोर्ली वर डिप्लॉय केले होते, जे तेव्हापासून निवृत्त (retired) झाले आहे. आज क्लायंट चालवण्यासाठी, WORM ला OP Sepolia (opens in a new tab) सारख्या समर्थित OP Stack नेटवर्कवर पुन्हा डिप्लॉय करा, आणि नंतर परिणामी कॉन्ट्रॅक्ट पत्ता JavaScript क्लायंटमध्ये वापरा.
तुम्ही क्लायंटसाठी JavaScript कोड येथे पाहू शकता (opens in a new tab). नमुना रिपॉझिटरी ऑप्टिमिझम् गोर्ली साठी लिहिली गेली होती, त्यामुळे ती चालवण्यापूर्वी, तुमच्या लक्ष्यित नेटवर्कसाठी javascript/.env.example आणि javascript/index.js मधील RPC एंडपॉइंट आणि एक्सप्लोरर URLs अपडेट करा. ते वापरण्यासाठी:
-
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 ज्या नेटवर्कवर तुम्ही WORM पुन्हा डिप्लॉय करता त्या नेटवर्कसाठी RPC URL. OP Sepolia साठी, https://sepolia.optimism.ioसारखा OP Sepolia RPC एंडपॉइंट वापरा, किंवा तुमच्या प्रोव्हायडरकडून दुसरा एंडपॉइंट वापरा. -
index.jsचालवा.node index.jsहे नमुना ॲप्लिकेशन प्रथम WORM मध्ये एक नोंद लिहिते, कॉल डेटा आणि ब्लॉक एक्सप्लोरर वरील व्यवहाराची लिंक प्रदर्शित करते. नंतर ते ती नोंद परत वाचते, आणि ती वापरत असलेली की आणि नोंदीमधील मूल्ये (मूल्य, ब्लॉक क्रमांक, आणि लेखक) प्रदर्शित करते.
बहुतेक क्लायंट सामान्य 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 ला कॉल डेटा हेक्स स्ट्रिंग असणे अपेक्षित आहे, 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 फंक्शन्ससह पॅरामीटर कॅशिंग वापरण्याची आवश्यकता नाही.
निष्कर्ष
या लेखामधील कोड एक प्रुफ ऑफ कन्सेप्ट आहे, ज्याचा उद्देश कल्पना समजण्यास सोपी करणे हा आहे. उत्पादन-सज्ज प्रणालीसाठी तुम्हाला काही अतिरिक्त कार्यक्षमता लागू करायची असेल:
-
uint256नसलेली मूल्ये हाताळा. उदाहरणार्थ, स्ट्रिंग्ज. -
ग्लोबल कॅशेऐवजी, कदाचित वापरकर्ते आणि कॅशे यांच्यात मॅपिंग असावे. वेगवेगळे वापरकर्ते वेगवेगळी मूल्ये वापरतात.
-
पत्त्यांसाठी वापरलेली मूल्ये इतर उद्देशांसाठी वापरल्या जाणाऱ्या मूल्यांपेक्षा वेगळी असतात. फक्त पत्त्यांसाठी वेगळा कॅशे असणे अर्थपूर्ण ठरू शकते.
-
सध्या, कॅशे कीज "प्रथम येणाऱ्यास, सर्वात लहान की" अल्गोरिदमवर आहेत. पहिली सोळा मूल्ये एकच बाइट म्हणून पाठवली जाऊ शकतात. पुढील 4080 मूल्ये दोन बाइट्स म्हणून पाठवली जाऊ शकतात. पुढील अंदाजे दशलक्ष मूल्ये तीन बाइट्स आहेत, इ. उत्पादन प्रणालीने कॅशे नोंदींवर वापर काउंटर्स ठेवले पाहिजेत आणि त्यांची पुनर्रचना केली पाहिजे जेणेकरून सोळा सर्वात सामान्य मूल्ये एक बाइट असतील, पुढील 4080 सर्वात सामान्य मूल्ये दोन बाइट्स असतील, इ.
तथापि, हे संभाव्यतः धोकादायक ऑपरेशन आहे. खालील घटनाक्रमाची कल्पना करा:
-
नोम नेव्ह (Noam Naive) ज्या पत्त्यावर टोकन्स पाठवू इच्छितो तो पत्ता एन्कोड करण्यासाठी
encodeValला कॉल करतो. तो पत्ता ॲप्लिकेशनवर वापरल्या गेलेल्या पहिल्या पत्त्यांपैकी एक आहे, त्यामुळे एन्कोड केलेले मूल्य 0x06 आहे. हे एकviewफंक्शन आहे, व्यवहार नाही, त्यामुळे ते नोम आणि तो वापरत असलेल्या नोडच्या दरम्यान आहे, आणि इतर कोणालाही त्याबद्दल माहिती नाही -
ओवेन ओनर (Owen Owner) कॅशे रिऑर्डरिंग ऑपरेशन चालवतो. खूप कमी लोक प्रत्यक्षात तो पत्ता वापरतात, त्यामुळे तो आता 0x201122 म्हणून एन्कोड केला आहे. एक वेगळे मूल्य, 1018, 0x06 ला नियुक्त केले आहे.
-
नोम नेव्ह त्याचे टोकन्स 0x06 वर पाठवतो. ते
0x0000000000000000000000000de0b6b3a7640000या पत्त्यावर जातात, आणि कोणालाही त्या पत्त्याची खाजगी की माहित नसल्यामुळे, ते तिथेच अडकून राहतात. नोम खूश नाही.
ही समस्या सोडवण्याचे मार्ग आहेत, आणि कॅशे रिऑर्डर दरम्यान मेमपूलमध्ये असलेल्या व्यवहारांची संबंधित समस्या सोडवण्याचे मार्ग आहेत, परंतु तुम्हाला याची जाणीव असणे आवश्यक आहे.
-
मी येथे ऑप्टिमिझम् सह कॅशिंगचे प्रात्यक्षिक दाखवले, कारण मी ऑप्टिमिझम् चा कर्मचारी आहे आणि हा रोलअप मला सर्वात चांगला माहित आहे. परंतु हे कोणत्याही रोलअपसह कार्य केले पाहिजे जे अंतर्गत प्रक्रियेसाठी किमान खर्च आकारते, जेणेकरून तुलनेत स्तर १ (l1) वर व्यवहार डेटा लिहिणे हा प्रमुख खर्च असतो.