प्रमुख मजकुराकडे जा

तुम्ही कॅशे करू शकता ते सर्व

स्तर 2
कॅशिंग
स्टोरेज
मध्यम
Ori Pomerantz
१५ सप्टेंबर, २०२२
21 मिनिट वाचन

रोलअप वापरताना, व्यवहारातील एका बाइटची किंमत स्टोरेज स्लॉटच्या किंमतीपेक्षा खूप जास्त असते. म्हणून, शक्य तितकी माहिती ऑनचेन कॅशे करणे अर्थपूर्ण आहे.

या लेखात, तुम्ही कॅशिंग करार कसा तयार करायचा आणि वापरायचा हे शिकाल. ज्या पॅरामीटर व्हॅल्यूचा अनेक वेळा वापर होण्याची शक्यता आहे, ती कॅशे केली जाईल आणि (पहिल्या वापरानंतर) कमी बाइट्ससह वापरासाठी उपलब्ध होईल. तसेच, हा कॅशे वापरणारा ऑफचेन कोड कसा लिहायचा हेही शिकाल.

तुम्हाला हा लेख वगळून थेट सोर्स कोड बघायचा असेल, तर तो इथे आहे (opens in a new tab). डेव्हलपमेंट स्टॅक Foundry (opens in a new tab) आहे.

एकूण डिझाइन

सोपेपणासाठी, आपण असे गृहीत धरू की सर्व व्यवहार पॅरामीटर्स uint256 आहेत, ज्यांची लांबी 32 बाइट्स आहे. जेव्हा आपल्याला व्यवहार प्राप्त होतो, तेव्हा आपण प्रत्येक पॅरामीटरचे अशा प्रकारे विश्लेषण करू:

  1. जर पहिला बाइट 0xFF असेल, तर पुढील 32 बाइट्स पॅरामीटर व्हॅल्यू म्हणून घ्या आणि कॅशेमध्ये लिहा.

  2. जर पहिला बाइट 0xFE असेल, तर पुढील 32 बाइट्स पॅरामीटर व्हॅल्यू म्हणून घ्या, परंतु कॅशेमध्ये लिहू नका.

  3. इतर कोणत्याही व्हॅल्यूसाठी, शीर्ष चार बिट्स अतिरिक्त बाइट्सची संख्या म्हणून घ्या आणि खालचे चार बिट्स कॅशे कीचे सर्वात महत्त्वपूर्ण बिट्स म्हणून घ्या. येथे काही उदाहरणे आहेत:

    कॉलडेटामधील बाइट्सकॅशे की
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

कॅशे मॅनिप्युलेशन

कॅशे Cache.sol (opens in a new tab) मध्ये इम्प्लिमेंट केले आहे. चला ओळीनुसार पाहूया.

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4
5contract Cache {
6
7 bytes1 public constant INTO_CACHE = 0xFF;
8 bytes1 public constant DONT_CACHE = 0xFE;

हे कॉन्स्टंट्स विशेष प्रकरणांचे अर्थ लावण्यासाठी वापरले जातात, जिथे आपण सर्व माहिती प्रदान करतो आणि ती कॅशेमध्ये लिहावी की नाही हे ठरवतो. कॅशेमध्ये लिहिण्यासाठी पूर्वी न वापरलेल्या स्टोरेज स्लॉटमध्ये प्रत्येकी 22100 गॅसच्या दराने दोन SSTORE (opens in a new tab) ऑपरेशन्सची आवश्यकता असते, म्हणून आपण ते ऐच्छिक ठेवतो.

1
2 mapping(uint => uint) public val2key;

व्हॅल्यूज आणि त्यांच्या कीजमधील मॅपिंग (opens in a new tab). तुम्ही व्यवहार पाठवण्यापूर्वी व्हॅल्यूज एन्कोड करण्यासाठी ही माहिती आवश्यक आहे.

1 // लोकेशन n मध्ये की n+1 साठी व्हॅल्यू आहे, कारण आपल्याला
2 // शून्य "कॅशेमध्ये नाही" म्हणून जतन करणे आवश्यक आहे.
3 uint[] public key2val;

आपण कीज ते व्हॅल्यूजच्या मॅपिंगसाठी ॲरे वापरू शकतो कारण आपण कीज नियुक्त करतो आणि सोपेपणासाठी आपण ते क्रमाने करतो.

1 function cacheRead(uint _key) public view returns (uint) {
2 require(_key <= key2val.length, "Reading uninitialize cache entry");
3 return key2val[_key-1];
4 } // cacheRead

कॅशेमधून व्हॅल्यू वाचा.

1 // एखादी व्हॅल्यू कॅशेमध्ये आधीच नसल्यास ती लिहा
2 // चाचणी काम करण्यासाठी फक्त पब्लिक
3 function cacheWrite(uint _value) public returns (uint) {
4 // जर व्हॅल्यू आधीच कॅशेमध्ये असेल, तर सध्याची की परत करा
5 if (val2key[_value] != 0) {
6 return val2key[_value];
7 }

एकच व्हॅल्यू एकापेक्षा जास्त वेळा कॅशेमध्ये टाकण्यात काहीच अर्थ नाही. जर व्हॅल्यू आधीच तिथे असेल, तर फक्त अस्तित्वात असलेली की परत करा.

1 // 0xFE हा एक विशेष केस असल्यामुळे, कॅशेमध्ये ठेवता येणारी सर्वात मोठी की
2 // 0x0D आणि त्यानंतर 15 0xFF's आहे. जर कॅशेची लांबी आधीच इतकी
3 // मोठी असेल, तर फेल करा.
4 // 1 2 3 4 5 6 7 8 9 A B C D E F
5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
6 "cache overflow");

मला नाही वाटत की आपल्याला इतका मोठा कॅशे कधी मिळेल (अंदाजे 1.8*1037 नोंदी, ज्यासाठी सुमारे 1027 टीबी स्टोरेज लागेल). तथापि, "640kB नेहमीच पुरेसे असेल" (opens in a new tab) हे आठवण्याइतका मी जुना आहे. ही चाचणी खूपच स्वस्त आहे.

1 // पुढील की वापरून व्हॅल्यू लिहा
2 val2key[_value] = key2val.length+1;

रिव्हर्स लुकअप (व्हॅल्यूपासून कीपर्यंत) जोडा.

1 key2val.push(_value);

फॉरवर्ड लुकअप (कीपासून व्हॅल्यूपर्यंत) जोडा. कारण आपण व्हॅल्यूज क्रमाने नियुक्त करतो, आपण ते फक्त शेवटच्या ॲरे व्हॅल्यूनंतर जोडू शकतो.

1 return key2val.length;
2 } // cacheWrite

