Lompat ke konten utama

Semua yang dapat Anda cache

layer 2
caching
penyimpanan
peningkatan
Menengah
Ori Pomerantz
15 September 2022
24 menit baca

Saat menggunakan rollup, biaya satu byte dalam transaksi jauh lebih mahal daripada biaya slot penyimpanan. Oleh karena itu, masuk akal untuk melakukan cache sebanyak mungkin informasi secara onchain.

Dalam artikel ini Anda akan mempelajari cara membuat dan menggunakan kontrak caching sedemikian rupa sehingga setiap nilai parameter yang kemungkinan akan digunakan berkali-kali akan di-cache dan tersedia untuk digunakan (setelah pertama kali) dengan jumlah byte yang jauh lebih kecil, dan cara menulis kode offchain yang menggunakan cache ini.

Jika Anda ingin melewati artikel ini dan langsung melihat kode sumbernya, ada di sini (opens in a new tab). Stack pengembangannya adalah Foundry (opens in a new tab).

Desain keseluruhan

Demi kesederhanaan, kita akan mengasumsikan semua parameter transaksi adalah uint256, dengan panjang 32 byte. Saat kita menerima transaksi, kita akan mengurai setiap parameter seperti ini:

  1. Jika byte pertama adalah 0xFF, ambil 32 byte berikutnya sebagai nilai parameter dan tulis ke dalam cache.

  2. Jika byte pertama adalah 0xFE, ambil 32 byte berikutnya sebagai nilai parameter tetapi jangan tulis ke dalam cache.

  3. Untuk nilai lainnya, ambil empat bit teratas sebagai jumlah byte tambahan, dan empat bit terbawah sebagai bit paling signifikan dari kunci cache. Berikut adalah beberapa contohnya:

    Byte dalam calldataKunci cache
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

Manipulasi cache

Cache diimplementasikan dalam Cache.sol (opens in a new tab). Mari kita bahas baris demi baris.

1// SPDX-License-Identifier: UNLICENSED // 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;

Konstanta ini digunakan untuk menafsirkan kasus khusus di mana kita memberikan semua informasi dan ingin menuliskannya ke dalam cache atau tidak. Menulis ke dalam cache memerlukan dua operasi SSTORE (opens in a new tab) ke dalam slot penyimpanan yang sebelumnya tidak digunakan dengan biaya masing-masing 22100 gas, jadi kita membuatnya opsional.

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

Sebuah pemetaan (opens in a new tab) antara nilai dan kuncinya. Informasi ini diperlukan untuk menyandikan nilai sebelum Anda mengirimkan transaksi.

1 // Location n has the value for key n+1, because we need to preserve // Lokasi n memiliki nilai untuk kunci n+1, karena kita perlu mempertahankan
2 // zero as "not in the cache". // nol sebagai "tidak ada di dalam cache".
3 uint[] public key2val;

Kita dapat menggunakan array untuk pemetaan dari kunci ke nilai karena kita menetapkan kuncinya, dan demi kesederhanaan kita melakukannya secara berurutan.

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 // cacheRead

Membaca nilai dari cache.

1 // Write a value to the cache if it's not there already // Tulis nilai ke cache jika belum ada di sana
2 // Only public to enable the test to work // Hanya publik untuk memungkinkan pengujian berfungsi
3 function cacheWrite(uint _value) public returns (uint) {
4 // If the value is already in the cache, return the current key // Jika nilai sudah ada di dalam cache, kembalikan kunci saat ini
5 if (val2key[_value] != 0) {
6 return val2key[_value];
7 }

Tidak ada gunanya memasukkan nilai yang sama ke dalam cache lebih dari sekali. Jika nilainya sudah ada, cukup kembalikan kunci yang ada.

1 // Since 0xFE is a special case, the largest key the cache can // Karena 0xFE adalah kasus khusus, kunci terbesar yang dapat
2 // hold is 0x0D followed by 15 0xFF's. If the cache length is already that // disimpan cache adalah 0x0D diikuti oleh 15 0xFF. Jika panjang cache sudah sebesar itu,
3 // large, fail. // gagal.
4 // 1 2 3 4 5 6 7 8 9 A B C D E F // 1 2 3 4 5 6 7 8 9 A B C D E F
5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
6 "cache overflow");

