আপনি যা কিছু ক্যাশ করতে পারেন
রোলআপ ব্যবহার করার সময় ট্রানজ্যাকশনে একটি বাইটের খরচ একটি স্টোরেজ স্লটের খরচের চেয়ে অনেক বেশি ব্যয়বহুল। তাই, অনচেইনে যতটা সম্ভব তথ্য ক্যাশ করা যৌক্তিক।
এই নিবন্ধে আপনি শিখবেন কীভাবে এমনভাবে একটি ক্যাশিং কন্ট্রাক্ট তৈরি এবং ব্যবহার করতে হয় যাতে একাধিকবার ব্যবহার হওয়ার সম্ভাবনা রয়েছে এমন যেকোনো প্যারামিটার মান ক্যাশ করা হবে এবং (প্রথমবারের পরে) অনেক কম সংখ্যক বাইট সহ ব্যবহারের জন্য উপলব্ধ থাকবে, এবং কীভাবে অফচেইন কোড লিখতে হয় যা এই ক্যাশ ব্যবহার করে।
আপনি যদি নিবন্ধটি এড়িয়ে যেতে চান এবং শুধুমাত্র সোর্স কোড দেখতে চান, তবে এটি এখানে রয়েছে (opens in a new tab)। ডেভেলপমেন্ট স্ট্যাক হলো Foundry (opens in a new tab)।
সামগ্রিক ডিজাইন
সরলতার স্বার্থে আমরা ধরে নেব সমস্ত ট্রানজ্যাকশন প্যারামিটার হলো uint256, যা 32 বাইট দীর্ঘ। যখন আমরা একটি ট্রানজ্যাকশন পাই, তখন আমরা প্রতিটি প্যারামিটারকে এভাবে পার্স করব:
-
যদি প্রথম বাইট
0xFFহয়, তবে পরবর্তী 32 বাইটকে একটি প্যারামিটার মান হিসেবে নিন এবং এটি ক্যাশে লিখুন। -
যদি প্রথম বাইট
0xFEহয়, তবে পরবর্তী 32 বাইটকে একটি প্যারামিটার মান হিসেবে নিন কিন্তু এটি ক্যাশে লিখবেন না। -
অন্য যেকোনো মানের জন্য, শীর্ষ চারটি বিটকে অতিরিক্ত বাইটের সংখ্যা হিসেবে নিন এবং নিচের চারটি বিটকে ক্যাশ কীর সবচেয়ে গুরুত্বপূর্ণ বিট (most significant bits) হিসেবে নিন। এখানে কিছু উদাহরণ দেওয়া হলো:
কল ডেটায় বাইট ক্যাশ কী 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;
আমরা কী থেকে মানগুলোতে ম্যাপিংয়ের জন্য একটি অ্যারে ব্যবহার করতে পারি কারণ আমরা কীগুলো নির্ধারণ করি এবং সরলতার জন্য আমরা এটি ক্রমানুসারে করি।
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 এবং এরপরে ১৫টি 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 বাইট পর্যন্ত, ওয়ার্ড সাইজ) একটি মান পড়ে।
{
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;
নিচের নিবল (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 বিট মান, একটি 16 বিট মানে রূপান্তর করি এবং প্রথম বাইটে অতিরিক্ত বাইটের সংখ্যা যোগ করতে লজিক্যাল অর (logical 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 বাইট, 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;
// আমরা সঠিক মানগুলো ফেরত পাচ্ছি কিনা তা দেখার জন্য কিছু কনস্ট্যান্ট মান
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
// অত্যধিক ছোট বাফারের সাথে আমরা একটি রিভার্ট পাই কিনা তা টেস্ট করুন
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);
এভাবেই (vm.expectRevert) (opens in a new tab) আমরা একটি Foundry পরীক্ষায় নির্দিষ্ট করি যে পরবর্তী কলটি ব্যর্থ হওয়া উচিত এবং ব্যর্থতার রিপোর্ট করা কারণটি কী। এটি তখন প্রযোজ্য হয় যখন আমরা কল ডেটা তৈরি করার এবং লো লেভেল ইন্টারফেস (<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-কে অপটিমিজম Goerli (opens in a new tab)-তে ডিপ্লয় করেছি, যা অপটিমিজমের (opens in a new tab) নতুন টেস্টনেট। এটি 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens in a new tab) ঠিকানায় রয়েছে।
আপনি এখানে ক্লায়েন্টের জন্য JavaScript কোড দেখতে পারেন (opens in a new tab)। এটি ব্যবহার করতে:
-
গিট রিপোজিটরি ক্লোন করুন:
git clone https://github.com/qbzzt/20220915-all-you-can-cache.git -
প্রয়োজনীয় প্যাকেজগুলো ইনস্টল করুন:
cd javascript yarn -
কনফিগারেশন ফাইলটি কপি করুন:
cp .env.example .env -
আপনার কনফিগারেশনের জন্য
.envএডিট করুন:প্যারামিটার মান MNEMONIC এমন একটি অ্যাকাউন্টের জন্য নেমোনিক (mnemonic) যার কাছে ট্রানজ্যাকশনের জন্য অর্থ প্রদানের মতো পর্যাপ্ত ETH রয়েছে। আপনি এখানে অপটিমিজম Goerli নেটওয়ার্কের জন্য বিনামূল্যে ETH পেতে পারেন (opens in a new tab)। OPTIMISM_GOERLI_URL অপটিমিজম 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 আশা করে যে কল ডেটা একটি হেক্স স্ট্রিং হবে, 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ঠিকানায় যায় এবং যেহেতু কেউ সেই ঠিকানার প্রাইভেট কী জানে না, তাই সেগুলো সেখানেই আটকে থাকে। নোয়াম খুশি নয়।
এই সমস্যাটি এবং ক্যাশ রিঅর্ডারের সময় মেমপুলে থাকা ট্রানজ্যাকশনগুলোর সম্পর্কিত সমস্যা সমাধানের উপায় রয়েছে, তবে আপনাকে অবশ্যই এটি সম্পর্কে সচেতন হতে হবে।
-
আমি এখানে অপটিমিজমের সাথে ক্যাশিং প্রদর্শন করেছি, কারণ আমি একজন অপটিমিজম কর্মী এবং এই রোলআপটি আমি সবচেয়ে ভালো জানি। তবে এটি এমন যেকোনো রোলআপের সাথে কাজ করা উচিত যা অভ্যন্তরীণ প্রক্রিয়াকরণের জন্য ন্যূনতম খরচ নেয়, যাতে এর তুলনায় লেয়ার 1 (l1)-এ ট্রানজ্যাকশন ডেটা লেখা প্রধান ব্যয় হয়।