key2val ची नवीन लांबी परत करा, जो तो सेल आहे जिथे नवीन व्हॅल्यू संग्रहित केली आहे.

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

हे फंक्शन कॉलडेटामधून अनियंत्रित लांबीची (32 बाइट्सपर्यंत, वर्ड साइज) व्हॅल्यू वाचते.

1 {
2 uint _retVal;
3
4 require(length < 0x21,
5 "_calldataVal length limit is 32 bytes");
6 require(length + startByte <= msg.data.length,
7 "_calldataVal trying to read beyond calldatasize");

हे फंक्शन इंटरनल आहे, त्यामुळे जर उर्वरित कोड योग्यरित्या लिहिला असेल, तर या चाचण्यांची आवश्यकता नाही. तथापि, त्यांची किंमत जास्त नाही म्हणून त्या असण्यात काही हरकत नाही.

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

हा कोड Yul (opens in a new tab) मध्ये आहे. हे कॉलडेटामधून 32 बाइट व्हॅल्यू वाचते. जर कॉलडेटा startByte+32 च्या आधी थांबला तरीही हे कार्य करते कारण EVM मधील अनइनिशियलाइज्ड स्पेस शून्य मानली जाते.

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

आम्हाला 32 बाइट व्हॅल्यूच हवी आहे असे नाही. यामुळे अतिरिक्त बाइट्स काढून टाकले जातात.

1 return _retVal;
2 } // _calldataVal
3
4
5 // _fromByte पासून सुरू होणाऱ्या कॉलडेटामधून एकच पॅरामीटर वाचा
6 function _readParam(uint _fromByte) internal
7 returns (uint _nextByte, uint _parameterValue)
8 {

कॉलडेटामधून एकच पॅरामीटर वाचा. लक्षात ठेवा की आम्हाला केवळ आपण वाचलेली व्हॅल्यूच नाही, तर पुढील बाइटचे स्थान देखील परत करणे आवश्यक आहे कारण पॅरामीटर्स 1 बाइट ते 33 बाइट्स लांब असू शकतात.

1 // पहिला बाइट आपल्याला बाकीच्यांचा अर्थ कसा लावायचा हे सांगतो
2 uint8 _firstByte;
3
4 _firstByte = uint8(_calldataVal(_fromByte, 1));

Solidity संभाव्य धोकादायक इंप्लिसिट टाइप कन्व्हर्शन्स (opens in a new tab) प्रतिबंधित करून बग्सची संख्या कमी करण्याचा प्रयत्न करते. डाउनग्रेड, उदाहरणार्थ 256 बिट्सवरून 8 बिट्सपर्यंत, स्पष्टपणे नमूद करणे आवश्यक आहे.

1
2 // व्हॅल्यू वाचा, पण कॅशेमध्ये लिहू नका
3 if (_firstByte == uint8(DONT_CACHE))
4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));
5
6 // व्हॅल्यू वाचा आणि कॅशेमध्ये लिहा
7 if (_firstByte == uint8(INTO_CACHE)) {
8 uint _param = _calldataVal(_fromByte+1, 32);
9 cacheWrite(_param);
10 return(_fromByte+33, _param);
11 }
12
13 // जर आपण इथे पोहोचलो तर याचा अर्थ आपल्याला कॅशेमधून वाचण्याची गरज आहे
14
15 // वाचण्यासाठी अतिरिक्त बाइट्सची संख्या
16 uint8 _extraBytes = _firstByte / 16;
सर्व दाखवा

खालचा निबल (opens in a new tab) घ्या आणि कॅशेमधून व्हॅल्यू वाचण्यासाठी इतर बाइट्ससह एकत्र करा.

1 uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
2 _calldataVal(_fromByte+1, _extraBytes);
3
4 return (_fromByte+_extraBytes+1, cacheRead(_key));
5
6 } // _readParam
7
8
9 // n पॅरामीटर्स वाचा (फंक्शन्सना किती पॅरामीटर्स अपेक्षित आहेत हे माहीत असते)
10 function _readParams(uint _paramNum) internal returns (uint[] memory) {
सर्व दाखवा

आपण आपल्याकडे असलेल्या पॅरामीटर्सची संख्या कॉलडेटामधूनच मिळवू शकलो असतो, परंतु आपल्याला कॉल करणार्‍या फंक्शन्सना किती पॅरामीटर्स अपेक्षित आहेत हे माहीत असते. त्यांनाच आपल्याला सांगू देणे सोपे आहे.

1 // आपण वाचलेले पॅरामीटर्स
2 uint[] memory params = new uint[](_paramNum);
3
4 // पॅरामीटर्स बाइट 4 पासून सुरू होतात, त्याआधी फंक्शन सिग्नेचर असते
5 uint _atByte = 4;
6
7 for(uint i=0; i<_paramNum; i++) {
8 (_atByte, params[i]) = _readParam(_atByte);
9 }
सर्व दाखवा

आपल्याला आवश्यक असलेली संख्या मिळेपर्यंत पॅरामीटर्स वाचा. जर आपण कॉलडेटाच्या पुढे गेलो, तर _readParams कॉल रिव्हर्ट करेल.

1
2 return(params);
3 } // readParams
4
5 // _readParams च्या चाचणीसाठी, चार पॅरामीटर्स वाचण्याची चाचणी
6 function fourParam() public
7 returns (uint256,uint256,uint256,uint256)
8 {
9 uint[] memory params;
10 params = _readParams(4);
11 return (params[0], params[1], params[2], params[3]);
12 } // fourParam
सर्व दाखवा