Saya rasa kita tidak akan pernah mendapatkan cache sebesar itu (sekitar 1.8*1037 entri, yang akan membutuhkan sekitar 1027 TB untuk menyimpannya). Namun, saya cukup tua untuk mengingat "640kB akan selalu cukup" (opens in a new tab). Pengujian ini sangat murah.

1 // Write the value using the next key // Tulis nilai menggunakan kunci berikutnya
2 val2key[_value] = key2val.length+1;

Tambahkan pencarian terbalik (dari nilai ke kunci).

1 key2val.push(_value);

Tambahkan pencarian maju (dari kunci ke nilai). Karena kita menetapkan nilai secara berurutan, kita cukup menambahkannya setelah nilai array terakhir.

1 return key2val.length;
2 } // cacheWrite // cacheWrite

Kembalikan panjang baru dari key2val, yang merupakan sel tempat nilai baru disimpan.

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

Fungsi ini membaca nilai dari calldata dengan panjang sembarang (hingga 32 byte, ukuran kata).

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");

Fungsi ini bersifat internal, jadi jika sisa kode ditulis dengan benar, pengujian ini tidak diperlukan. Namun, biayanya tidak mahal jadi sebaiknya kita memilikinya.

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

Kode ini ada di Yul (opens in a new tab). Ini membaca nilai 32 byte dari calldata. Ini berfungsi bahkan jika calldata berhenti sebelum startByte+32 karena ruang yang tidak diinisialisasi dalam EVM dianggap nol.

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

Kita tidak selalu menginginkan nilai 32 byte. Ini menghilangkan byte yang berlebih.

1 return _retVal;
2 } // _calldataVal // _calldataVal
3
4
5 // Read a single parameter from the calldata, starting at _fromByte // Baca satu parameter dari calldata, dimulai dari _fromByte
6 function _readParam(uint _fromByte) internal
7 returns (uint _nextByte, uint _parameterValue)
8 {

Membaca parameter tunggal dari calldata. Perhatikan bahwa kita perlu mengembalikan bukan hanya nilai yang kita baca, tetapi juga lokasi byte berikutnya karena parameter dapat berkisar dari panjang 1 byte hingga 33 byte.

1 // The first byte tells us how to interpret the rest // Byte pertama memberi tahu kita cara menafsirkan sisanya
2 uint8 _firstByte;
3
4 _firstByte = uint8(_calldataVal(_fromByte, 1));

Solidity mencoba mengurangi jumlah bug dengan melarang konversi tipe implisit (opens in a new tab) yang berpotensi berbahaya. Penurunan versi, misalnya dari 256 bit ke 8 bit, harus eksplisit.

1
2 // Read the value, but do not write it to the cache // Baca nilai, tetapi jangan tulis ke cache
3 if (_firstByte == uint8(DONT_CACHE))
4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));
5
6 // Read the value, and write it to the cache // Baca nilai, dan tulis ke cache
7 if (_firstByte == uint8(INTO_CACHE)) {
8 uint _param = _calldataVal(_fromByte+1, 32);
9 cacheWrite(_param);
10 return(_fromByte+33, _param);
11 }
12
13 // If we got here it means that we need to read from the cache // Jika kita sampai di sini, itu berarti kita perlu membaca dari cache
14
15 // Number of extra bytes to read // Jumlah byte tambahan untuk dibaca
16 uint8 _extraBytes = _firstByte / 16;
Tampilkan semua

Ambil nibble (opens in a new tab) yang lebih rendah dan gabungkan dengan byte lain untuk membaca nilai dari cache.

1 uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
2 _calldataVal(_fromByte+1, _extraBytes);
3
4 return (_fromByte+_extraBytes+1, cacheRead(_key));
5
6 } // _readParam // _readParam
7
8
9 // Read n parameters (functions know how many parameters they expect) // Baca n parameter (fungsi tahu berapa banyak parameter yang mereka harapkan)
10 function _readParams(uint _paramNum) internal returns (uint[] memory) {
Tampilkan semua

Kita bisa mendapatkan jumlah parameter yang kita miliki dari calldata itu sendiri, tetapi fungsi yang memanggil kita tahu berapa banyak parameter yang mereka harapkan. Lebih mudah membiarkan mereka memberi tahu kita.

1 // The parameters we read // Parameter yang kita baca
2 uint[] memory params = new uint[](_paramNum);
3
4 // Parameters start at byte 4, before that it's the function signature // Parameter dimulai pada byte 4, sebelumnya adalah tanda tangan fungsi
5 uint _atByte = 4;
6
7 for(uint i=0; i<_paramNum; i++) {
8 (_atByte, params[i]) = _readParam(_atByte);
9 }
Tampilkan semua

Baca parameter hingga Anda mendapatkan jumlah yang Anda butuhkan. Jika kita melewati akhir calldata, _readParams akan membatalkan panggilan.

1
2 return(params);
3 } // readParams // readParams
4
5 // For testing _readParams, test reading four parameters // Untuk menguji _readParams, uji membaca empat parameter
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 // fourParam
Tampilkan semua