Foundry चा एक मोठा फायदा हा आहे की ते Solidity मध्ये चाचण्या लिहिण्याची परवानगी देते (खालील कॅशेची चाचणी पहा). यामुळे युनिट टेस्ट्स खूप सोप्या होतात. हे एक फंक्शन आहे जे चार पॅरामीटर्स वाचते आणि त्यांना परत करते जेणेकरून चाचणी ते योग्य होते की नाही हे तपासू शकेल.

1 // एक व्हॅल्यू मिळवा, असे बाइट्स परत करा जे ते एन्कोड करतील (शक्य असल्यास कॅशे वापरून)
2 function encodeVal(uint _val) public view returns(bytes memory) {

encodeVal हे एक फंक्शन आहे जे ऑफचेन कोड कॅशे वापरणारा कॉलडेटा तयार करण्यात मदत करण्यासाठी कॉल करते. हे एकच व्हॅल्यू प्राप्त करते आणि ते एन्कोड करणारे बाइट्स परत करते. हे फंक्शन view आहे, त्यामुळे त्याला व्यवहाराची आवश्यकता नाही आणि बाहेरून कॉल केल्यावर कोणताही गॅस लागत नाही.

1 uint _key = val2key[_val];
2
3 // व्हॅल्यू अजून कॅशेमध्ये नाही, ती जोडा
4 if (_key == 0)
5 return bytes.concat(INTO_CACHE, bytes32(_val));

EVM मध्ये सर्व अनइनिशियलाइज्ड स्टोरेज शून्य मानले जाते. त्यामुळे जर आपण अशा व्हॅल्यूसाठी की शोधली जी तिथे नाही, तर आपल्याला शून्य मिळतो. त्या बाबतीत, ते एन्कोड करणारे बाइट्स INTO_CACHE (जेणेकरून पुढच्या वेळी ते कॅशे केले जाईल) असतात, त्यानंतर वास्तविक व्हॅल्यू असते.

1 // जर की <0x10 असेल, तर ती एकच बाइट म्हणून परत करा
2 if (_key < 0x10)
3 return bytes.concat(bytes1(uint8(_key)));

सिंगल बाइट्स सर्वात सोपे आहेत. आपण bytes<n> प्रकाराला बाइट ॲरेमध्ये बदलण्यासाठी bytes.concat (opens in a new tab) वापरतो, ज्याची कोणतीही लांबी असू शकते. नाव असूनही, फक्त एक युक्तिवाद दिल्यावर ते व्यवस्थित काम करते.

1 // दोन बाइट व्हॅल्यू, 0x1vvv म्हणून एन्कोड केलेली
2 if (_key < 0x1000)
3 return bytes.concat(bytes2(uint16(_key) | 0x1000));

जेव्हा आपल्याकडे 163 पेक्षा कमी असलेली की असते, तेव्हा आपण ती दोन बाइट्समध्ये व्यक्त करू शकतो. आपण आधी _key, जे 256 बिट व्हॅल्यू आहे, ते 16 बिट व्हॅल्यूमध्ये रूपांतरित करतो आणि पहिल्या बाइटमध्ये अतिरिक्त बाइट्सची संख्या जोडण्यासाठी लॉजिकल ऑर वापरतो. मग आपण ते bytes2 व्हॅल्यूमध्ये टाकतो, जे bytes मध्ये रूपांतरित केले जाऊ शकते.

1 // खालील ओळी लूप म्हणून करण्याचा कदाचित एक हुशार मार्ग आहे,
2 // पण हे एक व्ह्यू फंक्शन आहे म्हणून मी प्रोग्रामरच्या वेळेसाठी आणि
3 // सोपेपणासाठी ऑप्टिमाइझ करत आहे.
4
5 if (_key < 16*256**2)
6 return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));
7 if (_key < 16*256**3)
8 return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));
9 .
10 .
11 .
12 if (_key < 16*256**14)
13 return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));
14 if (_key < 16*256**15)
15 return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));
सर्व दाखवा

इतर व्हॅल्यूज (3 बाइट्स, 4 बाइट्स, इत्यादी) त्याच प्रकारे हाताळल्या जातात, फक्त फील्ड आकार भिन्न असतात.

1 // जर आपण इथे पोहोचलो, तर काहीतरी चुकले आहे.
2 revert("Error in encodeVal, should not happen");

जर आपण इथे पोहोचलो तर याचा अर्थ आपल्याला एक की मिळाली आहे जी 16*25615 पेक्षा कमी नाही. परंतु cacheWrite कीज मर्यादित करते त्यामुळे आपण 14*25616 पर्यंत पोहोचू शकत नाही (ज्याचा पहिला बाइट 0xFE असेल, म्हणून ते DONT_CACHE सारखे दिसेल). परंतु भविष्यात एखादा प्रोग्रामर बग आणल्यास चाचणी जोडण्यासाठी आपल्याला जास्त खर्च येत नाही.

1 } // encodeVal
2
3} // Cache

कॅशेची चाचणी

Foundry चा एक फायदा हा आहे की ते तुम्हाला Solidity मध्ये चाचण्या लिहिण्याची परवानगी देते (opens in a new tab), ज्यामुळे युनिट चाचण्या लिहिणे सोपे होते. Cache क्लाससाठीच्या चाचण्या येथे (opens in a new tab) आहेत. कारण चाचणी कोड पुनरावृत्तीचा आहे, जसे चाचण्या असतात, हा लेख फक्त मनोरंजक भाग स्पष्ट करतो.

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4import "forge-std/Test.sol";
5
6
7// कन्सोलसाठी `forge test -vv` चालवणे आवश्यक आहे.
8import "forge-std/console.sol";

हे फक्त बॉयलरप्लेट आहे जे चाचणी पॅकेज आणि console.log वापरण्यासाठी आवश्यक आहे.

1import "src/Cache.sol";

आपण ज्या कराराची चाचणी घेत आहोत ते आपल्याला माहित असणे आवश्यक आहे.

1contract CacheTest is Test {
2 Cache cache;
3
4 function setUp() public {
5 cache = new Cache();
6 }

setUp फंक्शन प्रत्येक चाचणीपूर्वी कॉल केले जाते. या प्रकरणात आपण फक्त एक नवीन कॅशे तयार करतो, जेणेकरून आपल्या चाचण्या एकमेकांवर परिणाम करणार नाहीत.

1 function testCaching() public {

चाचण्या ही अशी फंक्शन्स आहेत ज्यांची नावे test ने सुरू होतात. हे फंक्शन मूलभूत कॅशे कार्यक्षमता तपासते, व्हॅल्यूज लिहिणे आणि त्या पुन्हा वाचणे.

1 for(uint i=1; i<5000; i++) {
2 cache.cacheWrite(i*i);
3 }
4
5 for(uint i=1; i<5000; i++) {
6 assertEq(cache.cacheRead(i), i*i);

assert... फंक्शन्स (opens in a new tab) वापरून तुम्ही प्रत्यक्ष चाचणी कशी करता हे येथे दिले आहे. या प्रकरणात, आम्ही तपासतो की आम्ही लिहिलेली व्हॅल्यू हीच आम्ही वाचलेली व्हॅल्यू आहे. आपण cache.cacheWrite चा निकाल सोडून देऊ शकतो कारण आपल्याला माहित आहे की कॅशे की रेषीयपणे नियुक्त केल्या जातात.

1 }
2 } // testCaching
3
4
5 // एकाच व्हॅल्यूला अनेक वेळा कॅशे करा, की तीच
6 // राहील याची खात्री करा
7 function testRepeatCaching() public {
8 for(uint i=1; i<100; i++) {
9 uint _key1 = cache.cacheWrite(i);
10 uint _key2 = cache.cacheWrite(i);
11 assertEq(_key1, _key2);
12 }
सर्व दाखवा

प्रथम आम्ही प्रत्येक व्हॅल्यू कॅशेमध्ये दोनदा लिहितो आणि कीज समान असल्याची खात्री करतो (म्हणजे दुसरे लिखाण खरोखरच झाले नाही).

1 for(uint i=1; i<100; i+=3) {
2 uint _key = cache.cacheWrite(i);
3 assertEq(_key, i);
4 }
5 } // testRepeatCaching

सिद्धांतानुसार, एक बग असू शकतो जो सलग कॅशे लेखनावर परिणाम करत नाही. म्हणून येथे आम्ही काही असे लेखन करतो जे सलग नाहीत आणि पाहतो की व्हॅल्यूज अद्याप पुन्हा लिहिल्या जात नाहीत.

1 // मेमरी बफरमधून एक uint वाचा (आपण पाठवलेले पॅरामीटर्स
2 // परत मिळतात याची खात्री करण्यासाठी)
3 function toUint256(bytes memory _bytes, uint256 _start) internal pure
4 returns (uint256)

bytes memory बफरमधून 256 बिट वर्ड वाचा. हे युटिलिटी फंक्शन आम्हाला कॅशे वापरणारे फंक्शन कॉल चालवल्यावर योग्य परिणाम मिळतात की नाही हे सत्यापित करू देते.

1 {
2 require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
3 uint256 tempUint;
4
5 assembly {
6 tempUint := mload(add(add(_bytes, 0x20), _start))
7 }

Yul uint256 च्या पलीकडे डेटा स्ट्रक्चर्सला समर्थन देत नाही, म्हणून जेव्हा तुम्ही अधिक अत्याधुनिक डेटा स्ट्रक्चरचा संदर्भ देता, जसे की मेमरी बफर _bytes, तेव्हा तुम्हाला त्या स्ट्रक्चरचा ॲड्रेस मिळतो. Solidity bytes memory व्हॅल्यूज 32 बाइट वर्ड म्हणून संग्रहित करते ज्यात लांबी असते, त्यानंतर वास्तविक बाइट्स असतात, त्यामुळे बाइट क्रमांक _start मिळवण्यासाठी आपल्याला _bytes+32+_start मोजणे आवश्यक आहे.

1
2 return tempUint;
3 } // toUint256
4
5 // fourParams() साठी फंक्शन सिग्नेचर, सौजन्याने
6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
8
9 // आपल्याला योग्य व्हॅल्यूज परत मिळत आहेत हे पाहण्यासाठी फक्त काही कॉन्स्टंट व्हॅल्यूज
10 uint256 constant VAL_A = 0xDEAD60A7;
11 uint256 constant VAL_B = 0xBEEF;
12 uint256 constant VAL_C = 0x600D;
13 uint256 constant VAL_D = 0x600D60A7;
सर्व दाखवा

चाचणीसाठी आपल्याला आवश्यक असलेले काही कॉन्स्टंट्स.

1 function testReadParam() public {

readParams वापरणारे फंक्शन fourParams() ला कॉल करा, जेणेकरून आपण पॅरामीटर्स योग्यरित्या वाचू शकतो की नाही हे तपासता येईल.

1 address _cacheAddr = address(cache);
2 bool _success;
3 bytes memory _callInput;
4 bytes memory _callOutput;

आपण कॅशे वापरून फंक्शन कॉल करण्यासाठी सामान्य ABI यंत्रणा वापरू शकत नाही, म्हणून आपल्याला निम्न स्तरावरील <address>.call() (opens in a new tab) यंत्रणा वापरण्याची आवश्यकता आहे. ती यंत्रणा इनपुट म्हणून bytes memory घेते आणि ते (तसेच बुलियन व्हॅल्यू) आउटपुट म्हणून परत करते.

1 // पहिला कॉल, कॅशे रिकामा आहे
2 _callInput = bytes.concat(
3 FOUR_PARAMS,

एकाच करारासाठी कॅश्ड फंक्शन्स (थेट व्यवहारांमधून कॉल करण्यासाठी) आणि नॉन-कॅश्ड फंक्शन्स (इतर स्मार्ट करारांमधून कॉल करण्यासाठी) दोन्हीला समर्थन देणे उपयुक्त आहे. हे करण्यासाठी, आपल्याला सर्व काही एका fallback फंक्शनमध्ये (opens in a new tab) टाकण्याऐवजी, योग्य फंक्शनला कॉल करण्यासाठी Solidity यंत्रणेवर अवलंबून राहणे आवश्यक आहे. हे केल्याने कंपोझेबिलिटी खूप सोपी होते. बहुतेक प्रकरणांमध्ये फंक्शन ओळखण्यासाठी एकच बाइट पुरेसा असेल, म्हणून आपण तीन बाइट्स (16*3=48 गॅस) वाया घालवत आहोत. तथापि, मी हे लिहित असताना त्या 48 गॅसची किंमत 0.07 सेंट आहे, जी सोप्या, कमी बग प्रोन कोडसाठी वाजवी किंमत आहे.

1 // पहिली व्हॅल्यू, ती कॅशेमध्ये जोडा
2 cache.INTO_CACHE(),
3 bytes32(VAL_A),

पहिली व्हॅल्यू: एक फ्लॅग जो सांगतो की ही एक पूर्ण व्हॅल्यू आहे जी कॅशेमध्ये लिहिली पाहिजे, त्यानंतर व्हॅल्यूचे 32 बाइट्स. इतर तीन व्हॅल्यूज सारख्याच आहेत, फक्त VAL_B कॅशेमध्ये लिहिलेली नाही आणि VAL_C तिसरा आणि चौथा दोन्ही पॅरामीटर आहे.

1 .
2 .
3 .
4 );
5 (_success, _callOutput) = _cacheAddr.call(_callInput);

येथेच आपण प्रत्यक्षात Cache कराराला कॉल करतो.

1 assertEq(_success, true);

आम्ही अपेक्षा करतो की कॉल यशस्वी होईल.

1 assertEq(cache.cacheRead(1), VAL_A);
2 assertEq(cache.cacheRead(2), VAL_C);

आपण रिकाम्या कॅशेने सुरुवात करतो आणि नंतर VAL_A आणि त्यानंतर VAL_C जोडतो. आम्ही अपेक्षा करतो की पहिल्याची की 1 असेल आणि दुसऱ्याची 2 असेल.

1 assertEq(toUint256(_callOutput,0), VAL_A);
2 assertEq(toUint256(_callOutput,32), VAL_B);
3 assertEq(toUint256(_callOutput,64), VAL_C);
4 assertEq(toUint256(_callOutput,96), VAL_C);

आउटपुट हे चार पॅरामीटर्स आहेत. येथे आम्ही ते योग्य असल्याची पडताळणी करतो.

1 // दुसरा कॉल, आपण कॅशे वापरू शकतो
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // कॅशेमधील पहिली व्हॅल्यू
6 bytes1(0x01),

16 पेक्षा कमी असलेल्या कॅशे की फक्त एक बाइटच्या असतात.

1 // दुसरी व्हॅल्यू, ती कॅशेमध्ये जोडू नका
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // तिसरी आणि चौथी व्हॅल्यू, समान व्हॅल्यू
6 bytes1(0x02),
7 bytes1(0x02)
8 );
9 .
10 .
11 .
12 } // testReadParam
सर्व दाखवा