Salah satu keuntungan besar Foundry adalah memungkinkan pengujian ditulis dalam Solidity (lihat Menguji cache di bawah). Ini membuat pengujian unit jauh lebih mudah. Ini adalah fungsi yang membaca empat parameter dan mengembalikannya sehingga pengujian dapat memverifikasi bahwa parameter tersebut benar.

1 // Get a value, return bytes that will encode it (using the cache if possible) // Dapatkan nilai, kembalikan byte yang akan mengodenya (menggunakan cache jika memungkinkan)
2 function encodeVal(uint _val) public view returns(bytes memory) {

encodeVal adalah fungsi yang dipanggil oleh kode offchain untuk membantu membuat calldata yang menggunakan cache. Fungsi ini menerima nilai tunggal dan mengembalikan byte yang menyandikannya. Fungsi ini adalah view, jadi tidak memerlukan transaksi dan saat dipanggil secara eksternal tidak memerlukan biaya gas apa pun.

1 uint _key = val2key[_val];
2
3 // The value isn't in the cache yet, add it // Nilai belum ada di dalam cache, tambahkan
4 if (_key == 0)
5 return bytes.concat(INTO_CACHE, bytes32(_val));

Dalam EVM semua penyimpanan yang tidak diinisialisasi diasumsikan nol. Jadi jika kita mencari kunci untuk nilai yang tidak ada, kita mendapatkan nol. Dalam hal ini byte yang menyandikannya adalah INTO_CACHE (sehingga akan di-cache di lain waktu), diikuti oleh nilai sebenarnya.

1 // If the key is <0x10, return it as a single byte // Jika kunci <0x10, kembalikan sebagai satu byte
2 if (_key < 0x10)
3 return bytes.concat(bytes1(uint8(_key)));

Byte tunggal adalah yang paling mudah. Kita cukup menggunakan bytes.concat (opens in a new tab) untuk mengubah tipe bytes<n> menjadi array byte yang bisa berapa pun panjangnya. Terlepas dari namanya, ini berfungsi dengan baik saat diberikan hanya satu argumen.

1 // Two byte value, encoded as 0x1vvv // Nilai dua byte, dikodekan sebagai 0x1vvv
2 if (_key < 0x1000)
3 return bytes.concat(bytes2(uint16(_key) | 0x1000));

Saat kita memiliki kunci yang kurang dari 163, kita dapat menyatakannya dalam dua byte. Pertama-tama kita mengonversi _key, yang merupakan nilai 256 bit, menjadi nilai 16 bit dan menggunakan logika or untuk menambahkan jumlah byte ekstra ke byte pertama. Kemudian kita cukup mengubahnya menjadi nilai bytes2, yang dapat dikonversi menjadi bytes.

1 // There is probably a clever way to do the following lines as a loop, // Mungkin ada cara cerdas untuk melakukan baris berikut sebagai loop,
2 // but it's a view function so I'm optimizing for programmer time and // tetapi ini adalah fungsi view jadi saya mengoptimalkan untuk waktu programmer dan
3 // simplicity. // kesederhanaan.
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)));
Tampilkan semua

Nilai lainnya (3 byte, 4 byte, dll.) ditangani dengan cara yang sama, hanya dengan ukuran bidang yang berbeda.

1 // If we get here, something is wrong. // Jika kita sampai di sini, ada sesuatu yang salah.
2 revert("Error in encodeVal, should not happen");

Jika kita sampai di sini, itu berarti kita mendapatkan kunci yang tidak kurang dari 16*25615. Tetapi cacheWrite membatasi kunci sehingga kita bahkan tidak bisa mencapai 14*25616 (yang akan memiliki byte pertama 0xFE, sehingga akan terlihat seperti DONT_CACHE). Tetapi tidak memakan banyak biaya untuk menambahkan pengujian jika programmer di masa mendatang memasukkan bug.