कॉलनंतरच्या चाचण्या पहिल्या कॉलनंतरच्या चाचण्यांसारख्याच आहेत.

1 function testEncodeVal() public {

हे फंक्शन testReadParam सारखेच आहे, फक्त फरक एवढाच की पॅरामीटर्स स्पष्टपणे लिहिण्याऐवजी आपण encodeVal() वापरतो.

1 .
2 .
3 .
4 _callInput = bytes.concat(
5 FOUR_PARAMS,
6 cache.encodeVal(VAL_A),
7 cache.encodeVal(VAL_B),
8 cache.encodeVal(VAL_C),
9 cache.encodeVal(VAL_D)
10 );
11 .
12 .
13 .
14 assertEq(_callInput.length, 4+1*4);
15 } // testEncodeVal
सर्व दाखवा

testEncodeVal() मधील एकमेव अतिरिक्त चाचणी म्हणजे _callInput ची लांबी योग्य आहे की नाही हे तपासणे. पहिल्या कॉलसाठी ते 4+33*4 आहे. दुसऱ्यासाठी, जिथे प्रत्येक व्हॅल्यू आधीच कॅशेमध्ये आहे, ते 4+1*4 आहे.

1 // जेव्हा की एका बाइटपेक्षा जास्त असेल तेव्हा encodeVal ची चाचणी घ्या
2 // कमाल तीन बाइट्स कारण कॅशे चार बाइट्सपर्यंत भरण्यास
3 // खूप वेळ लागतो.
4 function testEncodeValBig() public {
5 // कॅशेमध्ये अनेक व्हॅल्यूज ठेवा.
6 // गोष्टी सोप्या ठेवण्यासाठी, व्हॅल्यू n साठी की n वापरा.
7 for(uint i=1; i<0x1FFF; i++) {
8 cache.cacheWrite(i);
9 }
सर्व दाखवा

वरील testEncodeVal फंक्शन कॅशेमध्ये फक्त चार व्हॅल्यूज लिहिते, त्यामुळे फंक्शनचा जो भाग मल्टी-बाइट व्हॅल्यूज हाताळतो (opens in a new tab) तो तपासला जात नाही. परंतु तो कोड गुंतागुंतीचा आणि त्रुटी-प्रवण आहे.

या फंक्शनचा पहिला भाग एक लूप आहे जो 1 ते 0x1FFF पर्यंतच्या सर्व व्हॅल्यूज क्रमाने कॅशेमध्ये लिहितो, जेणेकरून आपण त्या व्हॅल्यूज एन्कोड करू शकू आणि त्या कुठे जात आहेत हे जाणून घेऊ शकू.

1 .
2 .
3 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // एक बाइट 0x0F
8 cache.encodeVal(0x0010), // दोन बाइट्स 0x1010
9 cache.encodeVal(0x0100), // दोन बाइट्स 0x1100
10 cache.encodeVal(0x1000) // तीन बाइट्स 0x201000
11 );
सर्व दाखवा