1 } // encodeVal // encodeVal
2
3} // Cache // Cache

Menguji cache

Salah satu keuntungan Foundry adalah memungkinkan Anda menulis pengujian dalam Solidity (opens in a new tab), yang membuatnya lebih mudah untuk menulis pengujian unit. Pengujian untuk kelas Cache ada di sini (opens in a new tab). Karena kode pengujian berulang, seperti halnya pengujian pada umumnya, artikel ini hanya menjelaskan bagian-bagian yang menarik.

1// SPDX-License-Identifier: UNLICENSED // SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4import "forge-std/Test.sol";
5
6
7// Need to run `forge test -vv` for the console. // Perlu menjalankan `forge test -vv` untuk konsol.
8import "forge-std/console.sol";

Ini hanyalah boilerplate yang diperlukan untuk menggunakan paket pengujian dan console.log.

1import "src/Cache.sol";

Kita perlu mengetahui kontrak yang sedang kita uji.

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

Fungsi setUp dipanggil sebelum setiap pengujian. Dalam hal ini kita hanya membuat cache baru, sehingga pengujian kita tidak akan saling memengaruhi.

1 function testCaching() public {

Pengujian adalah fungsi yang namanya dimulai dengan test. Fungsi ini memeriksa fungsionalitas cache dasar, menulis nilai dan membacanya lagi.

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);

Beginilah cara Anda melakukan pengujian yang sebenarnya, menggunakan fungsi assert... (opens in a new tab). Dalam hal ini, kita memeriksa bahwa nilai yang kita tulis adalah nilai yang kita baca. Kita dapat membuang hasil cache.cacheWrite karena kita tahu bahwa kunci cache ditetapkan secara linier.

1 }
2 } // testCaching // testCaching
3
4
5 // Cache the same value multiple times, ensure that the key stays // Cache nilai yang sama beberapa kali, pastikan kuncinya tetap
6 // the same // sama
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 }
Tampilkan semua

Pertama kita menulis setiap nilai dua kali ke cache dan memastikan kuncinya sama (artinya penulisan kedua tidak benar-benar terjadi).

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

Secara teori mungkin ada bug yang tidak memengaruhi penulisan cache berturut-turut. Jadi di sini kita melakukan beberapa penulisan yang tidak berturut-turut dan melihat nilainya masih belum ditulis ulang.

1 // Read a uint from a memory buffer (to make sure we get back the parameters // Baca uint dari buffer memori (untuk memastikan kita mendapatkan kembali parameter
2 // we sent out) // yang kita kirimkan)
3 function toUint256(bytes memory _bytes, uint256 _start) internal pure
4 returns (uint256)

Membaca kata 256 bit dari buffer bytes memory. Fungsi utilitas ini memungkinkan kita memverifikasi bahwa kita menerima hasil yang benar saat kita menjalankan panggilan fungsi yang menggunakan cache.

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 tidak mendukung struktur data di luar uint256, jadi saat Anda merujuk ke struktur data yang lebih canggih, seperti buffer memori _bytes, Anda mendapatkan alamat struktur tersebut. Solidity menyimpan nilai bytes memory sebagai kata 32 byte yang berisi panjang, diikuti oleh byte sebenarnya, jadi untuk mendapatkan nomor byte _start kita perlu menghitung _bytes+32+_start.

1
2 return tempUint;
3 } // toUint256 // toUint256
4
5 // Function signature for fourParams(), courtesy of // Tanda tangan fungsi untuk fourParams(), atas kebaikan
6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
8
9 // Just some constant values to see we're getting the correct values back // Hanya beberapa nilai konstan untuk melihat kita mendapatkan kembali nilai yang benar
10 uint256 constant VAL_A = 0xDEAD60A7;
11 uint256 constant VAL_B = 0xBEEF;
12 uint256 constant VAL_C = 0x600D;
13 uint256 constant VAL_D = 0x600D60A7;
Tampilkan semua

Beberapa konstanta yang kita butuhkan untuk pengujian.

1 function testReadParam() public {

Panggil fourParams(), sebuah fungsi yang menggunakan readParams, untuk menguji apakah kita dapat membaca parameter dengan benar.

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

Kita tidak dapat menggunakan mekanisme ABI normal untuk memanggil fungsi menggunakan cache, jadi kita perlu menggunakan mekanisme tingkat rendah <address>.call() (opens in a new tab). Mekanisme tersebut mengambil bytes memory sebagai input, dan mengembalikannya (serta nilai Boolean) sebagai output.

1 // First call, the cache is empty // Panggilan pertama, cache kosong
2 _callInput = bytes.concat(
3 FOUR_PARAMS,

Sangat berguna bagi kontrak yang sama untuk mendukung fungsi yang di-cache (untuk panggilan langsung dari transaksi) dan fungsi yang tidak di-cache (untuk panggilan dari kontrak pintar lainnya). Untuk melakukannya, kita perlu terus mengandalkan mekanisme Solidity untuk memanggil fungsi yang benar, alih-alih memasukkan semuanya ke dalam fungsi fallback (opens in a new tab). Melakukan ini membuat komposabilitas jauh lebih mudah. Satu byte akan cukup untuk mengidentifikasi fungsi dalam banyak kasus, jadi kita membuang tiga byte (16*3=48 gas). Namun, saat saya menulis ini, 48 gas tersebut berharga 0,07 sen, yang merupakan biaya yang wajar untuk kode yang lebih sederhana dan tidak rentan terhadap bug.

1 // First value, add it to the cache // Nilai pertama, tambahkan ke cache
2 cache.INTO_CACHE(),
3 bytes32(VAL_A),

Nilai pertama: Bendera yang mengatakan bahwa ini adalah nilai penuh yang perlu ditulis ke cache, diikuti oleh 32 byte dari nilai tersebut. Tiga nilai lainnya serupa, kecuali bahwa VAL_B tidak ditulis ke cache dan VAL_C adalah parameter ketiga dan keempat.

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

Di sinilah kita benar-benar memanggil kontrak Cache.

1 assertEq(_success, true);

Kita mengharapkan panggilan berhasil.

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

Kita mulai dengan cache kosong dan kemudian menambahkan VAL_A diikuti oleh VAL_C. Kita mengharapkan yang pertama memiliki kunci 1, dan yang kedua memiliki 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);

Outputnya adalah empat parameter. Di sini kita memverifikasi bahwa itu benar.

1 // Second call, we can use the cache // Panggilan kedua, kita dapat menggunakan cache
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // First value in the Cache // Nilai pertama di dalam Cache
6 bytes1(0x01),

Kunci cache di bawah 16 hanya satu byte.

1 // Second value, don't add it to the cache // Nilai kedua, jangan tambahkan ke cache
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // Third and fourth values, same value // Nilai ketiga dan keempat, nilai yang sama
6 bytes1(0x02),
7 bytes1(0x02)
8 );
9 .
10 .
11 .
12 } // testReadParam // testReadParam
Tampilkan semua

Pengujian setelah panggilan identik dengan pengujian setelah panggilan pertama.