एक बाइट, दोन बाइट आणि तीन बाइट व्हॅल्यूजची चाचणी घ्या. आम्ही त्यापलीकडे चाचणी करत नाही कारण पुरेशा स्टॅक नोंदी लिहिण्यासाठी खूप वेळ लागेल (किमान 0x10000000, अंदाजे पाव अब्ज).

1 .
2 .
3 .
4 .
5 } // testEncodeValBig
6
7
8 // अत्यंत लहान बफरमुळे आपल्याला रिव्हर्ट मिळतो की नाही याची चाचणी
9 function testShortCalldata() public {
सर्व दाखवा

जेव्हा पुरेसे पॅरामीटर्स नसतात तेव्हा असामान्य परिस्थितीत काय होते याची चाचणी घ्या.

1 .
2 .
3 .
4 (_success, _callOutput) = _cacheAddr.call(_callInput);
5 assertEq(_success, false);
6 } // testShortCalldata

ते रिव्हर्ट होत असल्याने, आपल्याला मिळणारा निकाल false असावा.

1 // अस्तित्वात नसलेल्या कॅशे कीजसह कॉल करा
2 function testNoCacheKey() public {
3 .
4 .
5 .
6 _callInput = bytes.concat(
7 FOUR_PARAMS,
8
9 // पहिली व्हॅल्यू, ती कॅशेमध्ये जोडा
10 cache.INTO_CACHE(),
11 bytes32(VAL_A),
12
13 // दुसरी व्हॅल्यू
14 bytes1(0x0F),
15 bytes2(0x1234),
16 bytes11(0xA10102030405060708090A)
17 );
सर्व दाखवा

हे फंक्शन चार पूर्णपणे कायदेशीर पॅरामीटर्स मिळवते, फक्त फरक एवढाच की कॅशे रिकामा आहे त्यामुळे वाचण्यासाठी तेथे कोणतीही व्हॅल्यूज नाहीत.

1 .
2 .
3 .
4 // अत्यंत लांब बफरसह सर्व काही व्यवस्थित काम करते की नाही याची चाचणी
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // पहिला कॉल, कॅशे रिकामा आहे
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // पहिली व्हॅल्यू, ती कॅशेमध्ये जोडा
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // दुसरी व्हॅल्यू, ती कॅशेमध्ये जोडा
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // तिसरी व्हॅल्यू, ती कॅशेमध्ये जोडा
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // चौथी व्हॅल्यू, ती कॅशेमध्ये जोडा
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // आणि "गुड लक" साठी आणखी एक व्हॅल्यू
28 bytes4(0x31112233)
29 );
सर्व दाखवा

हे फंक्शन पाच व्हॅल्यूज पाठवते. आम्हाला माहित आहे की पाचवी व्हॅल्यू दुर्लक्षित केली जाते कारण ती वैध कॅशे नोंद नाही, जी समाविष्ट केली नसती तर रिव्हर्ट झाली असती.

1 (_success, _callOutput) = _cacheAddr.call(_callInput);
2 assertEq(_success, true);
3 .
4 .
5 .
6 } // testLongCalldata
7
8} // CacheTest
9
सर्व दाखवा

एक नमुना ॲप्लिकेशन

Solidity मध्ये चाचण्या लिहिणे खूप चांगले आहे, पण शेवटी, उपयुक्त होण्यासाठी dapp ला चेनच्या बाहेरून आलेल्या विनंत्यांवर प्रक्रिया करता आली पाहिजे. हा लेख WORM सह dapp मध्ये कॅशिंग कसे वापरावे हे दाखवतो, ज्याचा अर्थ "एकदा लिहा, अनेकदा वाचा" असा होतो. जर की अजून लिहिलेली नसेल, तर तुम्ही त्यावर एक व्हॅल्यू लिहू शकता. जर की आधीच लिहिलेली असेल, तर तुम्हाला रिव्हर्ट मिळेल.

करार

हा करार आहे (opens in a new tab). हे बहुतेक Cache आणि CacheTest सह आपण आधीच जे केले आहे त्याचीच पुनरावृत्ती करते, म्हणून आपण फक्त मनोरंजक भाग कव्हर करू.

1import "./Cache.sol";
2
3contract WORM is Cache {

Cache वापरण्याचा सर्वात सोपा मार्ग म्हणजे तो आपल्या स्वतःच्या करारामध्ये इनहेरिट करणे.

1 function writeEntryCached() external {
2 uint[] memory params = _readParams(2);
3 writeEntry(params[0], params[1]);
4 } // writeEntryCached

हे फंक्शन वरील CacheTest मधील fourParam सारखेच आहे. कारण आपण ABI स्पेसिफिकेशन्सचे पालन करत नाही, त्यामुळे फंक्शनमध्ये कोणतेही पॅरामीटर्स घोषित न करणे चांगले आहे.

1 // आम्हाला कॉल करणे सोपे करा
2 // writeEntryCached() साठी फंक्शन सिग्नेचर, सौजन्याने
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
4 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;

writeEntryCached ला कॉल करणारा बाह्य कोडला worm.writeEntryCached वापरण्याऐवजी मॅन्युअली कॉलडेटा तयार करावा लागेल, कारण आम्ही ABI स्पेसिफिकेशन्सचे पालन करत नाही. हे कॉन्स्टंट व्हॅल्यू असल्याने ते लिहिणे सोपे होते.

लक्षात ठेवा की आम्ही WRITE_ENTRY_CACHED ला स्टेट व्हेरिएबल म्हणून परिभाषित केले असले तरी, ते बाहेरून वाचण्यासाठी त्यासाठी गेटर फंक्शन worm.WRITE_ENTRY_CACHED() वापरणे आवश्यक आहे.

1 function readEntry(uint key) public view
2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)