1 function testEncodeVal() public {

Fungsi ini mirip dengan testReadParam, kecuali bahwa alih-alih menulis parameter secara eksplisit, kita menggunakan 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
Tampilkan semua

Satu-satunya pengujian tambahan dalam testEncodeVal() adalah memverifikasi bahwa panjang _callInput sudah benar. Untuk panggilan pertama adalah 4+33*4. Untuk yang kedua, di mana setiap nilai sudah ada di cache, adalah 4+1*4.

1 // Test encodeVal when the key is more than a single byte // Uji encodeVal ketika kunci lebih dari satu byte
2 // Maximum three bytes because filling the cache to four bytes takes // Maksimum tiga byte karena mengisi cache hingga empat byte membutuhkan
3 // too long. // waktu terlalu lama.
4 function testEncodeValBig() public {
5 // Put a number of values in the cache. // Masukkan sejumlah nilai ke dalam cache.
6 // To keep things simple, use key n for value n. // Agar tetap sederhana, gunakan kunci n untuk nilai n.
7 for(uint i=1; i<0x1FFF; i++) {
8 cache.cacheWrite(i);
9 }
Tampilkan semua

Fungsi testEncodeVal di atas hanya menulis empat nilai ke dalam cache, jadi bagian fungsi yang menangani nilai multi-byte (opens in a new tab) tidak diperiksa. Tetapi kode itu rumit dan rentan terhadap kesalahan.

Bagian pertama dari fungsi ini adalah loop yang menulis semua nilai dari 1 hingga 0x1FFF ke cache secara berurutan, sehingga kita akan dapat menyandikan nilai-nilai tersebut dan mengetahui ke mana arahnya.

1 .
2 .
3 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // One byte 0x0F // Satu byte 0x0F
8 cache.encodeVal(0x0010), // Two bytes 0x1010 // Dua byte 0x1010
9 cache.encodeVal(0x0100), // Two bytes 0x1100 // Dua byte 0x1100
10 cache.encodeVal(0x1000) // Three bytes 0x201000 // Tiga byte 0x201000
11 );
Tampilkan semua

Uji nilai satu byte, dua byte, dan tiga byte. Kita tidak menguji lebih dari itu karena akan memakan waktu terlalu lama untuk menulis entri tumpukan yang cukup (setidaknya 0x10000000, sekitar seperempat miliar).

1 .
2 .
3 .
4 .
5 } // testEncodeValBig // testEncodeValBig
6
7
8 // Test what with an excessively small buffer we get a revert // Uji bahwa dengan buffer yang terlalu kecil kita mendapatkan revert
9 function testShortCalldata() public {
Tampilkan semua

Uji apa yang terjadi dalam kasus abnormal di mana tidak ada cukup parameter.

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

Karena ini dibatalkan, hasil yang seharusnya kita dapatkan adalah false.

1 // Call with cache keys that aren't there
2 function testNoCacheKey() public {
3 .
4 .
5 .
6 _callInput = bytes.concat(
7 FOUR_PARAMS,
8
9 // First value, add it to the cache
10 cache.INTO_CACHE(),
11 bytes32(VAL_A),
12
13 // Second value
14 bytes1(0x0F),
15 bytes2(0x1234),
16 bytes11(0xA10102030405060708090A)
17 );
Tampilkan semua

Fungsi ini mendapatkan empat parameter yang sepenuhnya sah, kecuali bahwa cache kosong sehingga tidak ada nilai di sana untuk dibaca.

1 .
2 .
3 .
4 // Test what with an excessively long buffer everything works file // Uji bahwa dengan buffer yang terlalu panjang semuanya berfungsi dengan baik
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // First call, the cache is empty // Panggilan pertama, cache kosong
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // First value, add it to the cache // Nilai pertama, tambahkan ke cache
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // Second value, add it to the cache // Nilai kedua, tambahkan ke cache
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // Third value, add it to the cache // Nilai ketiga, tambahkan ke cache
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // Fourth value, add it to the cache // Nilai keempat, tambahkan ke cache
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // And another value for "good luck" // Dan nilai lain untuk "semoga berhasil"
28 bytes4(0x31112233)
29 );
Tampilkan semua

Fungsi ini mengirimkan lima nilai. Kita tahu bahwa nilai kelima diabaikan karena bukan entri cache yang valid, yang akan menyebabkan pembatalan jika tidak disertakan.

1 (_success, _callOutput) = _cacheAddr.call(_callInput);
2 assertEq(_success, true);
3 .
4 .
5 .
6 } // testLongCalldata // testLongCalldata
7
8} // CacheTest // CacheTest
9
Tampilkan semua

Contoh aplikasi

Menulis pengujian dalam Solidity memang sangat baik, tetapi pada akhirnya sebuah dapp harus dapat memproses permintaan dari luar rantai agar berguna. Artikel ini mendemonstrasikan cara menggunakan caching dalam dapp dengan WORM, yang merupakan singkatan dari "Write Once, Read Many" (Tulis Sekali, Baca Berkali-kali). Jika kunci belum ditulis, Anda dapat menulis nilai ke dalamnya. Jika kunci sudah ditulis, Anda akan mendapatkan pembatalan.

Kontrak

Ini adalah kontraknya (opens in a new tab). Ini sebagian besar mengulangi apa yang telah kita lakukan dengan Cache dan CacheTest, jadi kita hanya membahas bagian-bagian yang menarik.

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

Cara termudah untuk menggunakan Cache adalah dengan mewarisinya di kontrak kita sendiri.

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

Fungsi ini mirip dengan fourParam di CacheTest di atas. Karena kita tidak mengikuti spesifikasi ABI, sebaiknya jangan mendeklarasikan parameter apa pun ke dalam fungsi.

1 // Make it easier to call us // Buat lebih mudah untuk memanggil kami
2 // Function signature for writeEntryCached(), courtesy of // Tanda tangan fungsi untuk writeEntryCached(), atas kebaikan
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
4 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;

Kode eksternal yang memanggil writeEntryCached perlu membangun calldata secara manual, alih-alih menggunakan worm.writeEntryCached, karena kita tidak mengikuti spesifikasi ABI. Memiliki nilai konstan ini hanya membuatnya lebih mudah untuk ditulis.

Perhatikan bahwa meskipun kita mendefinisikan WRITE_ENTRY_CACHED sebagai variabel status, untuk membacanya secara eksternal perlu menggunakan fungsi getter untuk itu, worm.WRITE_ENTRY_CACHED().

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

Fungsi baca adalah view, jadi tidak memerlukan transaksi dan tidak memerlukan biaya gas. Akibatnya, tidak ada manfaat menggunakan cache untuk parameter. Dengan fungsi view, sebaiknya gunakan mekanisme standar yang lebih sederhana.

Kode pengujian

Ini adalah kode pengujian untuk kontrak (opens in a new tab). Sekali lagi, mari kita lihat hanya pada apa yang menarik.

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

Ini (vm.expectRevert) (opens in a new tab) adalah cara kita menentukan dalam pengujian Foundry bahwa panggilan berikutnya harus gagal, dan alasan kegagalan yang dilaporkan. Ini berlaku saat kita menggunakan sintaks <contract>.<function name>() daripada membangun calldata dan memanggil kontrak menggunakan antarmuka tingkat rendah (<contract>.call(), dll.).

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

Di sini kita menggunakan fakta bahwa cacheWrite mengembalikan kunci cache. Ini bukan sesuatu yang kita harapkan untuk digunakan dalam produksi, karena cacheWrite mengubah status, dan oleh karena itu hanya dapat dipanggil selama transaksi. Transaksi tidak memiliki nilai kembalian, jika mereka memiliki hasil, hasil tersebut seharusnya dipancarkan sebagai peristiwa. Jadi nilai kembalian cacheWrite hanya dapat diakses dari kode onchain, dan kode onchain tidak memerlukan caching parameter.

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

Beginilah cara kita memberi tahu Solidity bahwa meskipun <contract address>.call() memiliki dua nilai kembalian, kita hanya peduli pada yang pertama.

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

Karena kita menggunakan fungsi tingkat rendah <address>.call(), kita tidak dapat menggunakan vm.expectRevert() dan harus melihat nilai keberhasilan boolean yang kita dapatkan dari panggilan tersebut.

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);
Tampilkan semua

Ini adalah cara kita memverifikasi bahwa kode memancarkan peristiwa dengan benar (opens in a new tab) di Foundry.

Klien

Satu hal yang tidak Anda dapatkan dengan pengujian Solidity adalah kode JavaScript yang dapat Anda potong dan tempel ke aplikasi Anda sendiri. Untuk menulis kode itu, saya menerapkan WORM ke Optimism Goerli (opens in a new tab), testnet baru Optimism (opens in a new tab). Itu berada di alamat 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens in a new tab).

Anda dapat melihat kode JavaScript untuk klien di sini (opens in a new tab). Untuk menggunakannya:

  1. Klon repositori git:

    1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