रीड फंक्शन view आहे, त्यामुळे त्याला व्यवहाराची आवश्यकता नाही आणि गॅस लागत नाही. परिणामी, पॅरामीटरसाठी कॅशे वापरण्याचा कोणताही फायदा नाही. व्ह्यू फंक्शन्ससह, सोपी असलेली मानक यंत्रणा वापरणे सर्वोत्तम आहे.

चाचणी कोड

हा करारासाठीचा चाचणी कोड आहे (opens in a new tab). पुन्हा, आपण फक्त मनोरंजक गोष्टी पाहू.

1 function testWReadWrite() public {
2 worm.writeEntry(0xDEAD, 0x60A7);
3
4 vm.expectRevert(bytes("entry already written"));
5 worm.writeEntry(0xDEAD, 0xBEEF);

Foundry चाचणीमध्ये पुढील कॉल अयशस्वी व्हावा आणि अयशस्वी होण्याचे नोंदवलेले कारण कसे निर्दिष्ट करावे हे हे (vm.expectRevert) (opens in a new tab) आहे. जेव्हा आपण <contract>.<function name>() सिंटॅक्स वापरतो तेव्हा हे लागू होते कॉलडेटा तयार करून आणि निम्न-स्तरीय इंटरफेस (<contract>.call() इत्यादी) वापरून कराराला कॉल करण्याऐवजी.

1 function testReadWriteCached() public {
2 uint cacheGoat = worm.cacheWrite(0x60A7);

येथे आपण या वस्तुस्थितीचा वापर करतो की cacheWrite कॅशे की परत करते. हे असे काहीतरी नाही जे आपण उत्पादनात वापरण्याची अपेक्षा करतो, कारण cacheWrite स्टेट बदलते आणि म्हणून फक्त व्यवहारादरम्यानच कॉल केले जाऊ शकते. व्यवहारांना रिटर्न व्हॅल्यूज नसतात, जर त्यांचे निकाल असतील तर ते निकाल इव्हेंट म्हणून प्रसारित केले जातात. त्यामुळे cacheWrite रिटर्न व्हॅल्यू केवळ ऑनचेन कोडवरूनच ॲक्सेस करता येते आणि ऑनचेन कोडला पॅरामीटर कॅशिंगची आवश्यकता नसते.

1 (_success,) = address(worm).call(_callInput);

याद्वारे आपण Solidity ला सांगतो की <contract address>.call() मध्ये दोन रिटर्न व्हॅल्यूज असल्या तरी, आपल्याला फक्त पहिल्याचीच काळजी आहे.

1 (_success,) = address(worm).call(_callInput);
2 assertEq(_success, false);

कारण आपण निम्न-स्तरीय <address>.call() फंक्शन वापरतो, आपण vm.expectRevert() वापरू शकत नाही आणि आपल्याला कॉलमधून मिळणाऱ्या बुलियन यश व्हॅल्यूकडे लक्ष द्यावे लागते.

1 event EntryWritten(uint indexed key, uint indexed value);
2
3 .
4 .
5 .
6
7 _callInput = bytes.concat(
8 worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));
9 vm.expectEmit(true, true, false, false);
10 emit EntryWritten(a, b);
11 (_success,) = address(worm).call(_callInput);
सर्व दाखवा

Foundry मध्ये कोड योग्यरित्या इव्हेंट प्रसारित करतो (opens in a new tab) की नाही हे तपासण्याचा हा मार्ग आहे.

क्लायंट

Solidity चाचण्यांसोबत एक गोष्ट मिळत नाही ती म्हणजे जावास्क्रिप्ट कोड जो तुम्ही तुमच्या स्वतःच्या ॲप्लिकेशनमध्ये कट आणि पेस्ट करू शकता. तो कोड लिहिण्यासाठी मी WORM ला Optimism Goerli (opens in a new tab) वर तैनात केले, जो Optimism's (opens in a new tab) नवीन टेस्टनेट आहे. ते 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens in a new tab) ॲड्रेसवर आहे.

तुम्ही क्लायंटसाठी जावास्क्रिप्ट कोड येथे पाहू शकता (opens in a new tab). ते वापरण्यासाठी:

  1. गिट रिपॉझिटरी क्लोन करा:

    1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
  2. आवश्यक पॅकेजेस स्थापित करा:

    1cd javascript
    2yarn
  3. कॉन्फिगरेशन फाईल कॉपी करा:

    1cp .env.example .env
  4. तुमच्या कॉन्फिगरेशनसाठी .env संपादित करा:

    पॅरामीटरमूल्य
    MNEMONICएका खात्यासाठी मेमोनिक ज्यामध्ये व्यवहारासाठी पैसे भरण्यासाठी पुरेसे ETH आहे. तुम्ही Optimism Goerli नेटवर्कसाठी मोफत ETH येथे मिळवू शकता (opens in a new tab).
    OPTIMISM_GOERLI_URLOptimism Goerli साठी URL. पब्लिक एंडपॉइंट, https://goerli.optimism.io, रेट लिमिटेड आहे पण आपल्याला येथे जे हवे आहे त्यासाठी पुरेसा आहे
  5. index.js चालवा.

    1node index.js

    हे नमुना ॲप्लिकेशन प्रथम WORM मध्ये एक नोंद लिहिते, कॉलडेटा आणि Etherscan वरील व्यवहाराची लिंक दर्शवते. नंतर ते ती नोंद परत वाचते, आणि ती वापरत असलेली की आणि नोंदीमधील व्हॅल्यूज (व्हॅल्यू, ब्लॉक क्रमांक आणि लेखक) दर्शवते.

बहुतेक क्लायंट सामान्य Dapp JavaScript आहे. त्यामुळे पुन्हा आपण फक्त मनोरंजक भागांवरच नजर टाकू.

1.
2.
3.
4const main = async () => {
5 const func = await worm.WRITE_ENTRY_CACHED()
6
7 // प्रत्येक वेळी नवीन की आवश्यक आहे
8 const key = await worm.encodeVal(Number(new Date()))

दिलेल्या स्लॉटमध्ये फक्त एकदाच लिहिले जाऊ शकते, म्हणून आम्ही स्लॉट पुन्हा वापरत नाही याची खात्री करण्यासाठी टाइमस्टॅम्प वापरतो.

1const val = await worm.encodeVal("0x600D")
2
3// एक नोंद लिहा
4const calldata = func + key.slice(2) + val.slice(2)

Ethers ला कॉल डेटा हेक्स स्ट्रिंग असण्याची अपेक्षा आहे, 0x नंतर सम संख्येतील हेक्साडेसिमल अंक. key आणि val दोन्ही 0x ने सुरू होत असल्याने, आपल्याला ते हेडर काढून टाकणे आवश्यक आहे.

1const tx = await worm.populateTransaction.writeEntryCached()
2tx.data = calldata
3
4sentTx = await wallet.sendTransaction(tx)

Solidity चाचणी कोडप्रमाणे, आपण कॅश्ड फंक्शनला सामान्यपणे कॉल करू शकत नाही. त्याऐवजी, आपल्याला निम्न-स्तरीय यंत्रणा वापरण्याची आवश्यकता आहे.

1 .
2 .
3 .
4 // नुकतीच लिहिलेली नोंद वाचा
5 const realKey = '0x' + key.slice(4) // FF फ्लॅग काढा
6 const entryRead = await worm.readEntry(realKey)
7 .
8 .
9 .
सर्व दाखवा

नोंदी वाचण्यासाठी आपण सामान्य यंत्रणा वापरू शकतो. view फंक्शन्ससह पॅरामीटर कॅशिंग वापरण्याची आवश्यकता नाही.

निष्कर्ष

या लेखातील कोड एक प्रूफ ऑफ कॉन्सेप्ट आहे, उद्देश ही कल्पना समजण्यास सोपी करणे हा आहे. उत्पादनासाठी सज्ज प्रणालीसाठी तुम्ही काही अतिरिक्त कार्यक्षमता लागू करू शकता:

  • uint256 नसलेल्या व्हॅल्यूज हाताळा. उदाहरणार्थ, स्ट्रिंग्स.

  • जागतिक कॅशेऐवजी, कदाचित वापरकर्ते आणि कॅशे यांच्यात मॅपिंग ठेवा. वेगवेगळे वापरकर्ते वेगवेगळ्या व्हॅल्यूज वापरतात.

  • ॲड्रेससाठी वापरल्या जाणार्‍या व्हॅल्यूज इतर उद्देशांसाठी वापरल्या जाणार्‍या व्हॅल्यूजपेक्षा वेगळ्या असतात. फक्त ॲड्रेससाठी वेगळा कॅशे असणे अर्थपूर्ण असू शकते.

  • सध्या, कॅशे की "प्रथम येणाऱ्यास, सर्वात लहान की" अल्गोरिदमवर आहेत. पहिल्या सोळा व्हॅल्यूज एकाच बाइटमध्ये पाठवल्या जाऊ शकतात. पुढील 4080 व्हॅल्यूज दोन बाइट्समध्ये पाठवल्या जाऊ शकतात. पुढील अंदाजे दहा लाख व्हॅल्यूज तीन बाइट्सच्या आहेत, इत्यादी. एक उत्पादन प्रणालीने कॅशे नोंदींवर वापर काउंटर ठेवावेत आणि त्यांची पुनर्रचना करावी जेणेकरून सोळा सर्वात सामान्य व्हॅल्यूज एक बाइटच्या असतील, पुढील 4080 सर्वात सामान्य व्हॅल्यूज दोन बाइट्सच्या असतील, इत्यादी.

    तथापि, ही एक संभाव्य धोकादायक क्रिया आहे. खालील घटनांचा क्रम कल्पना करा:

    1. नोआम नेव्ह त्याला टोकन पाठवायचे असलेल्या ॲड्रेसला एन्कोड करण्यासाठी encodeVal ला कॉल करतो. तो ॲड्रेस ॲप्लिकेशनवर वापरल्या जाणाऱ्या पहिल्या ॲड्रेसपैकी एक आहे, त्यामुळे एन्कोड केलेली व्हॅल्यू 0x06 आहे. हे एक view फंक्शन आहे, व्यवहार नाही, त्यामुळे ते नोआम आणि तो वापरत असलेल्या नोडच्या दरम्यान आहे आणि इतर कोणालाही त्याबद्दल माहिती नाही

    2. ओवेन ओनर कॅशे पुनर्रचना ऑपरेशन चालवतो. फार कमी लोक प्रत्यक्षात तो ॲड्रेस वापरतात, त्यामुळे तो आता 0x201122 म्हणून एन्कोड केला आहे. एक वेगळी व्हॅल्यू, 1018, 0x06 ला नियुक्त केली आहे.

    3. नोआम नेव्ह त्याचे टोकन 0x06 वर पाठवतो. ते 0x0000000000000000000000000de0b6b3a7640000 ॲड्रेसवर जातात, आणि त्या ॲड्रेसची प्रायव्हेट की कोणालाच माहीत नसल्याने, ते तिथेच अडकून पडतात. नोआम खुश नाही.

    ही समस्या सोडवण्याचे मार्ग आहेत, आणि कॅशे पुनर्रचनेदरम्यान मेमपूलमध्ये असलेल्या व्यवहारांची संबंधित समस्या, पण तुम्हाला त्याबद्दल जागरूक असले पाहिजे.

मी येथे Optimism सह कॅशिंगचे प्रदर्शन केले आहे, कारण मी Optimism चा कर्मचारी आहे आणि हा रोलअप मला सर्वोत्तम माहीत आहे. परंतु ते कोणत्याही रोलअपसह कार्य केले पाहिजे जे अंतर्गत प्रक्रियेसाठी किमान खर्च आकारते, जेणेकरून तुलनेत L1 वर व्यवहार डेटा लिहिणे हा मोठा खर्च असेल.

माझ्या कामाबद्दल अधिक माहितीसाठी येथे पहा (opens in a new tab).

पृष्ठ अखेरचे अद्यतन: २५ फेब्रुवारी, २०२६

हे मार्गदर्शन उपयुक्त होते का?