1
22. Instal paket yang diperlukan:
3
4 ```sh
5 cd javascript
6 yarn
  1. Salin file konfigurasi:

    1cp .env.example .env
1
24. Edit `.env` untuk konfigurasi Anda:
3
4 | Parameter | Nilai |
5 | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
6 | MNEMONIC | Mnemonic untuk akun yang memiliki cukup ETH untuk membayar transaksi. [Anda bisa mendapatkan ETH gratis untuk jaringan Optimism Goerli di sini](https://optimismfaucet.xyz/). |
7 | OPTIMISM_GOERLI_URL | URL ke Optimism Goerli. Titik akhir publik, `https://goerli.optimism.io`, dibatasi kecepatannya tetapi cukup untuk apa yang kita butuhkan di sini |
8
95. Jalankan `index.js`.
10
11 ```sh
12 node index.js
Tampilkan semua

Contoh aplikasi ini pertama-tama menulis entri ke WORM, menampilkan calldata dan tautan ke transaksi di Etherscan. Kemudian ia membaca kembali entri tersebut, dan menampilkan kunci yang digunakannya dan nilai-nilai dalam entri tersebut (nilai, nomor blok, dan penulis).

Sebagian besar klien adalah JavaScript Dapp normal. Jadi sekali lagi kita hanya akan membahas bagian-bagian yang menarik.

1.
2.
3.
4const main = async () => {
5 const func = await worm.WRITE_ENTRY_CACHED()
6
7 // Need a new key every time // Perlu kunci baru setiap saat
8 const key = await worm.encodeVal(Number(new Date()))

Slot tertentu hanya dapat ditulis sekali, jadi kita menggunakan stempel waktu untuk memastikan kita tidak menggunakan kembali slot.

1const val = await worm.encodeVal("0x600D")
2
3// Write an entry // Tulis entri
4const calldata = func + key.slice(2) + val.slice(2)

Ethers mengharapkan data panggilan berupa string hex, 0x diikuti oleh jumlah digit heksadesimal genap. Karena key dan val keduanya dimulai dengan 0x, kita perlu menghapus header tersebut.

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

Seperti halnya kode pengujian Solidity, kita tidak dapat memanggil fungsi yang di-cache secara normal. Sebaliknya, kita perlu menggunakan mekanisme tingkat yang lebih rendah.

1 .
2 .
3 .
4 // Read the entry just written // Baca entri yang baru saja ditulis
5 const realKey = '0x' + key.slice(4) // remove the FF flag // hapus bendera FF
6 const entryRead = await worm.readEntry(realKey)
7 .
8 .
9 .
Tampilkan semua

Untuk membaca entri kita dapat menggunakan mekanisme normal. Tidak perlu menggunakan caching parameter dengan fungsi view.

Kesimpulan

Kode dalam artikel ini adalah bukti konsep, tujuannya adalah untuk membuat ide tersebut mudah dipahami. Untuk sistem yang siap produksi, Anda mungkin ingin mengimplementasikan beberapa fungsionalitas tambahan:

  • Menangani nilai yang bukan uint256. Misalnya, string.

  • Alih-alih cache global, mungkin memiliki pemetaan antara pengguna dan cache. Pengguna yang berbeda menggunakan nilai yang berbeda.

  • Nilai yang digunakan untuk alamat berbeda dari yang digunakan untuk tujuan lain. Mungkin masuk akal untuk memiliki cache terpisah hanya untuk alamat.

  • Saat ini, kunci cache menggunakan algoritma "datang pertama, kunci terkecil". Enam belas nilai pertama dapat dikirim sebagai satu byte. 4080 nilai berikutnya dapat dikirim sebagai dua byte. Sekitar satu juta nilai berikutnya adalah tiga byte, dll. Sistem produksi harus menyimpan penghitung penggunaan pada entri cache dan mengatur ulangnya sehingga enam belas nilai paling umum adalah satu byte, 4080 nilai paling umum berikutnya dua byte, dll.

    Namun, itu adalah operasi yang berpotensi berbahaya. Bayangkan urutan peristiwa berikut:

    1. Noam Naive memanggil encodeVal untuk menyandikan alamat tujuan pengiriman tokennya. Alamat tersebut adalah salah satu yang pertama digunakan pada aplikasi, jadi nilai yang disandikan adalah 0x06. Ini adalah fungsi view, bukan transaksi, jadi ini antara Noam dan node yang dia gunakan, dan tidak ada orang lain yang mengetahuinya

    2. Owen Owner menjalankan operasi penataan ulang cache. Sangat sedikit orang yang benar-benar menggunakan alamat tersebut, sehingga sekarang disandikan sebagai 0x201122. Nilai yang berbeda, 1018, ditetapkan 0x06.

    3. Noam Naive mengirimkan tokennya ke 0x06. Token tersebut masuk ke alamat 0x0000000000000000000000000de0b6b3a7640000, dan karena tidak ada yang mengetahui kunci pribadi untuk alamat tersebut, token tersebut hanya tertahan di sana. Noam tidak senang.

    Ada cara untuk memecahkan masalah ini, dan masalah terkait transaksi yang ada di mempool selama penataan ulang cache, tetapi Anda harus menyadarinya.

Saya mendemonstrasikan caching di sini dengan Optimism, karena saya adalah karyawan Optimism dan ini adalah rollup yang paling saya ketahui. Tetapi ini seharusnya berfungsi dengan rollup apa pun yang membebankan biaya minimal untuk pemrosesan internal, sehingga sebagai perbandingan, menulis data transaksi ke L1 adalah pengeluaran utama.

Lihat di sini untuk karya saya yang lain (opens in a new tab).

Pembaruan terakhir halaman: 25 Februari 2026

Apakah tutorial ini membantu?